Added OpenStack services

Change-Id: I9aadad2919a6a7400d6f11a884317e33e787b416
diff --git a/molecule/default/requirements.txt b/molecule/default/requirements.txt
index 4f5af9e..7f9f540 100644
--- a/molecule/default/requirements.txt
+++ b/molecule/default/requirements.txt
@@ -1 +1,2 @@
+molecule==3.5.2 # https://github.com/ansible-community/molecule/issues/3435
 openstacksdk
\ No newline at end of file
diff --git a/playbooks/openstack.yml b/playbooks/openstack.yml
new file mode 100644
index 0000000..31d0a04
--- /dev/null
+++ b/playbooks/openstack.yml
@@ -0,0 +1,112 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+- hosts: controllers[0]
+  gather_facts: false
+  become: true
+  roles:
+    - role: cilium
+      tags:
+        - cilium
+
+    - role: ceph_csi_rbd
+      tags:
+        - ceph-csi-rbd
+
+    - role: kube_prometheus_stack
+      tags:
+        - kube-prometheus-stack
+
+    - role: node_feature_discovery
+      tags:
+        - node-feature-discovery
+
+    - role: prometheus_pushgateway
+      tags:
+        - prometheus-pushgateway
+
+    - role: openstack_namespace
+      tags:
+        - openstack-namespace
+
+    - role: ingress_nginx
+      tags:
+        - ingress-nginx
+
+    - role: cert_manager
+      tags:
+        - cert-manager
+
+    - role: percona_xtradb_cluster
+      tags:
+        - percona-xtradb-cluster
+
+    - role: openstack_helm_infra_memcached
+      tags:
+        - openstack-helm-infra-memcached
+
+    - role: openstack_helm_infra_rabbitmq
+      tags:
+        - openstack-helm-infra-rabbitmq
+
+    - role: openstack_helm_keystone
+      tags:
+        - openstack-helm-keystone
+
+    - role: openstack_helm_infra_ceph_provisioners
+      tags:
+        - openstack-helm-infra-ceph-provisioners
+
+    - role: openstack_helm_glance
+      tags:
+        - openstack-helm-glance
+
+    - role: openstack_helm_cinder
+      tags:
+        - openstack-helm-cinder
+
+    - role: openstack_helm_placement
+      tags:
+        - openstack-helm-placement
+
+    - role: openstack_helm_infra_openvswitch
+      tags:
+        - openstack-helm-infra-openvswitch
+
+    - role: openstack_helm_infra_libvirt
+      tags:
+        - openstack-helm-infra-libvirt
+
+    - role: openstack_helm_neutron
+      tags:
+        - openstack-helm-neutron
+
+    - role: openstack_helm_nova
+      tags:
+        - openstack-helm-nova
+
+    - role: openstack_helm_heat
+      tags:
+        - openstack-helm-heat
+
+    - role: openstack_helm_horizon
+      tags:
+        - openstack-helm-horizon
+
+- hosts: controllers
+  gather_facts: false
+  roles:
+    - role: openstack_cli
+      tags:
+        - openstack-cli
diff --git a/playbooks/site.yml b/playbooks/site.yml
index 2b745d4..2966d48 100644
--- a/playbooks/site.yml
+++ b/playbooks/site.yml
@@ -14,3 +14,4 @@
 
 - import_playbook: vexxhost.atmosphere.ceph
 - import_playbook: vexxhost.atmosphere.kubernetes
+- import_playbook: vexxhost.atmosphere.openstack
diff --git a/plugins/module_utils/ca_common.py b/plugins/module_utils/ca_common.py
new file mode 100644
index 0000000..380463b
--- /dev/null
+++ b/plugins/module_utils/ca_common.py
@@ -0,0 +1,114 @@
+import os
+import datetime
+
+
+def generate_ceph_cmd(sub_cmd, args, user_key=None, cluster='ceph', user='client.admin', container_image=None, interactive=False):
+    '''
+    Generate 'ceph' command line to execute
+    '''
+
+    if not user_key:
+        user_key = '/etc/ceph/{}.{}.keyring'.format(cluster, user)
+
+    cmd = pre_generate_ceph_cmd(
+        container_image=container_image, interactive=interactive)
+
+    base_cmd = [
+        '-n',
+        user,
+        '-k',
+        user_key,
+        '--cluster',
+        cluster
+    ]
+    base_cmd.extend(sub_cmd)
+    cmd.extend(base_cmd + args)
+
+    return cmd
+
+
+def container_exec(binary, container_image, interactive=False):
+    '''
+    Build the docker CLI to run a command inside a container
+    '''
+
+    container_binary = os.getenv('CEPH_CONTAINER_BINARY')
+    command_exec = [container_binary, 'run']
+
+    if interactive:
+        command_exec.extend(['--interactive'])
+
+    command_exec.extend(['--rm',
+                         '--net=host',
+                         '-v', '/etc/ceph:/etc/ceph:z',
+                         '-v', '/var/lib/ceph/:/var/lib/ceph/:z',
+                         '-v', '/var/log/ceph/:/var/log/ceph/:z',
+                         '--entrypoint=' + binary, container_image])
+    return command_exec
+
+
+def is_containerized():
+    '''
+    Check if we are running on a containerized cluster
+    '''
+
+    if 'CEPH_CONTAINER_IMAGE' in os.environ:
+        container_image = os.getenv('CEPH_CONTAINER_IMAGE')
+    else:
+        container_image = None
+
+    return container_image
+
+
+def pre_generate_ceph_cmd(container_image=None, interactive=False):
+    '''
+    Generate ceph prefix comaand
+    '''
+    if container_image:
+        cmd = container_exec('ceph', container_image, interactive=interactive)
+    else:
+        cmd = ['ceph']
+
+    return cmd
+
+
+def exec_command(module, cmd, stdin=None):
+    '''
+    Execute command(s)
+    '''
+
+    binary_data = False
+    if stdin:
+        binary_data = True
+    rc, out, err = module.run_command(cmd, data=stdin, binary_data=binary_data)
+
+    return rc, cmd, out, err
+
+
+def exit_module(module, out, rc, cmd, err, startd, changed=False, diff=dict(before="", after="")):
+    endd = datetime.datetime.now()
+    delta = endd - startd
+
+    result = dict(
+        cmd=cmd,
+        start=str(startd),
+        end=str(endd),
+        delta=str(delta),
+        rc=rc,
+        stdout=out.rstrip("\r\n"),
+        stderr=err.rstrip("\r\n"),
+        changed=changed,
+        diff=diff
+    )
+    module.exit_json(**result)
+
+
+def fatal(message, module):
+    '''
+    Report a fatal error and exit
+    '''
+
+    if module:
+        module.fail_json(msg=message, rc=1)
+    else:
+        raise(Exception(message))
diff --git a/plugins/modules/ceph_key.py b/plugins/modules/ceph_key.py
new file mode 100644
index 0000000..437a0d3
--- /dev/null
+++ b/plugins/modules/ceph_key.py
@@ -0,0 +1,692 @@
+#!/usr/bin/python3
+
+# Copyright 2018, Red Hat, Inc.
+#
+# 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.
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.vexxhost.atmosphere.plugins.module_utils.ca_common import generate_ceph_cmd, \
+    is_containerized, \
+    container_exec, \
+    fatal
+
+import datetime
+import json
+import os
+import struct
+import time
+import base64
+import socket
+
+
+ANSIBLE_METADATA = {
+    'metadata_version': '1.1',
+    'status': ['preview'],
+    'supported_by': 'community'
+}
+
+DOCUMENTATION = '''
+---
+module: ceph_key
+
+author: Sebastien Han <seb@redhat.com>
+
+short_description: Manage Cephx key(s)
+
+version_added: "2.6"
+
+description:
+    - Manage CephX creation, deletion and updates.
+    It can also list and get information about keyring(s).
+options:
+    cluster:
+        description:
+            - The ceph cluster name.
+        required: false
+        default: ceph
+    name:
+        description:
+            - name of the CephX key
+        required: true
+    user:
+        description:
+            - entity used to perform operation.
+            It corresponds to the -n option (--name)
+        required: false
+    user_key:
+        description:
+            - the path to the keyring corresponding to the
+            user being used.
+            It corresponds to the -k option (--keyring)
+    state:
+        description:
+            - If 'present' is used, the module creates a keyring
+            with the associated capabilities.
+            If 'present' is used and a secret is provided the module
+            will always add the key. Which means it will update
+            the keyring if the secret changes, the same goes for
+            the capabilities.
+            If 'absent' is used, the module will simply delete the keyring.
+            If 'list' is used, the module will list all the keys and will
+            return a json output.
+            If 'info' is used, the module will return in a json format the
+            description of a given keyring.
+            If 'generate_secret' is used, the module will simply output a cephx keyring.
+        required: false
+        choices: ['present', 'update', 'absent', 'list', 'info', 'fetch_initial_keys', 'generate_secret']
+        default: present
+    caps:
+        description:
+            - CephX key capabilities
+        default: None
+        required: false
+    secret:
+        description:
+            - keyring's secret value
+        required: false
+        default: None
+    import_key:
+        description:
+            - Wether or not to import the created keyring into Ceph.
+            This can be useful for someone that only wants to generate keyrings
+            but not add them into Ceph.
+        required: false
+        default: True
+    dest:
+        description:
+            - Destination to write the keyring, can a file or a directory
+        required: false
+        default: /etc/ceph/
+    fetch_initial_keys:
+        description:
+            - Fetch client.admin and bootstrap key.
+            This is only needed for Nautilus and above.
+            Writes down to the filesystem the initial keys generated by the monitor.  # noqa: E501
+            This command can ONLY run from a monitor node.
+        required: false
+        default: false
+    output_format:
+        description:
+            - The key output format when retrieving the information of an
+            entity.
+        required: false
+        default: json
+'''
+
+EXAMPLES = '''
+
+keys_to_create:
+  - { name: client.key, key: "AQAin8tUUK84ExAA/QgBtI7gEMWdmnvKBzlXdQ==", caps: { mon: "allow rwx", mds: "allow *" } , mode: "0600" }  # noqa: E501
+  - { name: client.cle, caps: { mon: "allow r", osd: "allow *" } , mode: "0600" }  # noqa: E501
+
+caps:
+  mon: "allow rwx"
+  mds: "allow *"
+
+- name: create ceph admin key
+  ceph_key:
+    name: client.admin
+    state: present
+    secret: AQAin8tU2DsKFBAAFIAzVTzkL3+gtAjjpQiomw==
+    caps:
+      mon: allow *
+      osd: allow *
+      mgr: allow *
+      mds: allow
+    mode: 0400
+    import_key: False
+
+- name: create monitor initial keyring
+  ceph_key:
+    name: mon.
+    state: present
+    secret: AQAin8tUMICVFBAALRHNrV0Z4MXupRw4v9JQ6Q==
+    caps:
+      mon: allow *
+    dest: "/var/lib/ceph/tmp/"
+    import_key: False
+
+- name: create cephx key
+  ceph_key:
+    name: "{{ keys_to_create }}"
+    user: client.bootstrap-rgw
+    user_key: /var/lib/ceph/bootstrap-rgw/ceph.keyring
+    state: present
+    caps: "{{ caps }}"
+
+- name: create cephx key but don't import it in Ceph
+  ceph_key:
+    name: "{{ keys_to_create }}"
+    state: present
+    caps: "{{ caps }}"
+    import_key: False
+
+- name: delete cephx key
+  ceph_key:
+    name: "my_key"
+    state: absent
+
+- name: info cephx key
+  ceph_key:
+    name: "my_key""
+    state: info
+
+- name: info cephx admin key (plain)
+  ceph_key:
+    name: client.admin
+    output_format: plain
+    state: info
+  register: client_admin_key
+
+- name: list cephx keys
+  ceph_key:
+    state: list
+
+- name: fetch cephx keys
+  ceph_key:
+    state: fetch_initial_keys
+'''
+
+RETURN = '''#  '''
+
+
+CEPH_INITIAL_KEYS = ['client.admin', 'client.bootstrap-mds', 'client.bootstrap-mgr',  # noqa: E501
+                     'client.bootstrap-osd', 'client.bootstrap-rbd', 'client.bootstrap-rbd-mirror', 'client.bootstrap-rgw']  # noqa: E501
+
+
+def str_to_bool(val):
+    try:
+        val = val.lower()
+    except AttributeError:
+        val = str(val).lower()
+    if val == 'true':
+        return True
+    elif val == 'false':
+        return False
+    else:
+        raise ValueError("Invalid input value: %s" % val)
+
+
+def generate_secret():
+    '''
+    Generate a CephX secret
+    '''
+
+    key = os.urandom(16)
+    header = struct.pack('<hiih', 1, int(time.time()), 0, len(key))
+    secret = base64.b64encode(header + key)
+
+    return secret
+
+
+def generate_caps(_type, caps):
+    '''
+    Generate CephX capabilities list
+    '''
+
+    caps_cli = []
+
+    for k, v in caps.items():
+        # makes sure someone didn't pass an empty var,
+        # we don't want to add an empty cap
+        if len(k) == 0:
+            continue
+        if _type == "ceph-authtool":
+            caps_cli.extend(["--cap"])
+        caps_cli.extend([k, v])
+
+    return caps_cli
+
+
+def generate_ceph_authtool_cmd(cluster, name, secret, caps, dest, container_image=None):  # noqa: E501
+    '''
+    Generate 'ceph-authtool' command line to execute
+    '''
+
+    if container_image:
+        binary = 'ceph-authtool'
+        cmd = container_exec(
+            binary, container_image)
+    else:
+        binary = ['ceph-authtool']
+        cmd = binary
+
+    base_cmd = [
+        '--create-keyring',
+        dest,
+        '--name',
+        name,
+        '--add-key',
+        secret,
+    ]
+
+    cmd.extend(base_cmd)
+    cmd.extend(generate_caps("ceph-authtool", caps))
+
+    return cmd
+
+
+def create_key(module, result, cluster, user, user_key, name, secret, caps, import_key, dest, container_image=None):  # noqa: E501
+    '''
+    Create a CephX key
+    '''
+
+    cmd_list = []
+    if not secret:
+        secret = generate_secret()
+
+    if user == 'client.admin':
+        args = ['import', '-i', dest]
+    else:
+        args = ['get-or-create', name]
+        args.extend(generate_caps(None, caps))
+        args.extend(['-o', dest])
+
+    cmd_list.append(generate_ceph_authtool_cmd(
+        cluster, name, secret, caps, dest, container_image))
+
+    if import_key or user != 'client.admin':
+        cmd_list.append(generate_ceph_cmd(sub_cmd=['auth'],
+                                          args=args,
+                                          cluster=cluster,
+                                          user=user,
+                                          user_key=user_key,
+                                          container_image=container_image))
+
+    return cmd_list
+
+
+def delete_key(cluster, user, user_key, name, container_image=None):
+    '''
+    Delete a CephX key
+    '''
+
+    cmd_list = []
+
+    args = [
+        'del',
+        name,
+    ]
+
+    cmd_list.append(generate_ceph_cmd(sub_cmd=['auth'],
+                                      args=args,
+                                      cluster=cluster,
+                                      user=user,
+                                      user_key=user_key,
+                                      container_image=container_image))
+
+    return cmd_list
+
+
+def get_key(cluster, user, user_key, name, dest, container_image=None):
+    '''
+    Get a CephX key (write on the filesystem)
+    '''
+
+    cmd_list = []
+
+    args = [
+        'get',
+        name,
+        '-o',
+        dest,
+    ]
+
+    cmd_list.append(generate_ceph_cmd(sub_cmd=['auth'],
+                                      args=args,
+                                      cluster=cluster,
+                                      user=user,
+                                      user_key=user_key,
+                                      container_image=container_image))
+
+    return cmd_list
+
+
+def info_key(cluster, name, user, user_key, output_format, container_image=None):  # noqa: E501
+    '''
+    Get information about a CephX key
+    '''
+
+    cmd_list = []
+
+    args = [
+        'get',
+        name,
+        '-f',
+        output_format,
+    ]
+
+    cmd_list.append(generate_ceph_cmd(sub_cmd=['auth'],
+                                      args=args,
+                                      cluster=cluster,
+                                      user=user,
+                                      user_key=user_key,
+                                      container_image=container_image))
+
+    return cmd_list
+
+
+def list_keys(cluster, user, user_key, container_image=None):
+    '''
+    List all CephX keys
+    '''
+
+    cmd_list = []
+
+    args = [
+        'ls',
+        '-f',
+        'json',
+    ]
+
+    cmd_list.append(generate_ceph_cmd(sub_cmd=['auth'],
+                                      args=args,
+                                      cluster=cluster,
+                                      user=user,
+                                      user_key=user_key,
+                                      container_image=container_image))
+
+    return cmd_list
+
+
+def exec_commands(module, cmd_list):
+    '''
+    Execute command(s)
+    '''
+
+    for cmd in cmd_list:
+        rc, out, err = module.run_command(cmd)
+        if rc != 0:
+            return rc, cmd, out, err
+
+    return rc, cmd, out, err
+
+
+def lookup_ceph_initial_entities(module, out):
+    '''
+    Lookup Ceph initial keys entries in the auth map
+    '''
+
+    # convert out to json, ansible returns a string...
+    try:
+        out_dict = json.loads(out)
+    except ValueError as e:
+        fatal("Could not decode 'ceph auth list' json output: {}".format(e), module)  # noqa: E501
+
+    entities = []
+    if "auth_dump" in out_dict:
+        for key in out_dict["auth_dump"]:
+            for k, v in key.items():
+                if k == "entity":
+                    if v in CEPH_INITIAL_KEYS:
+                        entities.append(v)
+    else:
+        fatal("'auth_dump' key not present in json output:", module)  # noqa: E501
+
+    if len(entities) != len(CEPH_INITIAL_KEYS) and not str_to_bool(os.environ.get('CEPH_ROLLING_UPDATE', False)):  # noqa: E501
+        # must be missing in auth_dump, as if it were in CEPH_INITIAL_KEYS
+        # it'd be in entities from the above test. Report what's missing.
+        missing = []
+        for e in CEPH_INITIAL_KEYS:
+            if e not in entities:
+                missing.append(e)
+        fatal("initial keyring does not contain keys: " + ' '.join(missing), module)  # noqa: E501
+    return entities
+
+
+def build_key_path(cluster, entity):
+    '''
+    Build key path depending on the key type
+    '''
+
+    if "admin" in entity:
+        path = "/etc/ceph"
+        keyring_filename = cluster + "." + entity + ".keyring"
+        key_path = os.path.join(path, keyring_filename)
+    elif "bootstrap" in entity:
+        path = "/var/lib/ceph"
+        # bootstrap keys show up as 'client.boostrap-osd'
+        # however the directory is called '/var/lib/ceph/bootstrap-osd'
+        # so we need to substring 'client.'
+        entity_split = entity.split('.')[1]
+        keyring_filename = cluster + ".keyring"
+        key_path = os.path.join(path, entity_split, keyring_filename)
+    else:
+        return None
+
+    return key_path
+
+
+def run_module():
+    module_args = dict(
+        cluster=dict(type='str', required=False, default='ceph'),
+        name=dict(type='str', required=False),
+        state=dict(type='str', required=False, default='present', choices=['present', 'update', 'absent',  # noqa: E501
+                                                                           'list', 'info', 'fetch_initial_keys', 'generate_secret']),  # noqa: E501
+        caps=dict(type='dict', required=False, default=None),
+        secret=dict(type='str', required=False, default=None, no_log=True),
+        import_key=dict(type='bool', required=False, default=True),
+        dest=dict(type='str', required=False, default='/etc/ceph/'),
+        user=dict(type='str', required=False, default='client.admin'),
+        user_key=dict(type='str', required=False, default=None),
+        output_format=dict(type='str', required=False, default='json', choices=['json', 'plain', 'xml', 'yaml'])  # noqa: E501
+    )
+
+    module = AnsibleModule(
+        argument_spec=module_args,
+        supports_check_mode=True,
+        add_file_common_args=True,
+    )
+
+    file_args = module.load_file_common_arguments(module.params)
+
+    # Gather module parameters in variables
+    state = module.params['state']
+    name = module.params.get('name')
+    cluster = module.params.get('cluster')
+    caps = module.params.get('caps')
+    secret = module.params.get('secret')
+    import_key = module.params.get('import_key')
+    dest = module.params.get('dest')
+    user = module.params.get('user')
+    user_key = module.params.get('user_key')
+    output_format = module.params.get('output_format')
+
+    changed = False
+
+    result = dict(
+        changed=changed,
+        stdout='',
+        stderr='',
+        rc=0,
+        start='',
+        end='',
+        delta='',
+    )
+
+    if module.check_mode and state != "info":
+        module.exit_json(**result)
+
+    startd = datetime.datetime.now()
+
+    # will return either the image name or None
+    container_image = is_containerized()
+
+    # Test if the key exists, if it does we skip its creation
+    # We only want to run this check when a key needs to be added
+    # There is no guarantee that any cluster is running and we don't need one
+    _secret = secret
+    _caps = caps
+    key_exist = 1
+
+    if not user_key:
+        user_key_filename = '{}.{}.keyring'.format(cluster, user)
+        user_key_dir = '/etc/ceph'
+        user_key_path = os.path.join(user_key_dir, user_key_filename)
+    else:
+        user_key_path = user_key
+
+    if (state in ["present", "update"]):
+        # if dest is not a directory, the user wants to change the file's name
+        # (e,g: /etc/ceph/ceph.mgr.ceph-mon2.keyring)
+        if not os.path.isdir(dest):
+            file_path = dest
+        else:
+            if 'bootstrap' in dest:
+                # Build a different path for bootstrap keys as there are stored
+                # as /var/lib/ceph/bootstrap-rbd/ceph.keyring
+                keyring_filename = cluster + '.keyring'
+            else:
+                keyring_filename = cluster + "." + name + ".keyring"
+            file_path = os.path.join(dest, keyring_filename)
+
+        file_args['path'] = file_path
+
+        if import_key:
+            _info_key = []
+            rc, cmd, out, err = exec_commands(
+                module, info_key(cluster, name, user, user_key_path, output_format, container_image))  # noqa: E501
+            key_exist = rc
+            if not caps and key_exist != 0:
+                fatal("Capabilities must be provided when state is 'present'", module)  # noqa: E501
+            if key_exist != 0 and secret is None and caps is None:
+                fatal("Keyring doesn't exist, you must provide 'secret' and 'caps'", module)  # noqa: E501
+            if key_exist == 0:
+                _info_key = json.loads(out)
+                if not secret:
+                    secret = _info_key[0]['key']
+                _secret = _info_key[0]['key']
+                if not caps:
+                    caps = _info_key[0]['caps']
+                _caps = _info_key[0]['caps']
+                if secret == _secret and caps == _caps:
+                    if not os.path.isfile(file_path):
+                        rc, cmd, out, err = exec_commands(module, get_key(cluster, user, user_key_path, name, file_path, container_image))  # noqa: E501
+                        result["rc"] = rc
+                        if rc != 0:
+                            result["stdout"] = "Couldn't fetch the key {0} at {1}.".format(name, file_path)  # noqa: E501
+                            module.exit_json(**result)
+                        result["stdout"] = "fetched the key {0} at {1}.".format(name, file_path)  # noqa: E501
+
+                    result["stdout"] = "{0} already exists and doesn't need to be updated.".format(name)  # noqa: E501
+                    result["rc"] = 0
+                    module.set_fs_attributes_if_different(file_args, False)
+                    module.exit_json(**result)
+        else:
+            if os.path.isfile(file_path) and not secret or not caps:
+                result["stdout"] = "{0} already exists in {1} you must provide secret *and* caps when import_key is {2}".format(name, dest, import_key)  # noqa: E501
+                result["rc"] = 0
+                module.exit_json(**result)
+        if (key_exist == 0 and (secret != _secret or caps != _caps)) or key_exist != 0:  # noqa: E501
+            rc, cmd, out, err = exec_commands(module, create_key(
+                module, result, cluster, user, user_key_path, name, secret, caps, import_key, file_path, container_image))  # noqa: E501
+            if rc != 0:
+                result["stdout"] = "Couldn't create or update {0}".format(name)
+                result["stderr"] = err
+                module.exit_json(**result)
+            module.set_fs_attributes_if_different(file_args, False)
+            changed = True
+
+    elif state == "absent":
+        if key_exist == 0:
+            rc, cmd, out, err = exec_commands(
+                module, delete_key(cluster, user, user_key_path, name, container_image))  # noqa: E501
+            if rc == 0:
+                changed = True
+        else:
+            rc = 0
+
+    elif state == "info":
+        rc, cmd, out, err = exec_commands(
+            module, info_key(cluster, name, user, user_key_path, output_format, container_image))  # noqa: E501
+
+    elif state == "list":
+        rc, cmd, out, err = exec_commands(
+            module, list_keys(cluster, user, user_key_path, container_image))
+
+    elif state == "fetch_initial_keys":
+        hostname = socket.gethostname().split('.', 1)[0]
+        user = "mon."
+        keyring_filename = cluster + "-" + hostname + "/keyring"
+        user_key_path = os.path.join("/var/lib/ceph/mon/", keyring_filename)
+        rc, cmd, out, err = exec_commands(
+            module, list_keys(cluster, user, user_key_path, container_image))
+        if rc != 0:
+            result["stdout"] = "failed to retrieve ceph keys"
+            result["sdterr"] = err
+            result['rc'] = 0
+            module.exit_json(**result)
+
+        entities = lookup_ceph_initial_entities(module, out)
+
+        output_format = "plain"
+        for entity in entities:
+            key_path = build_key_path(cluster, entity)
+            if key_path is None:
+                fatal("Failed to build key path, no entity yet?", module)
+            elif os.path.isfile(key_path):
+                # if the key is already on the filesystem
+                # there is no need to fetch it again
+                continue
+
+            extra_args = [
+                '-o',
+                key_path,
+            ]
+
+            info_cmd = info_key(cluster, entity, user,
+                                user_key_path, output_format, container_image)
+            # we use info_cmd[0] because info_cmd is an array made of an array
+            info_cmd[0].extend(extra_args)
+            rc, cmd, out, err = exec_commands(
+                module, info_cmd)  # noqa: E501
+
+            file_args = module.load_file_common_arguments(module.params)
+            file_args['path'] = key_path
+            module.set_fs_attributes_if_different(file_args, False)
+    elif state == "generate_secret":
+        out = generate_secret().decode()
+        cmd = ''
+        rc = 0
+        err = ''
+        changed = True
+
+    endd = datetime.datetime.now()
+    delta = endd - startd
+
+    result = dict(
+        cmd=cmd,
+        start=str(startd),
+        end=str(endd),
+        delta=str(delta),
+        rc=rc,
+        stdout=out.rstrip("\r\n"),
+        stderr=err.rstrip("\r\n"),
+        changed=changed,
+    )
+
+    if rc != 0:
+        module.fail_json(msg='non-zero return code', **result)
+
+    module.exit_json(**result)
+
+
+def main():
+    run_module()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/plugins/modules/ceph_pool.py b/plugins/modules/ceph_pool.py
new file mode 100644
index 0000000..ff91f76
--- /dev/null
+++ b/plugins/modules/ceph_pool.py
@@ -0,0 +1,684 @@
+#!/usr/bin/python3
+
+# Copyright 2020, Red Hat, Inc.
+#
+# 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.
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+from ansible.module_utils.basic import AnsibleModule
+from ansible_collections.vexxhost.atmosphere.plugins.module_utils.ca_common import generate_ceph_cmd, \
+    pre_generate_ceph_cmd, \
+    is_containerized, \
+    exec_command, \
+    exit_module
+
+import datetime
+import json
+import os
+
+
+ANSIBLE_METADATA = {
+    'metadata_version': '1.1',
+    'status': ['preview'],
+    'supported_by': 'community'
+}
+
+DOCUMENTATION = '''
+---
+module: ceph_pool
+
+author: Guillaume Abrioux <gabrioux@redhat.com>
+
+short_description: Manage Ceph Pools
+
+version_added: "2.8"
+
+description:
+    - Manage Ceph pool(s) creation, deletion and updates.
+options:
+    cluster:
+        description:
+            - The ceph cluster name.
+        required: false
+        default: ceph
+    name:
+        description:
+            - name of the Ceph pool
+        required: true
+    state:
+        description:
+            If 'present' is used, the module creates a pool if it doesn't exist
+            or update it if it already exists.
+            If 'absent' is used, the module will simply delete the pool.
+            If 'list' is used, the module will return all details about the
+            existing pools. (json formatted).
+        required: false
+        choices: ['present', 'absent', 'list']
+        default: present
+    size:
+        description:
+            - set the replica size of the pool.
+        required: false
+        default: 3
+    min_size:
+        description:
+            - set the min_size parameter of the pool.
+        required: false
+        default: default to `osd_pool_default_min_size` (ceph)
+    pg_num:
+        description:
+            - set the pg_num of the pool.
+        required: false
+        default: default to `osd_pool_default_pg_num` (ceph)
+    pgp_num:
+        description:
+            - set the pgp_num of the pool.
+        required: false
+        default: default to `osd_pool_default_pgp_num` (ceph)
+    pg_autoscale_mode:
+        description:
+            - set the pg autoscaler on the pool.
+        required: false
+        default: 'on'
+    target_size_ratio:
+        description:
+            - set the target_size_ratio on the pool
+        required: false
+        default: None
+    pool_type:
+        description:
+            - set the pool type, either 'replicated' or 'erasure'
+        required: false
+        default: 'replicated'
+    erasure_profile:
+        description:
+            - When pool_type = 'erasure', set the erasure profile of the pool
+        required: false
+        default: 'default'
+    rule_name:
+        description:
+            - Set the crush rule name assigned to the pool
+        required: false
+        default: 'replicated_rule' when pool_type is 'erasure' else None
+    expected_num_objects:
+        description:
+            -   Set the expected_num_objects parameter of the pool.
+        required: false
+        default: '0'
+    application:
+        description:
+            - Set the pool application on the pool.
+        required: false
+        default: None
+'''
+
+EXAMPLES = '''
+
+pools:
+  - { name: foo, size: 3, application: rbd, pool_type: 'replicated',
+      pg_autoscale_mode: 'on' }
+
+- hosts: all
+  become: true
+  tasks:
+    - name: create a pool
+      ceph_pool:
+        name: "{{ item.name }}"
+        state: present
+        size: "{{ item.size }}"
+        application: "{{ item.application }}"
+        pool_type: "{{ item.pool_type }}"
+        pg_autoscale_mode: "{{ item.pg_autoscale_mode }}"
+      with_items: "{{ pools }}"
+'''
+
+RETURN = '''#  '''
+
+
+def check_pool_exist(cluster,
+                     name,
+                     user,
+                     user_key,
+                     output_format='json',
+                     container_image=None):
+    '''
+    Check if a given pool exists
+    '''
+
+    args = ['stats', name, '-f', output_format]
+
+    cmd = generate_ceph_cmd(sub_cmd=['osd', 'pool'],
+                            args=args,
+                            cluster=cluster,
+                            user=user,
+                            user_key=user_key,
+                            container_image=container_image)
+
+    return cmd
+
+
+def generate_get_config_cmd(param,
+                            cluster,
+                            user,
+                            user_key,
+                            container_image=None):
+    _cmd = pre_generate_ceph_cmd(container_image=container_image)
+    args = [
+        '-n',
+        user,
+        '-k',
+        user_key,
+        '--cluster',
+        cluster,
+        'config',
+        'get',
+        'mon.*',
+        param
+    ]
+    cmd = _cmd + args
+    return cmd
+
+
+def get_application_pool(cluster,
+                         name,
+                         user,
+                         user_key,
+                         output_format='json',
+                         container_image=None):
+    '''
+    Get application type enabled on a given pool
+    '''
+
+    args = ['application', 'get', name, '-f', output_format]
+
+    cmd = generate_ceph_cmd(sub_cmd=['osd', 'pool'],
+                            args=args,
+                            cluster=cluster,
+                            user=user,
+                            user_key=user_key,
+                            container_image=container_image)
+
+    return cmd
+
+
+def enable_application_pool(cluster,
+                            name,
+                            application,
+                            user,
+                            user_key,
+                            container_image=None):
+    '''
+    Enable application on a given pool
+    '''
+
+    args = ['application', 'enable', name, application]
+
+    cmd = generate_ceph_cmd(sub_cmd=['osd', 'pool'],
+                            args=args,
+                            cluster=cluster,
+                            user=user,
+                            user_key=user_key,
+                            container_image=container_image)
+
+    return cmd
+
+
+def disable_application_pool(cluster,
+                             name,
+                             application,
+                             user,
+                             user_key,
+                             container_image=None):
+    '''
+    Disable application on a given pool
+    '''
+
+    args = ['application', 'disable', name,
+            application, '--yes-i-really-mean-it']
+
+    cmd = generate_ceph_cmd(sub_cmd=['osd', 'pool'],
+                            args=args,
+                            cluster=cluster,
+                            user=user,
+                            user_key=user_key,
+                            container_image=container_image)
+
+    return cmd
+
+
+def get_pool_details(module,
+                     cluster,
+                     name,
+                     user,
+                     user_key,
+                     output_format='json',
+                     container_image=None):
+    '''
+    Get details about a given pool
+    '''
+
+    args = ['ls', 'detail', '-f', output_format]
+
+    cmd = generate_ceph_cmd(sub_cmd=['osd', 'pool'],
+                            args=args,
+                            cluster=cluster,
+                            user=user,
+                            user_key=user_key,
+                            container_image=container_image)
+
+    rc, cmd, out, err = exec_command(module, cmd)
+
+    if rc == 0:
+        out = [p for p in json.loads(out.strip()) if p['pool_name'] == name][0]
+
+    _rc, _cmd, application_pool, _err = exec_command(module,
+                                                     get_application_pool(cluster,    # noqa: E501
+                                                                          name,    # noqa: E501
+                                                                          user,    # noqa: E501
+                                                                          user_key,    # noqa: E501
+                                                                          container_image=container_image))  # noqa: E501
+
+    # This is a trick because "target_size_ratio" isn't present at the same
+    # level in the dict
+    # ie:
+    # {
+    # 'pg_num': 8,
+    # 'pgp_num': 8,
+    # 'pg_autoscale_mode': 'on',
+    #     'options': {
+    #          'target_size_ratio': 0.1
+    #     }
+    # }
+    # If 'target_size_ratio' is present in 'options', we set it, this way we
+    # end up with a dict containing all needed keys at the same level.
+    if 'target_size_ratio' in out['options'].keys():
+        out['target_size_ratio'] = out['options']['target_size_ratio']
+    else:
+        out['target_size_ratio'] = None
+
+    application = list(json.loads(application_pool.strip()).keys())
+
+    if len(application) == 0:
+        out['application'] = ''
+    else:
+        out['application'] = application[0]
+
+    return rc, cmd, out, err
+
+
+def compare_pool_config(user_pool_config, running_pool_details):
+    '''
+    Compare user input config pool details with current running pool details
+    '''
+
+    delta = {}
+    filter_keys = ['pg_num', 'pg_placement_num', 'size',
+                   'pg_autoscale_mode', 'target_size_ratio']
+    for key in filter_keys:
+        if (str(running_pool_details[key]) != user_pool_config[key]['value'] and  # noqa: E501
+                user_pool_config[key]['value']):
+            delta[key] = user_pool_config[key]
+
+    if (running_pool_details['application'] !=
+            user_pool_config['application']['value'] and
+            user_pool_config['application']['value']):
+        delta['application'] = {}
+        delta['application']['new_application'] = user_pool_config['application']['value']  # noqa: E501
+        # to be improved (for update_pools()...)
+        delta['application']['value'] = delta['application']['new_application']
+        delta['application']['old_application'] = running_pool_details['application']  # noqa: E501
+
+    return delta
+
+
+def list_pools(cluster,
+               user,
+               user_key,
+               details,
+               output_format='json',
+               container_image=None):
+    '''
+    List existing pools
+    '''
+
+    args = ['ls']
+
+    if details:
+        args.append('detail')
+
+    args.extend(['-f', output_format])
+
+    cmd = generate_ceph_cmd(sub_cmd=['osd', 'pool'],
+                            args=args,
+                            cluster=cluster,
+                            user=user,
+                            user_key=user_key,
+                            container_image=container_image)
+
+    return cmd
+
+
+def create_pool(cluster,
+                name,
+                user,
+                user_key,
+                user_pool_config,
+                container_image=None):
+    '''
+    Create a new pool
+    '''
+
+    args = ['create', user_pool_config['pool_name']['value'],
+            user_pool_config['type']['value']]
+
+    if user_pool_config['pg_autoscale_mode']['value'] != 'on':
+        args.extend(['--pg_num',
+                     user_pool_config['pg_num']['value'],
+                     '--pgp_num',
+                     user_pool_config['pgp_num']['value'] or
+                     user_pool_config['pg_num']['value']])
+    elif user_pool_config['target_size_ratio']['value']:
+        args.extend(['--target_size_ratio',
+                     user_pool_config['target_size_ratio']['value']])
+
+    if user_pool_config['type']['value'] == 'replicated':
+        args.extend([user_pool_config['crush_rule']['value'],
+                     '--expected_num_objects',
+                     user_pool_config['expected_num_objects']['value'],
+                     '--autoscale-mode',
+                     user_pool_config['pg_autoscale_mode']['value']])
+
+    if (user_pool_config['size']['value'] and
+            user_pool_config['type']['value'] == "replicated"):
+        args.extend(['--size', user_pool_config['size']['value']])
+
+    elif user_pool_config['type']['value'] == 'erasure':
+        args.extend([user_pool_config['erasure_profile']['value']])
+
+        if user_pool_config['crush_rule']['value']:
+            args.extend([user_pool_config['crush_rule']['value']])
+
+        args.extend(['--expected_num_objects',
+                     user_pool_config['expected_num_objects']['value'],
+                     '--autoscale-mode',
+                     user_pool_config['pg_autoscale_mode']['value']])
+
+    cmd = generate_ceph_cmd(sub_cmd=['osd', 'pool'],
+                            args=args,
+                            cluster=cluster,
+                            user=user,
+                            user_key=user_key,
+                            container_image=container_image)
+
+    return cmd
+
+
+def remove_pool(cluster, name, user, user_key, container_image=None):
+    '''
+    Remove a pool
+    '''
+
+    args = ['rm', name, name, '--yes-i-really-really-mean-it']
+
+    cmd = generate_ceph_cmd(sub_cmd=['osd', 'pool'],
+                            args=args,
+                            cluster=cluster,
+                            user=user,
+                            user_key=user_key,
+                            container_image=container_image)
+
+    return cmd
+
+
+def update_pool(module, cluster, name,
+                user, user_key, delta, container_image=None):
+    '''
+    Update an existing pool
+    '''
+
+    report = ""
+
+    for key in delta.keys():
+        if key != 'application':
+            args = ['set',
+                    name,
+                    delta[key]['cli_set_opt'],
+                    delta[key]['value']]
+
+            cmd = generate_ceph_cmd(sub_cmd=['osd', 'pool'],
+                                    args=args,
+                                    cluster=cluster,
+                                    user=user,
+                                    user_key=user_key,
+                                    container_image=container_image)
+
+            rc, cmd, out, err = exec_command(module, cmd)
+            if rc != 0:
+                return rc, cmd, out, err
+
+        else:
+            rc, cmd, out, err = exec_command(module, disable_application_pool(cluster, name, delta['application']['old_application'], user, user_key, container_image=container_image))  # noqa: E501
+            if rc != 0:
+                return rc, cmd, out, err
+
+            rc, cmd, out, err = exec_command(module, enable_application_pool(cluster, name, delta['application']['new_application'], user, user_key, container_image=container_image))  # noqa: E501
+            if rc != 0:
+                return rc, cmd, out, err
+
+        report = report + "\n" + "{} has been updated: {} is now {}".format(name, key, delta[key]['value'])  # noqa: E501
+
+    out = report
+    return rc, cmd, out, err
+
+
+def run_module():
+    module_args = dict(
+        cluster=dict(type='str', required=False, default='ceph'),
+        name=dict(type='str', required=True),
+        state=dict(type='str', required=False, default='present',
+                   choices=['present', 'absent', 'list']),
+        details=dict(type='bool', required=False, default=False),
+        size=dict(type='str', required=False),
+        min_size=dict(type='str', required=False),
+        pg_num=dict(type='str', required=False),
+        pgp_num=dict(type='str', required=False),
+        pg_autoscale_mode=dict(type='str', required=False, default='on'),
+        target_size_ratio=dict(type='str', required=False, default=None),
+        pool_type=dict(type='str', required=False, default='replicated',
+                       choices=['replicated', 'erasure', '1', '3']),
+        erasure_profile=dict(type='str', required=False, default='default'),
+        rule_name=dict(type='str', required=False, default=None),
+        expected_num_objects=dict(type='str', required=False, default="0"),
+        application=dict(type='str', required=False, default=None),
+    )
+
+    module = AnsibleModule(
+        argument_spec=module_args,
+        supports_check_mode=True
+    )
+
+    # Gather module parameters in variables
+    cluster = module.params.get('cluster')
+    name = module.params.get('name')
+    state = module.params.get('state')
+    details = module.params.get('details')
+    size = module.params.get('size')
+    min_size = module.params.get('min_size')
+    pg_num = module.params.get('pg_num')
+    pgp_num = module.params.get('pgp_num')
+    pg_autoscale_mode = module.params.get('pg_autoscale_mode')
+    target_size_ratio = module.params.get('target_size_ratio')
+    application = module.params.get('application')
+
+    if (module.params.get('pg_autoscale_mode').lower() in
+            ['true', 'on', 'yes']):
+        pg_autoscale_mode = 'on'
+    elif (module.params.get('pg_autoscale_mode').lower() in
+          ['false', 'off', 'no']):
+        pg_autoscale_mode = 'off'
+    else:
+        pg_autoscale_mode = 'warn'
+
+    if module.params.get('pool_type') == '1':
+        pool_type = 'replicated'
+    elif module.params.get('pool_type') == '3':
+        pool_type = 'erasure'
+    else:
+        pool_type = module.params.get('pool_type')
+
+    if not module.params.get('rule_name'):
+        rule_name = 'replicated_rule' if pool_type == 'replicated' else None
+    else:
+        rule_name = module.params.get('rule_name')
+
+    erasure_profile = module.params.get('erasure_profile')
+    expected_num_objects = module.params.get('expected_num_objects')
+    user_pool_config = {
+        'pool_name': {'value': name},
+        'pg_num': {'value': pg_num, 'cli_set_opt': 'pg_num'},
+        'pgp_num': {'value': pgp_num, 'cli_set_opt': 'pgp_num'},
+        'pg_autoscale_mode': {'value': pg_autoscale_mode,
+                              'cli_set_opt': 'pg_autoscale_mode'},
+        'target_size_ratio': {'value': target_size_ratio,
+                              'cli_set_opt': 'target_size_ratio'},
+        'application': {'value': application},
+        'type': {'value': pool_type},
+        'erasure_profile': {'value': erasure_profile},
+        'crush_rule': {'value': rule_name, 'cli_set_opt': 'crush_rule'},
+        'expected_num_objects': {'value': expected_num_objects},
+        'size': {'value': size, 'cli_set_opt': 'size'},
+        'min_size': {'value': min_size}
+    }
+
+    if module.check_mode:
+        module.exit_json(
+            changed=False,
+            stdout='',
+            stderr='',
+            rc=0,
+            start='',
+            end='',
+            delta='',
+        )
+
+    startd = datetime.datetime.now()
+    changed = False
+
+    # will return either the image name or None
+    container_image = is_containerized()
+
+    user = "client.admin"
+    keyring_filename = cluster + '.' + user + '.keyring'
+    user_key = os.path.join("/etc/ceph/", keyring_filename)
+
+    if state == "present":
+        rc, cmd, out, err = exec_command(module,
+                                         check_pool_exist(cluster,
+                                                          name,
+                                                          user,
+                                                          user_key,
+                                                          container_image=container_image))  # noqa: E501
+        if rc == 0:
+            running_pool_details = get_pool_details(module,
+                                                    cluster,
+                                                    name,
+                                                    user,
+                                                    user_key,
+                                                    container_image=container_image)  # noqa: E501
+            user_pool_config['pg_placement_num'] = {'value': str(running_pool_details[2]['pg_placement_num']), 'cli_set_opt': 'pgp_num'}  # noqa: E501
+            delta = compare_pool_config(user_pool_config,
+                                        running_pool_details[2])
+            if len(delta) > 0:
+                keys = list(delta.keys())
+                details = running_pool_details[2]
+                if details['erasure_code_profile'] and 'size' in keys:
+                    del delta['size']
+                if details['pg_autoscale_mode'] == 'on':
+                    delta.pop('pg_num', None)
+                    delta.pop('pgp_num', None)
+
+                if len(delta) == 0:
+                    out = "Skipping pool {}.\nUpdating either 'size' on an erasure-coded pool or 'pg_num'/'pgp_num' on a pg autoscaled pool is incompatible".format(name)  # noqa: E501
+                else:
+                    rc, cmd, out, err = update_pool(module,
+                                                    cluster,
+                                                    name,
+                                                    user,
+                                                    user_key,
+                                                    delta,
+                                                    container_image=container_image)  # noqa: E501
+                    if rc == 0:
+                        changed = True
+            else:
+                out = "Pool {} already exists and there is nothing to update.".format(name)  # noqa: E501
+        else:
+            rc, cmd, out, err = exec_command(module,
+                                             create_pool(cluster,
+                                                         name,
+                                                         user,
+                                                         user_key,
+                                                         user_pool_config=user_pool_config,  # noqa: E501
+                                                         container_image=container_image))  # noqa: E501
+            if user_pool_config['application']['value']:
+                rc, _, _, _ = exec_command(module,
+                                           enable_application_pool(cluster,
+                                                                   name,
+                                                                   user_pool_config['application']['value'],  # noqa: E501
+                                                                   user,
+                                                                   user_key,
+                                                                   container_image=container_image))  # noqa: E501
+            if user_pool_config['min_size']['value']:
+                # not implemented yet
+                pass
+            changed = True
+
+    elif state == "list":
+        rc, cmd, out, err = exec_command(module,
+                                         list_pools(cluster,
+                                                    name, user,
+                                                    user_key,
+                                                    details,
+                                                    container_image=container_image))  # noqa: E501
+        if rc != 0:
+            out = "Couldn't list pool(s) present on the cluster"
+
+    elif state == "absent":
+        rc, cmd, out, err = exec_command(module,
+                                         check_pool_exist(cluster,
+                                                          name, user,
+                                                          user_key,
+                                                          container_image=container_image))  # noqa: E501
+        if rc == 0:
+            rc, cmd, out, err = exec_command(module,
+                                             remove_pool(cluster,
+                                                         name,
+                                                         user,
+                                                         user_key,
+                                                         container_image=container_image))  # noqa: E501
+            changed = True
+        else:
+            rc = 0
+            out = "Skipped, since pool {} doesn't exist".format(name)
+
+    exit_module(module=module, out=out, rc=rc, cmd=cmd, err=err, startd=startd,
+                changed=changed)
+
+
+def main():
+    run_module()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/roles/ceph_csi_rbd/defaults/main.yml b/roles/ceph_csi_rbd/defaults/main.yml
new file mode 100644
index 0000000..fc16f2d
--- /dev/null
+++ b/roles/ceph_csi_rbd/defaults/main.yml
@@ -0,0 +1,20 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+ceph_csi_rbd_ceph_fsid: "{{ ceph_mon_fsid }}"
+ceph_csi_rbd_mons_group: ceph_mons
+
+ceph_csi_rbd_id: kube
+ceph_csi_rbd_user: "client.{{ ceph_csi_rbd_id }}"
+ceph_csi_rbd_pool: kube
diff --git a/roles/ceph_csi_rbd/meta/main.yml b/roles/ceph_csi_rbd/meta/main.yml
new file mode 100644
index 0000000..b3836f8
--- /dev/null
+++ b/roles/ceph_csi_rbd/meta/main.yml
@@ -0,0 +1,20 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: ceph
+      helm_repository_repo_url: https://ceph.github.io/csi-charts
+  - cilium
diff --git a/roles/ceph_csi_rbd/tasks/main.yml b/roles/ceph_csi_rbd/tasks/main.yml
new file mode 100644
index 0000000..43e8498
--- /dev/null
+++ b/roles/ceph_csi_rbd/tasks/main.yml
@@ -0,0 +1,72 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: collect facts for all monitors
+  run_once: true
+  delegate_to: "{{ item }}"
+  delegate_facts: true
+  ansible.builtin.setup:
+    gather_subset: network
+  loop: "{{ groups[ceph_csi_rbd_mons_group] }}"
+
+- vexxhost.atmosphere.ceph_pool:
+    name: "{{ ceph_csi_rbd_pool }}"
+    application: rbd
+    pg_autoscale_mode: on
+
+- vexxhost.atmosphere.ceph_key:
+    name: "{{ ceph_csi_rbd_user }}"
+    caps:
+      mon: profile rbd
+      mgr: profile rbd pool={{ ceph_csi_rbd_pool }}
+      osd: profile rbd pool={{ ceph_csi_rbd_pool }}
+
+- vexxhost.atmosphere.ceph_key:
+    name: "{{ ceph_csi_rbd_user }}"
+    state: info
+    output_format: json
+  register: _ceph_key
+
+- ansible.builtin.set_fact:
+    _ceph_rbd_csi_ceph_keyring: "{{ _ceph_key.stdout | from_json | first }}"
+
+- kubernetes.core.helm:
+    name: ceph-csi-rbd
+    chart_ref: ceph/ceph-csi-rbd
+    chart_version: 3.5.1
+    release_namespace: kube-system
+    kubeconfig: /etc/kubernetes/admin.conf
+    values:
+      csiConfig:
+        - clusterID: "{{ ceph_mon_fsid }}"
+          monitors: "{{ groups[ceph_csi_rbd_mons_group] | map('extract', hostvars, ['ansible_default_ipv4', 'address']) }}"
+      nodeplugin:
+        httpMetrics:
+          containerPort: 8081
+      provisioner:
+        nodeSelector:
+          openstack-control-plane: enabled
+      storageClass:
+        create: true
+        name: general
+        annotations:
+          storageclass.kubernetes.io/is-default-class: "true"
+        clusterID: "{{ ceph_csi_rbd_ceph_fsid }}"
+        pool: "{{ ceph_csi_rbd_pool }}"
+        mountOptions:
+          - discard
+      secret:
+        create: true
+        userID: "{{ ceph_csi_rbd_id }}"
+        userKey: "{{ _ceph_rbd_csi_ceph_keyring.key }}"
diff --git a/roles/cert_manager/defaults/main.yml b/roles/cert_manager/defaults/main.yml
new file mode 100644
index 0000000..45415d9
--- /dev/null
+++ b/roles/cert_manager/defaults/main.yml
@@ -0,0 +1,26 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+cert_manager_acme_server: https://acme-v02.api.letsencrypt.org/directory
+
+cert_manager_issuer:
+  acme:
+    email: mnaser@vexxhost.com
+    server: "{{ cert_manager_acme_server }}"
+    privateKeySecretRef:
+      name: issuer-account-key
+    solvers:
+      - http01:
+          ingress:
+            class: openstack
diff --git a/roles/cert_manager/meta/main.yml b/roles/cert_manager/meta/main.yml
new file mode 100644
index 0000000..1cf4a6e
--- /dev/null
+++ b/roles/cert_manager/meta/main.yml
@@ -0,0 +1,22 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: jetstack
+      helm_repository_repo_url: https://charts.jetstack.io
+  - cilium
+  - ingress_nginx
+  - openstack_namespace
diff --git a/roles/cert_manager/tasks/main.yml b/roles/cert_manager/tasks/main.yml
new file mode 100644
index 0000000..b06b4f6
--- /dev/null
+++ b/roles/cert_manager/tasks/main.yml
@@ -0,0 +1,43 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: cert-manager
+    chart_ref: jetstack/cert-manager
+    chart_version: v1.7.1
+    release_namespace: cert-manager
+    create_namespace: true
+    kubeconfig: /etc/kubernetes/admin.conf
+    values:
+      installCRDs: true
+      volumes:
+        - name: etc-ssl-certs
+          hostPath:
+            path: /etc/ssl/certs
+      volumeMounts:
+        - name: etc-ssl-certs
+          mountPath: /etc/ssl/certs
+          readOnly: true
+
+- name: Create issuer
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: cert-manager.io/v1
+      kind: Issuer
+      metadata:
+        name: openstack
+        namespace: openstack
+      spec: "{{ cert_manager_issuer }}"
diff --git a/roles/cilium/meta/main.yml b/roles/cilium/meta/main.yml
new file mode 100644
index 0000000..fda3c18
--- /dev/null
+++ b/roles/cilium/meta/main.yml
@@ -0,0 +1,19 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: cilium
+      helm_repository_repo_url: https://helm.cilium.io/
diff --git a/roles/cilium/tasks/main.yml b/roles/cilium/tasks/main.yml
new file mode 100644
index 0000000..98b5bb9
--- /dev/null
+++ b/roles/cilium/tasks/main.yml
@@ -0,0 +1,21 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: cilium
+    chart_ref: cilium/cilium
+    chart_version: 1.10.7
+    release_namespace: kube-system
+    kubeconfig: /etc/kubernetes/admin.conf
diff --git a/roles/helm_diff/tasks/main.yml b/roles/helm_diff/tasks/main.yml
new file mode 100644
index 0000000..909e650
--- /dev/null
+++ b/roles/helm_diff/tasks/main.yml
@@ -0,0 +1,27 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Retrieve values for current Helm release
+  kubernetes.core.helm_info:
+    name: "{{ helm_diff_release_name }}"
+    release_namespace: "{{ helm_diff_release_namespace }}"
+  register: _helm_diff_info
+
+- name: Generate diff between old and new values
+  ansible.utils.fact_diff:
+    before: "{{ _helm_diff_info.status['values'] }}"
+    after: "{{ helm_diff_values }}"
+
+- name: Pause until you can verify values are correct
+  ansible.builtin.pause:
diff --git a/roles/helm_repository/meta/main.yml b/roles/helm_repository/meta/main.yml
new file mode 100644
index 0000000..05e1c2d
--- /dev/null
+++ b/roles/helm_repository/meta/main.yml
@@ -0,0 +1,16 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - helm
diff --git a/roles/helm_repository/tasks/main.yml b/roles/helm_repository/tasks/main.yml
new file mode 100644
index 0000000..a7dd7e2
--- /dev/null
+++ b/roles/helm_repository/tasks/main.yml
@@ -0,0 +1,18 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Configure Helm repository ({{ helm_repository_name }})
+  kubernetes.core.helm_repository:
+    name: "{{ helm_repository_name }}"
+    repo_url: "{{ helm_repository_repo_url }}"
diff --git a/roles/ingress_nginx/meta/main.yml b/roles/ingress_nginx/meta/main.yml
new file mode 100644
index 0000000..bde6443
--- /dev/null
+++ b/roles/ingress_nginx/meta/main.yml
@@ -0,0 +1,20 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: ingress-nginx
+      helm_repository_repo_url: https://kubernetes.github.io/ingress-nginx
+  - openstack_namespace
diff --git a/roles/ingress_nginx/tasks/main.yml b/roles/ingress_nginx/tasks/main.yml
new file mode 100644
index 0000000..6436383
--- /dev/null
+++ b/roles/ingress_nginx/tasks/main.yml
@@ -0,0 +1,39 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: ingress-nginx
+    chart_ref: ingress-nginx/ingress-nginx
+    chart_version: 4.0.17
+    release_namespace: openstack
+    kubeconfig: /etc/kubernetes/admin.conf
+    values:
+      controller:
+        config:
+          proxy-buffer-size: 16k
+        dnsPolicy: ClusterFirstWithHostNet
+        hostNetwork: true
+        ingressClassResource:
+          name: openstack
+        ingressClass: openstack
+        extraArgs:
+          default-ssl-certificate: ingress-nginx/wildcard
+        kind: DaemonSet
+        nodeSelector:
+          openstack-control-plane: enabled
+        service:
+          type: ClusterIP
+        admissionWebhooks:
+          port: 7443
diff --git a/roles/kube_prometheus_stack/meta/main.yml b/roles/kube_prometheus_stack/meta/main.yml
new file mode 100644
index 0000000..137f7b5
--- /dev/null
+++ b/roles/kube_prometheus_stack/meta/main.yml
@@ -0,0 +1,21 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: prometheus-community
+      helm_repository_repo_url: https://prometheus-community.github.io/helm-charts
+  - cilium
+  - ceph_csi_rbd
diff --git a/roles/kube_prometheus_stack/tasks/main.yml b/roles/kube_prometheus_stack/tasks/main.yml
new file mode 100644
index 0000000..22c3e4d
--- /dev/null
+++ b/roles/kube_prometheus_stack/tasks/main.yml
@@ -0,0 +1,270 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+- ansible.builtin.slurp:
+    src: /etc/kubernetes/pki/etcd/ca.crt
+  register: _etcd_ca_crt
+
+- ansible.builtin.slurp:
+    src: /etc/kubernetes/pki/etcd/healthcheck-client.crt
+  register: _etcd_healthcheck_client_crt
+
+- ansible.builtin.slurp:
+    src: /etc/kubernetes/pki/etcd/healthcheck-client.key
+  register: _etcd_healthcheck_client_key
+
+- kubernetes.core.helm:
+    name: kube-prometheus-stack
+    chart_ref: prometheus-community/kube-prometheus-stack
+    chart_version: 30.2.0
+    release_namespace: monitoring
+    create_namespace: true
+    kubeconfig: /etc/kubernetes/admin.conf
+    values:
+      alertmanager:
+        serviceMonitor:
+          relabelings:
+            - sourceLabels: ["__meta_kubernetes_pod_name"]
+              targetLabel: "instance"
+            - action: "labeldrop"
+              regex: "^(container|endpoint|namespace|pod|service)$"
+      grafana:
+        serviceMonitor:
+          relabelings:
+            - sourceLabels: ["__meta_kubernetes_pod_name"]
+              targetLabel: "instance"
+            - action: "labeldrop"
+              regex: "^(container|endpoint|namespace|pod|service)$"
+      kubeApiServer:
+        serviceMonitor:
+          relabelings:
+            - sourceLabels: ["__meta_kubernetes_pod_node_name"]
+              targetLabel: "instance"
+            - action: "labeldrop"
+              regex: "^(container|endpoint|namespace|pod|service)$"
+      kubelet:
+        serviceMonitor:
+          cAdvisorRelabelings:
+            - sourceLabels: [__metrics_path__]
+              targetLabel: metrics_path
+            - sourceLabels: ["node"]
+              targetLabel: "instance"
+            - action: "labeldrop"
+              regex: "^(container|endpoint|namespace|node|service)$"
+          probesRelabelings:
+            - sourceLabels: [__metrics_path__]
+              targetLabel: metrics_path
+            - sourceLabels: ["node"]
+              targetLabel: "instance"
+            - action: "labeldrop"
+              regex: "^(container|endpoint|namespace|node|service)$"
+          relabelings:
+            - sourceLabels: [__metrics_path__]
+              targetLabel: metrics_path
+            - sourceLabels: ["node"]
+              targetLabel: "instance"
+            - action: "labeldrop"
+              regex: "^(container|endpoint|namespace|node|service)$"
+      kubeControllerManager:
+        serviceMonitor:
+          relabelings:
+            - sourceLabels: ["__meta_kubernetes_pod_node_name"]
+              targetLabel: "instance"
+            - action: "labeldrop"
+              regex: "^(container|endpoint|namespace|pod|service)$"
+      coreDns:
+        serviceMonitor:
+          relabelings:
+            - sourceLabels: ["__meta_kubernetes_pod_name"]
+              targetLabel: "instance"
+            - regex: "^(container|endpoint|namespace|pod|service)$"
+              action: "labeldrop"
+      kubeEtcd:
+        serviceMonitor:
+          scheme: https
+          serverName: localhost
+          insecureSkipVerify: false
+          caFile: /etc/prometheus/secrets/kube-prometheus-stack-etcd-client-cert/ca.crt
+          certFile: /etc/prometheus/secrets/kube-prometheus-stack-etcd-client-cert/healthcheck-client.crt
+          keyFile: /etc/prometheus/secrets/kube-prometheus-stack-etcd-client-cert/healthcheck-client.key
+          relabelings:
+            - sourceLabels: ["__meta_kubernetes_pod_node_name"]
+              targetLabel: "instance"
+            - action: "labeldrop"
+              regex: "^(container|endpoint|namespace|pod|service)$"
+      kubeScheduler:
+        service:
+          port: 10259
+          targetPort: 10259
+        serviceMonitor:
+          https: true
+          insecureSkipVerify: true
+          relabelings:
+            - sourceLabels: ["__meta_kubernetes_pod_node_name"]
+              targetLabel: "instance"
+            - action: "labeldrop"
+              regex: "^(container|endpoint|namespace|pod|service)$"
+      kubeProxy:
+        serviceMonitor:
+          relabelings:
+            - sourceLabels: ["__meta_kubernetes_pod_node_name"]
+              targetLabel: "instance"
+            - action: "labeldrop"
+              regex: "^(container|endpoint|namespace|pod|service)$"
+      kube-state-metrics:
+        prometheus:
+          monitor:
+            relabelings:
+              - sourceLabels: ["__meta_kubernetes_pod_name"]
+                targetLabel: "instance"
+              - action: "labeldrop"
+                regex: "^(container|endpoint|namespace|pod|service)$"
+        nodeSelector:
+          openstack-control-plane: enabled
+      prometheus:
+        serviceMonitor:
+          relabelings:
+            - sourceLabels: ["__meta_kubernetes_pod_name"]
+              targetLabel: "instance"
+            - action: "labeldrop"
+              regex: "^(container|endpoint|namespace|pod|service)$"
+        prometheusSpec:
+          nodeSelector:
+            openstack-control-plane: enabled
+          secrets:
+            - kube-prometheus-stack-etcd-client-cert
+      prometheusOperator:
+        serviceMonitor:
+          relabelings:
+            - sourceLabels: ["__meta_kubernetes_pod_name"]
+              targetLabel: "instance"
+            - action: "labeldrop"
+              regex: "^(container|endpoint|namespace|pod|service)$"
+        nodeSelector:
+          openstack-control-plane: enabled
+      prometheus-node-exporter:
+        extraArgs:
+          - --collector.diskstats.ignored-devices=^(ram|loop|nbd|fd|(h|s|v|xv)d[a-z]|nvme\\d+n\\d+p)\\d+$
+          - --collector.filesystem.fs-types-exclude=^(autofs|binfmt_misc|bpf|cgroup2?|configfs|debugfs|devpts|devtmpfs|fusectl|fuse.squashfuse_ll|hugetlbfs|iso9660|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs)$
+          - --collector.filesystem.mount-points-exclude=^/(dev|proc|run/credentials/.+|sys|var/lib/docker/.+|var/lib/kubelet/pods/.+|var/lib/kubelet/plugins/kubernetes.io/csi/.+|run/containerd/.+)($|/)
+          - --collector.netclass.ignored-devices=^(lxc|cilium_|qbr|qvb|qvo|ovs-system).*$
+          - --collector.netdev.device-exclude=^(lxc|cilium_|qbr|qvb|qvo|ovs-system).*$
+        prometheus:
+          monitor:
+            relabelings:
+              - sourceLabels: ["__meta_kubernetes_pod_node_name"]
+                targetLabel: "instance"
+              - action: "labeldrop"
+                regex: "^(container|endpoint|namespace|pod|service)$"
+      additionalPrometheusRulesMap:
+        coredns:
+          groups:
+            - name: coredns
+              rules:
+                - alert: CoreDNSDown
+                  expr: absent(up{job="coredns"} == 1)
+                  for: 15m
+                  labels:
+                    severity: critical
+                - alert: CoreDNSLatencyHigh
+                  expr: histogram_quantile(0.99, sum(rate(coredns_dns_request_duration_seconds_bucket{job="coredns"}[5m])) by(server, zone, le)) > 4
+                  for: 10m
+                  labels:
+                    severity: critical
+                - alert: CoreDNSErrorsHigh
+                  expr: sum(rate(coredns_dns_responses_total{job="coredns",rcode="SERVFAIL"}[5m])) / sum(rate(coredns_dns_responses_total{job="coredns"}[5m])) > 0.01
+                  for: 10m
+                  labels:
+                    severity: warning
+                - alert: CoreDNSErrorsHigh
+                  expr: sum(rate(coredns_dns_responses_total{job="coredns",rcode="SERVFAIL"}[5m])) / sum(rate(coredns_dns_responses_total{job="coredns"}[5m])) > 0.03
+                  for: 10m
+                  labels:
+                    severity: critical
+            - name: coredns_forward
+              rules:
+                - alert: CoreDNSForwardLatencyHigh
+                  expr: histogram_quantile(0.99, sum(rate(coredns_forward_request_duration_seconds_bucket{job="coredns"}[5m])) by(to, le)) > 4
+                  for: 10m
+                  labels:
+                    severity: critical
+                - alert: CoreDNSForwardErrorsHigh
+                  expr: sum(rate(coredns_forward_responses_total{job="coredns",rcode="SERVFAIL"}[5m])) / sum(rate(coredns_forward_responses_total{job="coredns"}[5m])) > 0.01
+                  for: 10m
+                  labels:
+                    severity: warning
+                - alert: CoreDNSForwardErrorsHigh
+                  expr: sum(rate(coredns_forward_responses_total{job="coredns",rcode="SERVFAIL"}[5m])) / sum(rate(coredns_forward_responses_total{job="coredns"}[5m])) > 0.03
+                  for: 10m
+                  labels:
+                    severity: critical
+                - alert: CoreDNSForwardHealthcheckFailureCount
+                  expr: sum(rate(coredns_forward_healthcheck_failures_total{job="coredns"}[5m])) by (to) > 0
+                  for: 2m
+                  labels:
+                    severity: warning
+                - alert: CoreDNSForwardHealthcheckBrokenCount
+                  expr: sum(rate(coredns_forward_healthcheck_broken_total{job="coredns"}[5m])) > 0
+                  for: 2m
+                  labels:
+                    severity: critical
+        node-exporter-local:
+          groups:
+            - name: node
+              rules:
+                - alert: NodeHighLoadAverage
+                  expr: node_load5 / count(node_cpu_seconds_total{mode="system"}) without (cpu, mode) > 1.5
+                  for: 30m
+                  labels:
+                    severity: warning
+                - alert: NodeHighMemoryUsage
+                  expr: (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100 < 2.5
+                  for: 2m
+                  labels:
+                    severity: critical
+                - alert: NodeHighCpuUsage
+                  expr: sum by(instance)(irate(node_cpu_seconds_total{mode='idle'}[5m])) < 1
+                  for: 2m
+                  labels:
+                    severity: warning
+                - alert: NodeLowEntropy
+                  expr: node_entropy_available_bits < 1000
+                  for: 5m
+                  labels:
+                    severity: warning
+            - name: softnet
+              rules:
+                - alert: NodeSoftNetTimesSqueezed
+                  expr: sum(rate(node_softnet_times_squeezed_total[1m])) by (instance) > 10
+                  for: 10m
+                  labels:
+                    severity: warning
+                - alert: NodeSoftNetDrops
+                  expr: sum(rate(node_softnet_dropped_total[1m])) by (instance) != 0
+                  for: 1m
+                  labels:
+                    severity: critical
+
+- kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: v1
+      kind: Secret
+      metadata:
+        name: kube-prometheus-stack-etcd-client-cert
+        namespace: monitoring
+      data:
+        ca.crt: "{{ _etcd_ca_crt.content }}"
+        healthcheck-client.crt: "{{ _etcd_healthcheck_client_crt.content }}"
+        healthcheck-client.key: "{{ _etcd_healthcheck_client_key.content }}"
diff --git a/roles/kubernetes/templates/haproxy.cfg.j2 b/roles/kubernetes/templates/haproxy.cfg.j2
index b4cb838..053ce9a 100644
--- a/roles/kubernetes/templates/haproxy.cfg.j2
+++ b/roles/kubernetes/templates/haproxy.cfg.j2
@@ -47,5 +47,5 @@
     option ssl-hello-chk
     balance     roundrobin
 {% for host in groups[kubernetes_control_plane_group] %}
-        server {{ host }} {{ hostvars[host]['ansible_host'] }}:16443 check
+        server {{ host }} {{ hostvars[host]['ansible_default_ipv4']['address'] }}:16443 check
 {% endfor %}
diff --git a/roles/kubernetes/templates/kubeadm.yaml.j2 b/roles/kubernetes/templates/kubeadm.yaml.j2
index eba1d7c..cc30dfd 100644
--- a/roles/kubernetes/templates/kubeadm.yaml.j2
+++ b/roles/kubernetes/templates/kubeadm.yaml.j2
@@ -7,7 +7,7 @@
   kubeletExtraArgs:
     cgroups-per-qos: "false"
     enforce-node-allocatable: ""
-    node-ip: "{{ ansible_host }}"
+    node-ip: "{{ ansible_default_ipv4.address }}"
 ---
 apiVersion: kubeadm.k8s.io/v1beta3
 kind: JoinConfiguration
@@ -15,7 +15,7 @@
   kubeletExtraArgs:
     cgroups-per-qos: "false"
     enforce-node-allocatable: ""
-    node-ip: "{{ ansible_host }}"
+    node-ip: "{{ ansible_default_ipv4.address }}"
 {% if (_kubernetes_bootstrap_node is not defined) or (_kubernetes_bootstrap_node is defined and inventory_hostname != _kubernetes_bootstrap_node) %}
 discovery:
   bootstrapToken:
diff --git a/roles/node_feature_discovery/meta/main.yml b/roles/node_feature_discovery/meta/main.yml
new file mode 100644
index 0000000..503ce14
--- /dev/null
+++ b/roles/node_feature_discovery/meta/main.yml
@@ -0,0 +1,21 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: node-feature-discovery
+      helm_repository_repo_url: https://kubernetes-sigs.github.io/node-feature-discovery/charts
+  - cilium
+  - kube_prometheus_stack
diff --git a/roles/node_feature_discovery/tasks/main.yml b/roles/node_feature_discovery/tasks/main.yml
new file mode 100644
index 0000000..213c72f
--- /dev/null
+++ b/roles/node_feature_discovery/tasks/main.yml
@@ -0,0 +1,39 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+- kubernetes.core.helm:
+    name: node-feature-discovery
+    chart_ref: node-feature-discovery/node-feature-discovery
+    chart_version: 0.10.0
+    release_namespace: monitoring
+    kubeconfig: /etc/kubernetes/admin.conf
+    values:
+      image:
+        repository: "{{ atmosphere_image_repository | default('us-docker.pkg.dev/vexxhost-infra/openstack') }}/node-feature-discovery"
+        tag: 0.10.0
+      master:
+        nodeSelector:
+          openstack-control-plane: enabled
+      worker:
+        config:
+          sources:
+            custom:
+              - name: ipmi
+                labels:
+                  ipmi: "true"
+                matchFeatures:
+                  - feature: kernel.loadedmodule
+                    matchExpresions:
+                      ipmi_msghandler:
+                        op: Exists
diff --git a/roles/openstack_cli/defaults/main.yml b/roles/openstack_cli/defaults/main.yml
new file mode 100644
index 0000000..8079a74
--- /dev/null
+++ b/roles/openstack_cli/defaults/main.yml
@@ -0,0 +1,16 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_cli_packages:
+  - python3-openstackclient
diff --git a/roles/openstack_cli/tasks/main.yml b/roles/openstack_cli/tasks/main.yml
new file mode 100644
index 0000000..b1c032a
--- /dev/null
+++ b/roles/openstack_cli/tasks/main.yml
@@ -0,0 +1,33 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Install OpenStack client
+  become: true
+  ansible.builtin.apt:
+    name: "{{ openstack_cli_packages }}"
+
+- name: Generate OpenStack-Helm endpoints
+  ansible.builtin.include_role:
+    name: openstack_helm_endpoints
+  vars:
+    openstack_helm_endpoints_list: ["identity"]
+
+- name: Generate openrc file
+  become: true
+  ansible.builtin.template:
+    src: openrc.j2
+    dest: /root/openrc
+    owner: root
+    group: root
+    mode: 0600
diff --git a/roles/openstack_cli/templates/openrc.j2 b/roles/openstack_cli/templates/openrc.j2
new file mode 100644
index 0000000..7b56e0f
--- /dev/null
+++ b/roles/openstack_cli/templates/openrc.j2
@@ -0,0 +1,12 @@
+# {{ ansible_managed }}
+
+export OS_IDENTITY_API_VERSION=3
+
+export OS_AUTH_URL="{{ openstack_helm_endpoints['identity']['scheme']['public'] }}://{{ openstack_helm_endpoints['identity']['host_fqdn_override']['public']['host'] }}/v3"
+export OS_AUTH_TYPE=password
+export OS_REGION_NAME="{{ openstack_helm_endpoints['identity']['auth']['admin']['region_name'] }}"
+export OS_USER_DOMAIN_NAME=Default
+export OS_USERNAME="{{ openstack_helm_endpoints['identity']['auth']['admin']['username'] }}"
+export OS_PASSWORD="{{ openstack_helm_endpoints['identity']['auth']['admin']['password'] }}"
+export OS_PROJECT_DOMAIN_NAME=Default
+export OS_PROJECT_NAME=admin
\ No newline at end of file
diff --git a/roles/openstack_helm_cinder/defaults/main.yml b/roles/openstack_helm_cinder/defaults/main.yml
new file mode 100644
index 0000000..02e5a98
--- /dev/null
+++ b/roles/openstack_helm_cinder/defaults/main.yml
@@ -0,0 +1,23 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_helm_cinder_chart_repo_name: openstack-helm
+openstack_helm_cinder_chart_repo_url: https://tarballs.opendev.org/openstack/openstack-helm/
+openstack_helm_cinder_chart_name: cinder
+
+openstack_helm_cinder_image_repository: "{{ atmosphere_image_repository | default('us-docker.pkg.dev/vexxhost-infra/openstack') }}"
+openstack_helm_cinder_image_tag: 18.1.1.dev29-1
+openstack_helm_cinder_heat_image_tag: wallaby
+
+openstack_helm_cinder_values: {}
diff --git a/roles/openstack_helm_cinder/meta/main.yml b/roles/openstack_helm_cinder/meta/main.yml
new file mode 100644
index 0000000..8ed7540
--- /dev/null
+++ b/roles/openstack_helm_cinder/meta/main.yml
@@ -0,0 +1,19 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: "{{ openstack_helm_cinder_chart_repo_name }}"
+      helm_repository_repo_url: "{{ openstack_helm_cinder_chart_repo_url }}"
diff --git a/roles/openstack_helm_cinder/tasks/main.yml b/roles/openstack_helm_cinder/tasks/main.yml
new file mode 100644
index 0000000..67fa685
--- /dev/null
+++ b/roles/openstack_helm_cinder/tasks/main.yml
@@ -0,0 +1,41 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Generate OpenStack-Helm endpoints
+  ansible.builtin.include_role:
+    name: openstack_helm_endpoints
+  vars:
+    openstack_helm_endpoints_repo_name: "{{ openstack_helm_cinder_chart_repo_name }}"
+    openstack_helm_endpoints_repo_url: "{{ openstack_helm_cinder_chart_repo_url }}"
+    openstack_helm_endpoints_chart: "{{ openstack_helm_cinder_chart_name }}"
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: "{{ openstack_helm_cinder_chart_name }}"
+    chart_ref: "{{ openstack_helm_cinder_chart_repo_name }}/{{ openstack_helm_cinder_chart_name }}"
+    chart_version: 0.2.15
+    release_namespace: openstack
+    kubeconfig: /etc/kubernetes/admin.conf
+    values: "{{ _openstack_helm_cinder_values | combine(openstack_helm_cinder_values, recursive=True) }}"
+
+- name: Create Ingress
+  ansible.builtin.include_role:
+    name: openstack_helm_ingress
+  vars:
+    openstack_helm_ingress_endpoint: volumev3
+    openstack_helm_ingress_service_name: cinder-api
+    openstack_helm_ingress_service_port: 8776
+    openstack_helm_ingress_annotations:
+      nginx.ingress.kubernetes.io/proxy-body-size: "0"
+      nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
diff --git a/roles/openstack_helm_cinder/vars/main.yml b/roles/openstack_helm_cinder/vars/main.yml
new file mode 100644
index 0000000..2b7fef5
--- /dev/null
+++ b/roles/openstack_helm_cinder/vars/main.yml
@@ -0,0 +1,61 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+_openstack_helm_cinder_values:
+  endpoints: "{{ openstack_helm_endpoints }}"
+  images:
+    tags:
+      bootstrap: "{{ openstack_helm_cinder_image_repository }}/heat:{{ openstack_helm_cinder_heat_image_tag }}"
+      cinder_api: "{{ openstack_helm_cinder_image_repository }}/cinder:{{ openstack_helm_cinder_image_tag }}"
+      cinder_backup_storage_init: "{{ openstack_helm_cinder_image_repository }}/cinder:{{ openstack_helm_cinder_image_tag }}"
+      cinder_backup: "{{ openstack_helm_cinder_image_repository }}/cinder:{{ openstack_helm_cinder_image_tag }}"
+      cinder_db_sync: "{{ openstack_helm_cinder_image_repository }}/cinder:{{ openstack_helm_cinder_image_tag }}"
+      cinder_scheduler: "{{ openstack_helm_cinder_image_repository }}/cinder:{{ openstack_helm_cinder_image_tag }}"
+      cinder_storage_init: "{{ openstack_helm_cinder_image_repository }}/cinder:{{ openstack_helm_cinder_image_tag }}"
+      cinder_volume_usage_audit: "{{ openstack_helm_cinder_image_repository }}/cinder:{{ openstack_helm_cinder_image_tag }}"
+      cinder_volume: "{{ openstack_helm_cinder_image_repository }}/cinder:{{ openstack_helm_cinder_image_tag }}"
+      db_drop: "{{ openstack_helm_cinder_image_repository }}/heat:{{ openstack_helm_cinder_heat_image_tag }}"
+      db_init: "{{ openstack_helm_cinder_image_repository }}/heat:{{ openstack_helm_cinder_heat_image_tag }}"
+      dep_check: "{{ openstack_helm_cinder_image_repository }}/kubernetes-entrypoint:latest"
+      ks_endpoints: "{{ openstack_helm_cinder_image_repository }}/heat:{{ openstack_helm_cinder_heat_image_tag }}"
+      ks_service: "{{ openstack_helm_cinder_image_repository }}/heat:{{ openstack_helm_cinder_heat_image_tag }}"
+      ks_user: "{{ openstack_helm_cinder_image_repository }}/heat:{{ openstack_helm_cinder_heat_image_tag }}"
+      rabbit_init: "{{ openstack_helm_cinder_image_repository }}/rabbitmq:3.8.23-management"
+  pod:
+    replicas:
+      api: 3
+      scheduler: 3
+  conf:
+    paste:
+      composite:openstack_volume_api_v3:
+        use: call:cinder.api.middleware.auth:pipeline_factory
+        noauth: cors http_proxy_to_wsgi request_id faultwrap sizelimit osprofiler noauth apiv3
+        keystone: cors http_proxy_to_wsgi request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv3
+        keystone_nolimit: cors http_proxy_to_wsgi request_id faultwrap sizelimit osprofiler authtoken keystonecontext apiv3
+    cinder:
+      DEFAULT:
+        allowed_direct_url_schemes: cinder
+        backup_driver: cinder.backup.drivers.ceph.CephBackupDriver
+        log_config_append: null
+        os_region_name: "{{ openstack_helm_endpoints['identity']['auth']['cinder']['region_name'] }}"
+        volume_usage_audit_period: hour
+        volume_name_template: volume-%s
+      barbican:
+        barbican_endpoint_type: internal
+      cors:
+        allowed_origins: "*"
+  manifests:
+    ingress_api: false
+    job_clean: false
+    service_ingress_api: false
diff --git a/roles/openstack_helm_endpoints/defaults/main.yml b/roles/openstack_helm_endpoints/defaults/main.yml
new file mode 100644
index 0000000..0338286
--- /dev/null
+++ b/roles/openstack_helm_endpoints/defaults/main.yml
@@ -0,0 +1,102 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+# Base configuration
+openstack_helm_endpoints_region_name: "{{ undef(hint='You must specify an OpenStack region name') }}"
+openstack_helm_endpoints_config: {}
+
+# RabbitMQ
+openstack_helm_endpoints_rabbitmq_erlang_cookie: "{{ undef(hint='You must specify an RabbitMQ Erlang cookie') }}"
+openstack_helm_endpoints_rabbitmq_admin_password: "{{ undef(hint='You must specify a RabbitMQ admin password') }}"
+
+# Memcached
+openstack_helm_endpoints_memcached_secret_key: "{{ undef(hint='You must specify a Memcached secret key') }}"
+
+# Keystone
+openstack_helm_endpoints_keystone_api_host: "{{ undef(hint='You must specify a Keystone API hostname') }}"
+openstack_helm_endpoints_keystone_region_name: "{{ openstack_helm_endpoints_region_name }}"
+openstack_helm_endpoints_keystone_admin_password: "{{ undef(hint='You must specify a Keystone administrator password') }}"
+openstack_helm_endpoints_keystone_mariadb_password: "{{ undef(hint='You must specify a Keystone MariaDB password') }}"
+openstack_helm_endpoints_keystone_rabbitmq_password: "{{ undef(hint='You must specify a Keystone RabbitMQ password') }}"
+
+# Glance
+openstack_helm_endpoints_glance_api_host: "{{ undef(hint='You must specify a Glance API hostname') }}"
+openstack_helm_endpoints_glance_region_name: "{{ openstack_helm_endpoints_region_name }}"
+openstack_helm_endpoints_glance_keystone_password: "{{ undef(hint='You must specify a Glance Keystone password') }}"
+openstack_helm_endpoints_glance_mariadb_password: "{{ undef(hint='You must specify a Glance MariaDB password') }}"
+openstack_helm_endpoints_glance_rabbitmq_password: "{{ undef(hint='You must specify a Glance RabbitMQ password') }}"
+
+# Cinder
+openstack_helm_endpoints_cinder_api_host: "{{ undef(hint='You must specify a Cinder API hostname') }}"
+openstack_helm_endpoints_cinder_region_name: "{{ openstack_helm_endpoints_region_name }}"
+openstack_helm_endpoints_cinder_keystone_password: "{{ undef(hint='You must specify a Cinder Keystone password') }}"
+openstack_helm_endpoints_cinder_mariadb_password: "{{ undef(hint='You must specify a Cinder MariaDB password') }}"
+openstack_helm_endpoints_cinder_rabbitmq_password: "{{ undef(hint='You must specify a Cinder RabbitMQ password') }}"
+
+# Placement
+openstack_helm_endpoints_placement_api_host: "{{ undef(hint='You must specify a Placement API hostname') }}"
+openstack_helm_endpoints_placement_region_name: "{{ openstack_helm_endpoints_region_name }}"
+openstack_helm_endpoints_placement_keystone_password: "{{ undef(hint='You must specify a Placement Keystone password') }}"
+openstack_helm_endpoints_placement_mariadb_password: "{{ undef(hint='You must specify a Placement MariaDB password') }}"
+
+# Neutron
+openstack_helm_endpoints_neutron_api_host: "{{ undef(hint='You must specify a Neutron API hostname') }}"
+openstack_helm_endpoints_neutron_region_name: "{{ openstack_helm_endpoints_region_name }}"
+openstack_helm_endpoints_neutron_keystone_password: "{{ undef(hint='You must specify a Neutron Keystone password') }}"
+openstack_helm_endpoints_neutron_mariadb_password: "{{ undef(hint='You must specify a Neutron MariaDB password') }}"
+openstack_helm_endpoints_neutron_rabbitmq_password: "{{ undef(hint='You must specify a Neutron RabbitMQ password') }}"
+openstack_helm_endpoints_neutron_metadata_secret: "{{ undef(hint='You must specify a Neutron metadata secret') }}"
+
+# Nova
+openstack_helm_endpoints_nova_api_host: "{{ undef(hint='You must specify a Nova API hostname') }}"
+openstack_helm_endpoints_nova_novnc_host: "{{ undef(hint='You must specify a Nova NoVNC hostname') }}"
+openstack_helm_endpoints_nova_region_name: "{{ openstack_helm_endpoints_region_name }}"
+openstack_helm_endpoints_nova_keystone_password: "{{ undef(hint='You must specify a Nova Keystone password') }}"
+openstack_helm_endpoints_nova_mariadb_password: "{{ undef(hint='You must specify a Nova MariaDB password') }}"
+openstack_helm_endpoints_nova_rabbitmq_password: "{{ undef(hint='You must specify a Nova RabbitMQ password') }}"
+
+# Ironic
+openstack_helm_endpoints_ironic_api_host: "{{ undef(hint='You must specify an Ironic API hostname') }}"
+openstack_helm_endpoints_ironic_region_name: "{{ openstack_helm_endpoints_region_name }}"
+openstack_helm_endpoints_ironic_keystone_password: "{{ undef(hint='You must specify an Ironic Keystone password') }}"
+openstack_helm_endpoints_ironic_mariadb_password: "{{ undef(hint='You must specify an Ironic MariaDB password') }}"
+openstack_helm_endpoints_ironic_rabbitmq_password: "{{ undef(hint='You must specify an Ironic RabbitMQ password') }}"
+
+# Designate
+openstack_helm_endpoints_designate_api_host: "{{ undef(hint='You must specify a Designate API hostname') }}"
+openstack_helm_endpoints_designate_region_name: "{{ openstack_helm_endpoints_region_name }}"
+openstack_helm_endpoints_designate_keystone_password: "{{ undef(hint='You must specify a Designate Keystone password') }}"
+openstack_helm_endpoints_designate_mariadb_password: "{{ undef(hint='You must specify a Designate MariaDB password') }}"
+openstack_helm_endpoints_designate_rabbitmq_password: "{{ undef(hint='You must specify a Designate RabbitMQ password') }}"
+
+# Octavia
+openstack_helm_endpoints_octavia_api_host: "{{ undef(hint='You must specify an Octavia API hostname') }}"
+openstack_helm_endpoints_octavia_region_name: "{{ openstack_helm_endpoints_region_name }}"
+openstack_helm_endpoints_octavia_keystone_password: "{{ undef(hint='You must specify an Octavia Keystone password') }}"
+openstack_helm_endpoints_octavia_mariadb_password: "{{ undef(hint='You must specify an Octavia MariaDB password') }}"
+openstack_helm_endpoints_octavia_rabbitmq_password: "{{ undef(hint='You must specify an Octavia RabbitMQ password') }}"
+
+# Heat
+openstack_helm_endpoints_heat_api_host: "{{ undef(hint='You must specify a Heat API hostname') }}"
+openstack_helm_endpoints_heat_region_name: "{{ openstack_helm_endpoints_region_name }}"
+openstack_helm_endpoints_heat_keystone_password: "{{ undef(hint='You must specify a Heat Keystone password') }}"
+openstack_helm_endpoints_heat_trustee_keystone_password: "{{ undef(hint='You must specify a Heat trustee Keystone password') }}"
+openstack_helm_endpoints_heat_stack_user_keystone_password: "{{ undef(hint='You must specify a Heat stack user Keystone password') }}"
+openstack_helm_endpoints_heat_mariadb_password: "{{ undef(hint='You must specify a Heat MariaDB password') }}"
+openstack_helm_endpoints_heat_rabbitmq_password: "{{ undef(hint='You must specify a Heat RabbitMQ password') }}"
+openstack_helm_endpoints_heat_cfn_api_host: "{{ undef(hint='You must specify a Heat CloudFormation API hostname') }}"
+
+# Horizon
+openstack_helm_endpoints_horizon_api_host: "{{ undef(hint='You must specify a Horizon API hostname') }}"
+openstack_helm_endpoints_horizon_mariadb_password: "{{ undef(hint='You must specify a Horizon MariaDB password') }}"
diff --git a/roles/openstack_helm_endpoints/tasks/main.yml b/roles/openstack_helm_endpoints/tasks/main.yml
new file mode 100644
index 0000000..a73b6f9
--- /dev/null
+++ b/roles/openstack_helm_endpoints/tasks/main.yml
@@ -0,0 +1,65 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Get Helm values if chart is provided
+  block:
+    - name: Get the default values for the Helm chart
+      ansible.builtin.shell: helm show values {{ openstack_helm_endpoints_repo_name }}/{{ openstack_helm_endpoints_chart }}
+      changed_when: false
+      register: _helm_show_values
+
+    - name: Retrieve list of all the needed endpoints
+      ansible.builtin.set_fact:
+        openstack_helm_endpoints_list: "{{ _helm_show_values.stdout | from_yaml | community.general.json_query('keys(endpoints)') | difference(_openstack_helm_endpoints_ignore) }}"
+  when:
+    - openstack_helm_endpoints_list is not defined or openstack_helm_endpoints_list == None
+
+# NOTE(mnaser): Since we deploy the database using the operator and we let it
+#               generate the root password, we look it up if the fact has not
+#               been cached from a previous run.
+- name: Append endpoints for "oslo_db"
+  block:
+    - name: Grab Percona XtraDB cluster secret
+      kubernetes.core.k8s_info:
+        api_version: v1
+        kind: Secret
+        name: percona-xtradb
+        namespace: openstack
+      register: _openstack_helm_endpoints_oslo_db_secret
+
+    - name: Cache fact with Percona XtraDB password
+      ansible.builtin.set_fact:
+        openstack_helm_endpoints_maridb_admin_password: "{{ _openstack_helm_endpoints_oslo_db_secret.resources[0]['data']['root'] | b64decode }}"
+  when:
+    - '"oslo_db" in openstack_helm_endpoints_list'
+    - openstack_helm_endpoints_maridb_admin_password is not defined
+
+- name: Reset value for OpenStack_Helm endpoints
+  ansible.builtin.set_fact:
+    openstack_helm_endpoints: "{{ openstack_helm_endpoints_config }}"
+
+- name: Generate OpenStack-Helm endpoints
+  ansible.builtin.set_fact:
+    openstack_helm_endpoints: |
+      {{ openstack_helm_endpoints | combine(lookup('vars', '_openstack_helm_endpoints_' + service), recursive=True) }}
+  loop: "{{ openstack_helm_endpoints_list }}"
+  loop_control:
+    loop_var: service
+
+# NOTE(mnaser): Since we use `openstack_helm_endpoints_list` to ensure that we
+#               have a common entry for endpoints and stay DRY, we need to
+#               reset the fact so it works for follow-up requests.
+- name: Clean-up facts
+  ansible.builtin.set_fact:
+    openstack_helm_endpoints_list:
diff --git a/roles/openstack_helm_endpoints/vars/main.yml b/roles/openstack_helm_endpoints/vars/main.yml
new file mode 100644
index 0000000..eb46051
--- /dev/null
+++ b/roles/openstack_helm_endpoints/vars/main.yml
@@ -0,0 +1,378 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+_openstack_helm_endpoints_ignore:
+  - ceph_object_store
+  - cloudwatch
+  - cluster_domain_suffix
+  - compute_spice_proxy
+  - fluentd
+  - ingress
+  - kube_dns
+  - ldap
+  - local_image_registry
+  - monitoring
+  - object_store
+  - prometheus_rabbitmq_exporter
+
+_openstack_helm_endpoints_oslo_db:
+  oslo_db:
+    auth:
+      admin:
+        password: "{{ openstack_helm_endpoints_maridb_admin_password }}"
+    hosts:
+      default: percona-xtradb-haproxy
+
+_openstack_helm_endpoints_oslo_messaging:
+  oslo_messaging:
+    auth:
+      erlang_cookie: "{{ openstack_helm_endpoints_rabbitmq_erlang_cookie }}"
+      user:
+        password: "{{ openstack_helm_endpoints_rabbitmq_admin_password }}"
+      # NOTE(mnaser): The following is not actually used by the chart, however,
+      #               since we are actually doing dynamic lookups to generate
+      #               endpoints, we add it here.
+      admin:
+        password: "{{ openstack_helm_endpoints_rabbitmq_admin_password }}"
+    statefulset:
+      replicas: 3
+
+_openstack_helm_endpoints_oslo_cache:
+  oslo_cache:
+    auth:
+      memcache_secret_key: "{{ openstack_helm_endpoints_memcached_secret_key }}"
+
+_openstack_helm_endpoints_identity:
+  identity:
+    auth:
+      admin:
+        region_name: "{{ openstack_helm_endpoints_keystone_region_name }}"
+        username: "admin-{{ openstack_helm_endpoints_keystone_region_name }}"
+        password: "{{ openstack_helm_endpoints_keystone_admin_password }}"
+    hosts:
+      default: keystone-api
+    scheme:
+      public: https
+    host_fqdn_override:
+      public:
+        host: "{{ openstack_helm_endpoints_keystone_api_host }}"
+    port:
+      api:
+        default: 5000
+        public: 443
+  oslo_db:
+    auth:
+      keystone:
+        password: "{{ openstack_helm_endpoints_keystone_mariadb_password }}"
+  oslo_messaging:
+    auth:
+      keystone:
+        password: "{{ openstack_helm_endpoints_keystone_rabbitmq_password }}"
+
+_openstack_helm_endpoints_image:
+  identity:
+    auth:
+      glance:
+        region_name: "{{ openstack_helm_endpoints_glance_region_name }}"
+        username: "glance-{{ openstack_helm_endpoints_glance_region_name }}"
+        password: "{{ openstack_helm_endpoints_glance_keystone_password }}"
+  image:
+    scheme:
+      public: https
+    host_fqdn_override:
+      public:
+        host: "{{ openstack_helm_endpoints_glance_api_host }}"
+    port:
+      api:
+        public: 443
+  oslo_db:
+    auth:
+      glance:
+        password: "{{ openstack_helm_endpoints_glance_mariadb_password }}"
+  oslo_messaging:
+    auth:
+      glance:
+        password: "{{ openstack_helm_endpoints_glance_rabbitmq_password }}"
+
+_openstack_helm_endpoints_volumev3:
+  identity:
+    auth:
+      cinder:
+        region_name: "{{ openstack_helm_endpoints_cinder_region_name }}"
+        username: "cinder-{{ openstack_helm_endpoints_cinder_region_name }}"
+        password: "{{ openstack_helm_endpoints_cinder_keystone_password }}"
+  volumev3:
+    scheme:
+      public: https
+    host_fqdn_override:
+      public:
+        host: "{{ openstack_helm_endpoints_cinder_api_host }}"
+    port:
+      api:
+        public: 443
+  oslo_db:
+    auth:
+      cinder:
+        password: "{{ openstack_helm_endpoints_cinder_mariadb_password }}"
+  oslo_messaging:
+    auth:
+      cinder:
+        password: "{{ openstack_helm_endpoints_cinder_rabbitmq_password }}"
+
+_openstack_helm_endpoints_placement:
+  identity:
+    auth:
+      placement:
+        region_name: "{{ openstack_helm_endpoints_placement_region_name }}"
+        username: "placement-{{ openstack_helm_endpoints_placement_region_name }}"
+        password: "{{ openstack_helm_endpoints_placement_keystone_password }}"
+  oslo_db:
+    auth:
+      placement:
+        password: "{{ openstack_helm_endpoints_placement_mariadb_password }}"
+  placement:
+    scheme:
+      public: https
+    host_fqdn_override:
+      public:
+        host: "{{ openstack_helm_endpoints_placement_api_host }}"
+    port:
+      api:
+        public: 443
+
+_openstack_helm_endpoints_network:
+  identity:
+    auth:
+      neutron:
+        region_name: "{{ openstack_helm_endpoints_neutron_region_name }}"
+        username: "neutron-{{ openstack_helm_endpoints_neutron_region_name }}"
+        password: "{{ openstack_helm_endpoints_neutron_keystone_password }}"
+  network:
+    scheme:
+      public: https
+    host_fqdn_override:
+      public:
+        host: "{{ openstack_helm_endpoints_neutron_api_host }}"
+    port:
+      api:
+        public: 443
+  oslo_db:
+    auth:
+      neutron:
+        password: "{{ openstack_helm_endpoints_neutron_mariadb_password }}"
+  oslo_messaging:
+    auth:
+      neutron:
+        password: "{{ openstack_helm_endpoints_neutron_rabbitmq_password }}"
+
+_openstack_helm_endpoints_compute:
+  identity:
+    auth:
+      nova:
+        region_name: "{{ openstack_helm_endpoints_nova_region_name }}"
+        username: "nova-{{ openstack_helm_endpoints_nova_region_name }}"
+        password: "{{ openstack_helm_endpoints_nova_keystone_password }}"
+  compute:
+    scheme:
+      public: https
+    host_fqdn_override:
+      public:
+        host: "{{ openstack_helm_endpoints_nova_api_host }}"
+    port:
+      api:
+        public: 443
+  oslo_db:
+    auth:
+      nova:
+        password: "{{ openstack_helm_endpoints_nova_mariadb_password }}"
+  oslo_messaging:
+    auth:
+      nova:
+        password: "{{ openstack_helm_endpoints_nova_rabbitmq_password }}"
+
+_openstack_helm_endpoints_oslo_db_api:
+  oslo_db_api:
+    auth:
+      admin:
+        password: "{{ openstack_helm_endpoints_maridb_admin_password }}"
+      nova:
+        password: "{{ openstack_helm_endpoints_nova_mariadb_password }}"
+    hosts:
+      default: percona-xtradb-haproxy
+
+_openstack_helm_endpoints_oslo_db_cell0:
+  oslo_db_cell0:
+    auth:
+      admin:
+        password: "{{ openstack_helm_endpoints_maridb_admin_password }}"
+      nova:
+        password: "{{ openstack_helm_endpoints_nova_mariadb_password }}"
+    hosts:
+      default: percona-xtradb-haproxy
+
+_openstack_helm_endpoints_compute_metadata:
+  compute_metadata:
+    secret: "{{ openstack_helm_endpoints_neutron_metadata_secret }}"
+    hosts:
+      public: nova-metadata
+    port:
+      metadata:
+        public: 8775
+
+_openstack_helm_endpoints_compute_novnc_proxy:
+  compute_novnc_proxy:
+    scheme:
+      public: https
+    host_fqdn_override:
+      public:
+        host: "{{ openstack_helm_endpoints_nova_novnc_host }}"
+    port:
+      novnc_proxy:
+        public: 443
+
+_openstack_helm_endpoints_baremetal:
+  identity:
+    auth:
+      ironic:
+        region_name: "{{ openstack_helm_endpoints_ironic_region_name }}"
+        username: "ironic-{{ openstack_helm_endpoints_ironic_region_name }}"
+        password: "{{ openstack_helm_endpoints_ironic_keystone_password }}"
+  baremetal:
+    scheme:
+      public: https
+    host_fqdn_override:
+      public:
+        host: "{{ openstack_helm_endpoints_ironic_api_host }}"
+    port:
+      api:
+        public: 443
+  oslo_db:
+    auth:
+      ironic:
+        password: "{{ openstack_helm_endpoints_ironic_mariadb_password }}"
+  oslo_messaging:
+    auth:
+      ironic:
+        password: "{{ openstack_helm_endpoints_ironic_rabbitmq_password }}"
+
+_openstack_helm_endpoints_dns:
+  identity:
+    auth:
+      designate:
+        region_name: "{{ openstack_helm_endpoints_designate_region_name }}"
+        username: "desigante-{{ openstack_helm_endpoints_designate_region_name }}"
+        password: "{{ openstack_helm_endpoints_designate_keystone_password }}"
+  dns:
+    scheme:
+      public: https
+    host_fqdn_override:
+      public:
+        host: "{{ openstack_helm_endpoints_designate_api_host }}"
+    port:
+      api:
+        public: 443
+  oslo_db:
+    auth:
+      designate:
+        password: "{{ openstack_helm_endpoints_designate_mariadb_password }}"
+  oslo_messaging:
+    auth:
+      designate:
+        password: "{{ openstack_helm_endpoints_designate_rabbitmq_password }}"
+
+_openstack_helm_endpoints_load_balancer:
+  identity:
+    auth:
+      octavia:
+        region_name: "{{ openstack_helm_endpoints_octavia_region_name }}"
+        username: "octavia-{{ openstack_helm_endpoints_octavia_region_name }}"
+        password: "{{ openstack_helm_endpoints_octavia_keystone_password }}"
+  load_balancer:
+    scheme:
+      public: https
+    host_fqdn_override:
+      public:
+        host: "{{ openstack_helm_endpoints_octavia_api_host }}"
+    port:
+      api:
+        public: 443
+  oslo_db:
+    auth:
+      octavia:
+        password: "{{ openstack_helm_endpoints_octavia_mariadb_password }}"
+  oslo_messaging:
+    auth:
+      octavia:
+        password: "{{ openstack_helm_endpoints_octavia_rabbitmq_password }}"
+
+_openstack_helm_endpoints_cloudformation:
+  cloudformation:
+    scheme:
+      public: https
+    host_fqdn_override:
+      public:
+        host: "{{ openstack_helm_endpoints_heat_cfn_api_host }}"
+    port:
+      api:
+        public: 443
+
+_openstack_helm_endpoints_orchestration:
+  identity:
+    auth:
+      heat:
+        region_name: "{{ openstack_helm_endpoints_heat_region_name }}"
+        username: "heat-{{ openstack_helm_endpoints_heat_region_name }}"
+        password: "{{ openstack_helm_endpoints_heat_keystone_password }}"
+      heat_trustee:
+        region_name: "{{ openstack_helm_endpoints_heat_region_name }}"
+        username: "heat-trustee-{{ openstack_helm_endpoints_heat_region_name }}"
+        password: "{{ openstack_helm_endpoints_heat_trustee_keystone_password }}"
+      heat_stack_user:
+        region_name: "{{ openstack_helm_endpoints_heat_region_name }}"
+        username: "heat-stack-user-{{ openstack_helm_endpoints_heat_region_name }}"
+        password: "{{ openstack_helm_endpoints_heat_stack_user_keystone_password }}"
+    path:
+      public: /v3
+  orchestration:
+    scheme:
+      public: https
+    host_fqdn_override:
+      public:
+        host: "{{ openstack_helm_endpoints_heat_api_host }}"
+    port:
+      api:
+        public: 443
+  oslo_db:
+    auth:
+      heat:
+        password: "{{ openstack_helm_endpoints_heat_mariadb_password }}"
+  oslo_messaging:
+    auth:
+      heat:
+        password: "{{ openstack_helm_endpoints_heat_rabbitmq_password }}"
+
+_openstack_helm_endpoints_dashboard:
+  dashboard:
+    scheme:
+      public: https
+    host_fqdn_override:
+      public:
+        host: "{{ openstack_helm_endpoints_horizon_api_host }}"
+    port:
+      api:
+        public: 443
+  oslo_db:
+    auth:
+      horizon:
+        password: "{{ openstack_helm_endpoints_horizon_mariadb_password }}"
diff --git a/roles/openstack_helm_glance/defaults/main.yml b/roles/openstack_helm_glance/defaults/main.yml
new file mode 100644
index 0000000..bfb6e83
--- /dev/null
+++ b/roles/openstack_helm_glance/defaults/main.yml
@@ -0,0 +1,23 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_helm_glance_chart_repo_name: openstack-helm
+openstack_helm_glance_chart_repo_url: https://tarballs.opendev.org/openstack/openstack-helm/
+openstack_helm_glance_chart_name: glance
+
+openstack_helm_glance_image_repository: "{{ atmosphere_image_repository | default('us-docker.pkg.dev/vexxhost-infra/openstack') }}"
+openstack_helm_glance_image_tag: 22.1.1.dev2-1
+openstack_helm_glance_heat_image_tag: wallaby
+
+openstack_helm_glance_values: {}
diff --git a/roles/openstack_helm_glance/meta/main.yml b/roles/openstack_helm_glance/meta/main.yml
new file mode 100644
index 0000000..a7eaa64
--- /dev/null
+++ b/roles/openstack_helm_glance/meta/main.yml
@@ -0,0 +1,19 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: "{{ openstack_helm_glance_chart_repo_name }}"
+      helm_repository_repo_url: "{{ openstack_helm_glance_chart_repo_url }}"
diff --git a/roles/openstack_helm_glance/tasks/main.yml b/roles/openstack_helm_glance/tasks/main.yml
new file mode 100644
index 0000000..0abdb87
--- /dev/null
+++ b/roles/openstack_helm_glance/tasks/main.yml
@@ -0,0 +1,41 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Generate OpenStack-Helm endpoints
+  ansible.builtin.include_role:
+    name: openstack_helm_endpoints
+  vars:
+    openstack_helm_endpoints_repo_name: "{{ openstack_helm_glance_chart_repo_name }}"
+    openstack_helm_endpoints_repo_url: "{{ openstack_helm_glance_chart_repo_url }}"
+    openstack_helm_endpoints_chart: "{{ openstack_helm_glance_chart_name }}"
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: "{{ openstack_helm_glance_chart_name }}"
+    chart_ref: "{{ openstack_helm_glance_chart_repo_name }}/{{ openstack_helm_glance_chart_name }}"
+    chart_version: 0.2.10
+    release_namespace: openstack
+    kubeconfig: /etc/kubernetes/admin.conf
+    values: "{{ _openstack_helm_glance_values | combine(openstack_helm_glance_values, recursive=True) }}"
+
+- name: Create Ingress
+  ansible.builtin.include_role:
+    name: openstack_helm_ingress
+  vars:
+    openstack_helm_ingress_endpoint: image
+    openstack_helm_ingress_service_name: glance-api
+    openstack_helm_ingress_service_port: 9292
+    openstack_helm_ingress_annotations:
+      nginx.ingress.kubernetes.io/proxy-body-size: "0"
+      nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
diff --git a/roles/openstack_helm_glance/vars/main.yml b/roles/openstack_helm_glance/vars/main.yml
new file mode 100644
index 0000000..41b8016
--- /dev/null
+++ b/roles/openstack_helm_glance/vars/main.yml
@@ -0,0 +1,51 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+_openstack_helm_glance_values:
+  endpoints: "{{ openstack_helm_endpoints }}"
+  storage: rbd
+  images:
+    tags:
+      bootstrap: "{{ openstack_helm_glance_image_repository }}/heat:{{ openstack_helm_glance_heat_image_tag }}"
+      db_drop: "{{ openstack_helm_glance_image_repository }}/heat:{{ openstack_helm_glance_heat_image_tag }}"
+      db_init: "{{ openstack_helm_glance_image_repository }}/heat:{{ openstack_helm_glance_heat_image_tag }}"
+      dep_check: "{{ openstack_helm_glance_image_repository }}/kubernetes-entrypoint:latest"
+      glance_api: "{{ openstack_helm_glance_image_repository }}/glance:{{ openstack_helm_glance_image_tag }}"
+      glance_db_sync: "{{ openstack_helm_glance_image_repository }}/glance:{{ openstack_helm_glance_image_tag }}"
+      glance_metadefs_load: "{{ openstack_helm_glance_image_repository }}/glance:{{ openstack_helm_glance_image_tag }}"
+      glance_registry: "{{ openstack_helm_glance_image_repository }}/glance:{{ openstack_helm_glance_image_tag }}"
+      glance_storage_init: "{{ openstack_helm_glance_image_repository }}/glance:{{ openstack_helm_glance_image_tag }}"
+      ks_endpoints: "{{ openstack_helm_glance_image_repository }}/heat:{{ openstack_helm_glance_heat_image_tag }}"
+      ks_service: "{{ openstack_helm_glance_image_repository }}/heat:{{ openstack_helm_glance_heat_image_tag }}"
+      ks_user: "{{ openstack_helm_glance_image_repository }}/heat:{{ openstack_helm_glance_heat_image_tag }}"
+      rabbit_init: "{{ openstack_helm_glance_image_repository }}/rabbitmq:3.8.23-management"
+  bootstrap:
+    enabled: false
+  pod:
+    replicas:
+      api: 3
+  conf:
+    glance:
+      DEFAULT:
+        log_config_append: null
+        show_image_direct_url: true
+        show_multiple_locations: true
+        enable_import_methods: "[]"
+      cors:
+        allowed_origins: "*"
+      image_formats:
+        disk_formats: "qcow2,raw"
+  manifests:
+    ingress_api: false
+    service_ingress_api: false
diff --git a/roles/openstack_helm_heat/defaults/main.yml b/roles/openstack_helm_heat/defaults/main.yml
new file mode 100644
index 0000000..3ca6123
--- /dev/null
+++ b/roles/openstack_helm_heat/defaults/main.yml
@@ -0,0 +1,27 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_helm_heat_chart_repo_name: openstack-helm
+openstack_helm_heat_chart_repo_url: https://tarballs.opendev.org/openstack/openstack-helm/
+openstack_helm_heat_chart_name: heat
+
+openstack_helm_heat_image_repository: "{{ atmosphere_image_repository | default('us-docker.pkg.dev/vexxhost-infra/openstack') }}"
+openstack_helm_heat_image_tag: 16.0.1.dev16
+
+openstack_helm_heat_auth_encryption_key: "{{ undef(hint='You must specifiy an encryption key for Heat.') }}"
+
+openstack_helm_heat_diff: false
+openstack_helm_heat_migrate_from_mariadb: false
+
+openstack_helm_heat_values: {}
diff --git a/roles/openstack_helm_heat/meta/main.yml b/roles/openstack_helm_heat/meta/main.yml
new file mode 100644
index 0000000..3dfc746
--- /dev/null
+++ b/roles/openstack_helm_heat/meta/main.yml
@@ -0,0 +1,19 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: "{{ openstack_helm_heat_chart_repo_name }}"
+      helm_repository_repo_url: "{{ openstack_helm_heat_chart_repo_url }}"
diff --git a/roles/openstack_helm_heat/tasks/main.yml b/roles/openstack_helm_heat/tasks/main.yml
new file mode 100644
index 0000000..7390441
--- /dev/null
+++ b/roles/openstack_helm_heat/tasks/main.yml
@@ -0,0 +1,74 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Generate OpenStack-Helm endpoints
+  ansible.builtin.include_role:
+    name: openstack_helm_endpoints
+  vars:
+    openstack_helm_endpoints_repo_name: "{{ openstack_helm_heat_chart_repo_name }}"
+    openstack_helm_endpoints_repo_url: "{{ openstack_helm_heat_chart_repo_url }}"
+    openstack_helm_endpoints_chart: "{{ openstack_helm_heat_chart_name }}"
+
+- name: Generate Helm values comparison
+  ansible.builtin.include_role:
+    name: helm_diff
+  vars:
+    helm_diff_release_name: "{{ openstack_helm_heat_chart_name }}"
+    helm_diff_release_namespace: openstack
+    helm_diff_values: "{{ _openstack_helm_heat_values }}"
+  when:
+    - openstack_helm_heat_diff | bool
+
+- name: Migrate database from MariaDB to Percona XtraDB Cluster
+  ansible.builtin.include_role:
+    name: openstack_helm_migrate_to_percona_xtradb_cluster
+  vars:
+    openstack_helm_migrate_to_percona_xtradb_cluster_release_name: "{{ openstack_helm_heat_chart_name }}"
+    openstack_helm_migrate_to_percona_xtradb_cluster_release_namespace: openstack
+    openstack_helm_migrate_to_percona_xtradb_cluster_databases:
+      - heat
+    openstack_helm_migrate_to_percona_xtradb_cluster_services:
+      - kind: Deployment
+        name: heat-api
+      - kind: Deployment
+        name: heat-cfn
+      - kind: Deployment
+        name: heat-engine
+  when:
+    - openstack_helm_heat_migrate_from_mariadb | bool
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: "{{ openstack_helm_heat_chart_name }}"
+    chart_ref: "{{ openstack_helm_heat_chart_repo_name }}/{{ openstack_helm_heat_chart_name }}"
+    chart_version: 0.2.8
+    release_namespace: openstack
+    kubeconfig: /etc/kubernetes/admin.conf
+    values: "{{ _openstack_helm_heat_values }}"
+
+- name: Create Ingress
+  ansible.builtin.include_role:
+    name: openstack_helm_ingress
+  vars:
+    openstack_helm_ingress_endpoint: orchestration
+    openstack_helm_ingress_service_name: heat-api
+    openstack_helm_ingress_service_port: 8004
+
+- name: Create Ingress
+  ansible.builtin.include_role:
+    name: openstack_helm_ingress
+  vars:
+    openstack_helm_ingress_endpoint: cloudformation
+    openstack_helm_ingress_service_name: heat-cfn
+    openstack_helm_ingress_service_port: 8000
diff --git a/roles/openstack_helm_heat/vars/main.yml b/roles/openstack_helm_heat/vars/main.yml
new file mode 100644
index 0000000..abec726
--- /dev/null
+++ b/roles/openstack_helm_heat/vars/main.yml
@@ -0,0 +1,54 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+_openstack_helm_heat_values: "{{ __openstack_helm_heat_values | combine(openstack_helm_heat_values, recursive=True) }}"
+__openstack_helm_heat_values:
+  endpoints: "{{ openstack_helm_endpoints }}"
+  images:
+    tags:
+      bootstrap: "{{ openstack_helm_heat_image_repository }}/heat:{{ openstack_helm_heat_image_tag }}"
+      db_drop: "{{ openstack_helm_heat_image_repository }}/heat:{{ openstack_helm_heat_image_tag }}"
+      db_init: "{{ openstack_helm_heat_image_repository }}/heat:{{ openstack_helm_heat_image_tag }}"
+      dep_check: "{{ openstack_helm_heat_image_repository }}/kubernetes-entrypoint:latest"
+      heat_api: "{{ openstack_helm_heat_image_repository }}/heat:{{ openstack_helm_heat_image_tag }}"
+      heat_cfn: "{{ openstack_helm_heat_image_repository }}/heat:{{ openstack_helm_heat_image_tag }}"
+      heat_cloudwatch: "{{ openstack_helm_heat_image_repository }}/heat:{{ openstack_helm_heat_image_tag }}"
+      heat_db_sync: "{{ openstack_helm_heat_image_repository }}/heat:{{ openstack_helm_heat_image_tag }}"
+      heat_engine_cleaner: "{{ openstack_helm_heat_image_repository }}/heat:{{ openstack_helm_heat_image_tag }}"
+      heat_engine: "{{ openstack_helm_heat_image_repository }}/heat:{{ openstack_helm_heat_image_tag }}"
+      heat_purge_deleted: "{{ openstack_helm_heat_image_repository }}/heat:{{ openstack_helm_heat_image_tag }}"
+      ks_endpoints: "{{ openstack_helm_heat_image_repository }}/heat:{{ openstack_helm_heat_image_tag }}"
+      ks_service: "{{ openstack_helm_heat_image_repository }}/heat:{{ openstack_helm_heat_image_tag }}"
+      ks_user: "{{ openstack_helm_heat_image_repository }}/heat:{{ openstack_helm_heat_image_tag }}"
+      rabbit_init: "{{ openstack_helm_heat_image_repository }}/rabbitmq:3.8.23-management"
+  pod:
+    replicas:
+      api: 3
+      cfn: 3
+      cloudwatch: 3
+      engine: 3
+  conf:
+    heat:
+      DEFAULT:
+        auth_encryption_key: "{{ openstack_helm_heat_auth_encryption_key }}"
+        log_config_append: null
+        region_name_for_services: "{{ openstack_helm_endpoints['identity']['auth']['heat']['region_name'] }}"
+        server_keystone_endpoint_type: public
+      clients_keystone:
+        endpoint_type: publicURL
+  manifests:
+    ingress_api: false
+    ingress_cfn: false
+    service_ingress_api: false
+    service_ingress_cfn: false
diff --git a/roles/openstack_helm_horizon/defaults/main.yml b/roles/openstack_helm_horizon/defaults/main.yml
new file mode 100644
index 0000000..ba6c691
--- /dev/null
+++ b/roles/openstack_helm_horizon/defaults/main.yml
@@ -0,0 +1,23 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_helm_horizon_chart_repo_name: openstack-helm
+openstack_helm_horizon_chart_repo_url: https://tarballs.opendev.org/openstack/openstack-helm/
+openstack_helm_horizon_chart_name: horizon
+
+openstack_helm_horizon_image_repository: "{{ atmosphere_image_repository | default('us-docker.pkg.dev/vexxhost-infra/openstack') }}"
+openstack_helm_horizon_image_tag: 19.2.1.dev13
+openstack_helm_horizon_heat_image_tag: wallaby
+
+openstack_helm_horizon_values: {}
diff --git a/roles/openstack_helm_horizon/files/50-monasca-ui-settings.py b/roles/openstack_helm_horizon/files/50-monasca-ui-settings.py
new file mode 100644
index 0000000..28b4a99
--- /dev/null
+++ b/roles/openstack_helm_horizon/files/50-monasca-ui-settings.py
@@ -0,0 +1,56 @@
+from django.conf import settings
+from django.utils.translation import ugettext_lazy as _
+
+# Service group names (global across all projects):
+MONITORING_SERVICES_GROUPS = [
+    {'name': _('OpenStack Services'), 'groupBy': 'service'},
+    {'name': _('Servers'), 'groupBy': 'hostname'}
+]
+
+# Services being monitored
+MONITORING_SERVICES = getattr(
+    settings,
+    'MONITORING_SERVICES_GROUPS',
+    MONITORING_SERVICES_GROUPS
+)
+
+MONITORING_SERVICE_VERSION = getattr(
+    settings, 'MONITORING_SERVICE_VERSION', '2_0'
+)
+MONITORING_SERVICE_TYPE = getattr(
+    settings, 'MONITORING_SERVICE_TYPE', 'monitoring'
+)
+MONITORING_ENDPOINT_TYPE = getattr(
+    # NOTE(trebskit) # will default to OPENSTACK_ENDPOINT_TYPE
+    settings, 'MONITORING_ENDPOINT_TYPE', None
+)
+
+# Grafana button titles/file names (global across all projects):
+# GRAFANA_LINKS = [{"raw": True, "path": "monasca-dashboard", "title": "Sub page1"}]
+GRAFANA_LINKS = []
+DASHBOARDS = getattr(settings, 'GRAFANA_LINKS', GRAFANA_LINKS)
+
+GRAFANA_URL = {"regionOne": "/grafana"}
+
+SHOW_GRAFANA_HOME = getattr(settings, 'SHOW_GRAFANA_HOME', True)
+
+ENABLE_LOG_MANAGEMENT_BUTTON = getattr(
+    settings, 'ENABLE_LOG_MANAGEMENT_BUTTON', False)
+ENABLE_EVENT_MANAGEMENT_BUTTON = getattr(
+    settings, 'ENABLE_EVENT_MANAGEMENT_BUTTON', False)
+
+KIBANA_POLICY_RULE = getattr(settings, 'KIBANA_POLICY_RULE',
+                             'monitoring:kibana_access')
+KIBANA_POLICY_SCOPE = getattr(settings, 'KIBANA_POLICY_SCOPE',
+                              'monitoring')
+KIBANA_HOST = getattr(settings, 'KIBANA_HOST',
+                      'http://192.168.10.6:5601/')
+
+OPENSTACK_SSL_NO_VERIFY = getattr(
+    settings, 'OPENSTACK_SSL_NO_VERIFY', False)
+OPENSTACK_SSL_CACERT = getattr(
+    settings, 'OPENSTACK_SSL_CACERT', None)
+
+POLICY_FILES = getattr(settings, 'POLICY_FILES', {})
+POLICY_FILES.update({'monitoring': 'monitoring_policy.json', })  # noqa
+setattr(settings, 'POLICY_FILES', POLICY_FILES)
diff --git a/roles/openstack_helm_horizon/meta/main.yml b/roles/openstack_helm_horizon/meta/main.yml
new file mode 100644
index 0000000..8027f7c
--- /dev/null
+++ b/roles/openstack_helm_horizon/meta/main.yml
@@ -0,0 +1,19 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: "{{ openstack_helm_horizon_chart_repo_name }}"
+      helm_repository_repo_url: "{{ openstack_helm_horizon_chart_repo_url }}"
diff --git a/roles/openstack_helm_horizon/tasks/main.yml b/roles/openstack_helm_horizon/tasks/main.yml
new file mode 100644
index 0000000..58bea45
--- /dev/null
+++ b/roles/openstack_helm_horizon/tasks/main.yml
@@ -0,0 +1,46 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Generate OpenStack-Helm endpoints
+  ansible.builtin.include_role:
+    name: openstack_helm_endpoints
+  vars:
+    openstack_helm_endpoints_repo_name: "{{ openstack_helm_horizon_chart_repo_name }}"
+    openstack_helm_endpoints_repo_url: "{{ openstack_helm_horizon_chart_repo_url }}"
+    openstack_helm_endpoints_chart: "{{ openstack_helm_horizon_chart_name }}"
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: "{{ openstack_helm_horizon_chart_name }}"
+    chart_ref: "{{ openstack_helm_horizon_chart_repo_name }}/{{ openstack_helm_horizon_chart_name }}"
+    chart_version: 0.2.16
+    release_namespace: openstack
+    kubeconfig: /etc/kubernetes/admin.conf
+    values: "{{ _openstack_helm_horizon_values | combine(openstack_helm_horizon_values, recursive=True) }}"
+
+- name: Create Ingress
+  ansible.builtin.include_role:
+    name: openstack_helm_ingress
+  vars:
+    openstack_helm_ingress_endpoint: dashboard
+    openstack_helm_ingress_service_name: horizon-int
+    openstack_helm_ingress_service_port: 80
+    openstack_helm_ingress_paths:
+      - path: /
+        pathType: Prefix
+        backend:
+          service:
+            name: grafana
+            port:
+              number: 80
diff --git a/roles/openstack_helm_horizon/vars/main.yml b/roles/openstack_helm_horizon/vars/main.yml
new file mode 100644
index 0000000..b63c869
--- /dev/null
+++ b/roles/openstack_helm_horizon/vars/main.yml
@@ -0,0 +1,60 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+_openstack_helm_horizon_values:
+  endpoints: "{{ openstack_helm_endpoints }}"
+  images:
+    tags:
+      bootstrap: "{{ openstack_helm_horizon_image_repository }}/heat:{{ openstack_helm_horizon_heat_image_tag }}"
+      db_drop: "{{ openstack_helm_horizon_image_repository }}/heat:{{ openstack_helm_horizon_heat_image_tag }}"
+      db_init: "{{ openstack_helm_horizon_image_repository }}/heat:{{ openstack_helm_horizon_heat_image_tag }}"
+      dep_check: "{{ openstack_helm_horizon_image_repository }}/kubernetes-entrypoint:latest"
+      ks_endpoints: "{{ openstack_helm_horizon_image_repository }}/heat:{{ openstack_helm_horizon_heat_image_tag }}"
+      ks_service: "{{ openstack_helm_horizon_image_repository }}/heat:{{ openstack_helm_horizon_heat_image_tag }}"
+      ks_user: "{{ openstack_helm_horizon_image_repository }}/heat:{{ openstack_helm_horizon_heat_image_tag }}"
+      horizon_db_sync: "{{ openstack_helm_horizon_image_repository }}/horizon:{{ openstack_helm_horizon_image_tag }}"
+      horizon: "{{ openstack_helm_horizon_image_repository }}/horizon:{{ openstack_helm_horizon_image_tag }}"
+      rabbit_init: "{{ openstack_helm_horizon_image_repository }}/rabbitmq:3.8.23-management"
+  pod:
+    replicas:
+      server: 3
+  conf:
+    horizon:
+      local_settings:
+        config:
+          secure_proxy_ssl_header: "True"
+          horizon_images_upload_mode: direct
+          openstack_enable_password_retrieve: "True"
+          raw:
+            WEBSSO_KEYSTONE_URL: "https://{{ openstack_helm_endpoints['identity']['scheme']['public'] }}://{{ openstack_helm_endpoints['identity']['host_fqdn_override']['public']['host'] }}/v3"
+      local_settings_d:
+        _50_monasca_ui_settings: "{{ lookup('file', '50-monasca-ui-settings.py') }}"
+      extra_panels:
+        - designatedashboard
+        - heat_dashboard
+        - ironic_ui
+        - magnum_ui
+        - monitoring
+        - neutron_vpnaas_dashboard
+        - octavia_dashboard
+        - senlin_dashboard
+      policy:
+        monitoring:
+          default: "@"
+          monasca_user_role: role:monasca-user
+          monitoring:monitoring: rule:monasca_user_role
+          monitoring:kibana_access: rule:monasca_user_role
+  manifests:
+    ingress_api: false
+    service_ingress_api: false
diff --git a/roles/openstack_helm_infra_ceph_provisioners/defaults/main.yml b/roles/openstack_helm_infra_ceph_provisioners/defaults/main.yml
new file mode 100644
index 0000000..f450a53
--- /dev/null
+++ b/roles/openstack_helm_infra_ceph_provisioners/defaults/main.yml
@@ -0,0 +1,23 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_helm_infra_ceph_provisioners_chart_repo_name: openstack-helm-infra
+openstack_helm_infra_ceph_provisioners_chart_repo_url: https://tarballs.opendev.org/openstack/openstack-helm-infra/
+openstack_helm_infra_ceph_provisioners_chart_name: ceph-provisioners
+
+openstack_helm_infra_ceph_provisioners_ceph_monitors: "{{ _ceph_csi_rbd_helm_info.status['values']['csiConfig'][0]['monitors'] }}"
+openstack_helm_infra_ceph_provisioners_ceph_fsid: "{{ _ceph_csi_rbd_helm_info.status['values']['csiConfig'][0]['clusterID'] }}"
+
+openstack_helm_infra_ceph_provisioners_ceph_public_network: "{{ ceph_mon_public_network }}"
+openstack_helm_infra_ceph_provisioners_ceph_cluster_network: "{{ openstack_helm_infra_ceph_provisioners_ceph_public_network }}"
diff --git a/roles/openstack_helm_infra_ceph_provisioners/meta/main.yml b/roles/openstack_helm_infra_ceph_provisioners/meta/main.yml
new file mode 100644
index 0000000..7f7df71
--- /dev/null
+++ b/roles/openstack_helm_infra_ceph_provisioners/meta/main.yml
@@ -0,0 +1,20 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: "{{ openstack_helm_infra_ceph_provisioners_chart_repo_name }}"
+      helm_repository_repo_url: "{{ openstack_helm_infra_ceph_provisioners_chart_repo_url }}"
+  - ceph_csi_rbd
diff --git a/roles/openstack_helm_infra_ceph_provisioners/tasks/main.yml b/roles/openstack_helm_infra_ceph_provisioners/tasks/main.yml
new file mode 100644
index 0000000..b43c3b1
--- /dev/null
+++ b/roles/openstack_helm_infra_ceph_provisioners/tasks/main.yml
@@ -0,0 +1,128 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Retrieve Helm values for "ceph-csi-rbd"
+  kubernetes.core.helm_info:
+    name: ceph-csi-rbd
+    release_namespace: kube-system
+  register: _ceph_csi_rbd_helm_info
+
+- name: Create Ceph service
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: v1
+      kind: Service
+      metadata:
+        name: ceph-mon
+        namespace: openstack
+        labels:
+          application: ceph
+      spec:
+        clusterIP: None
+        ports:
+          - name: mon
+            port: 6789
+            targetPort: 6789
+          - name: mon-msgr2
+            port: 3300
+            targetPort: 3300
+          - name: metrics
+            port: 9283
+            targetPort: 9283
+
+- name: Generate Ceph endpoint list
+  ansible.builtin.set_fact:
+    _openstack_helm_infra_ceph_provisioners_ceph_monitors: |
+      {{ 
+        _openstack_helm_infra_ceph_provisioners_ceph_monitors | default([]) +
+          [{'ip': item}]
+      }}
+  loop: "{{ openstack_helm_infra_ceph_provisioners_ceph_monitors }}"
+
+- name: Create Ceph endpoints
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: v1
+      kind: Endpoints
+      metadata:
+        name: ceph-mon
+        namespace: openstack
+        labels:
+          application: ceph
+      subsets:
+        - addresses: "{{ _openstack_helm_infra_ceph_provisioners_ceph_monitors }}"
+          ports:
+            - name: mon
+              port: 6789
+              protocol: TCP
+            - name: mon-msgr2
+              port: 3300
+              protocol: TCP
+            - name: metrics
+              port: 9283
+              protocol: TCP
+
+- name: Retrieve client.admin keyring
+  vexxhost.atmosphere.ceph_key:
+    name: client.admin
+    state: info
+    output_format: json
+  register: _openstack_helm_infra_ceph_provisioners_ceph_key
+
+- name: Parse client.admin keyring
+  ansible.builtin.set_fact:
+    _openstack_helm_infra_ceph_provisioners_keyring: "{{ _openstack_helm_infra_ceph_provisioners_ceph_key.stdout | from_json | first }}"
+
+- name: Create "pvc-ceph-client-key" secret
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: v1
+      kind: Secret
+      type: kubernetes.io/rbd
+      metadata:
+        name: pvc-ceph-client-key
+        namespace: openstack
+        labels:
+          application: ceph
+      stringData:
+        key: "{{ _openstack_helm_infra_ceph_provisioners_keyring.key }}"
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: "{{ openstack_helm_infra_ceph_provisioners_chart_name }}"
+    chart_ref: "{{ openstack_helm_infra_ceph_provisioners_chart_repo_name }}/{{ openstack_helm_infra_ceph_provisioners_chart_name }}"
+    chart_version: 0.1.17
+    release_namespace: openstack
+    kubeconfig: /etc/kubernetes/admin.conf
+    values:
+      network:
+        public: "{{ openstack_helm_infra_ceph_provisioners_ceph_public_network }}"
+        cluster: "{{ openstack_helm_infra_ceph_provisioners_ceph_cluster_network }}"
+      conf:
+        ceph:
+          global:
+            fsid: "{{ openstack_helm_infra_ceph_provisioners_ceph_fsid }}"
+      manifests:
+        configmap_bin: false
+        configmap_bin_common: false
+        deployment_rbd_provisioner: false
+        deployment_csi_rbd_provisioner: false
+        deployment_cephfs_provisioner: false
+        job_cephfs_client_key: false
+        job_namespace_client_key_cleaner: false
+        job_namespace_client_key: false
+        storageclass: false
diff --git a/roles/openstack_helm_infra_libvirt/defaults/main.yml b/roles/openstack_helm_infra_libvirt/defaults/main.yml
new file mode 100644
index 0000000..c2c4511
--- /dev/null
+++ b/roles/openstack_helm_infra_libvirt/defaults/main.yml
@@ -0,0 +1,22 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_helm_infra_libvirt_chart_repo_name: openstack-helm-infra
+openstack_helm_infra_libvirt_chart_repo_url: https://tarballs.opendev.org/openstack/openstack-helm-infra/
+openstack_helm_infra_libvirt_chart_name: libvirt
+
+openstack_helm_infra_libvirt_image_repository: "{{ atmosphere_image_repository | default('us-docker.pkg.dev/vexxhost-infra/openstack') }}"
+openstack_helm_infra_libvirt_image_tag: wallaby
+
+openstack_helm_infra_libvirt_values: {}
diff --git a/roles/openstack_helm_infra_libvirt/meta/main.yml b/roles/openstack_helm_infra_libvirt/meta/main.yml
new file mode 100644
index 0000000..67fb174
--- /dev/null
+++ b/roles/openstack_helm_infra_libvirt/meta/main.yml
@@ -0,0 +1,19 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: "{{ openstack_helm_infra_libvirt_chart_repo_name }}"
+      helm_repository_repo_url: "{{ openstack_helm_infra_libvirt_chart_repo_url }}"
diff --git a/roles/openstack_helm_infra_libvirt/tasks/main.yml b/roles/openstack_helm_infra_libvirt/tasks/main.yml
new file mode 100644
index 0000000..46df5d1
--- /dev/null
+++ b/roles/openstack_helm_infra_libvirt/tasks/main.yml
@@ -0,0 +1,30 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Generate OpenStack-Helm endpoints
+  ansible.builtin.include_role:
+    name: openstack_helm_endpoints
+  vars:
+    openstack_helm_endpoints_repo_name: "{{ openstack_helm_infra_libvirt_chart_repo_name }}"
+    openstack_helm_endpoints_repo_url: "{{ openstack_helm_infra_libvirt_chart_repo_url }}"
+    openstack_helm_endpoints_chart: "{{ openstack_helm_infra_libvirt_chart_name }}"
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: "{{ openstack_helm_infra_libvirt_chart_name }}"
+    chart_ref: "{{ openstack_helm_infra_libvirt_chart_repo_name }}/{{ openstack_helm_infra_libvirt_chart_name }}"
+    chart_version: 0.1.8
+    release_namespace: openstack
+    kubeconfig: /etc/kubernetes/admin.conf
+    values: "{{ _openstack_helm_infra_libvirt_values | combine(openstack_helm_infra_libvirt_values, recursive=True) }}"
diff --git a/roles/openstack_helm_infra_libvirt/vars/main.yml b/roles/openstack_helm_infra_libvirt/vars/main.yml
new file mode 100644
index 0000000..f79b2a8
--- /dev/null
+++ b/roles/openstack_helm_infra_libvirt/vars/main.yml
@@ -0,0 +1,24 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+_openstack_helm_infra_libvirt_values:
+  endpoints: "{{ openstack_helm_endpoints }}"
+  images:
+    tags:
+      ceph_config_helper: "{{ openstack_helm_infra_libvirt_image_repository }}/libvirt:{{ openstack_helm_infra_libvirt_image_tag }}"
+      dep_check: "{{ openstack_helm_infra_libvirt_image_repository }}/kubernetes-entrypoint:latest"
+      libvirt: "{{ openstack_helm_infra_libvirt_image_repository }}/libvirt:{{ openstack_helm_infra_libvirt_image_tag }}"
+  conf:
+    libvirt:
+      listen_addr: 0.0.0.0
diff --git a/roles/openstack_helm_infra_memcached/defaults/main.yml b/roles/openstack_helm_infra_memcached/defaults/main.yml
new file mode 100644
index 0000000..23a09da
--- /dev/null
+++ b/roles/openstack_helm_infra_memcached/defaults/main.yml
@@ -0,0 +1,20 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_helm_infra_memcached_chart_repo_name: openstack-helm-infra
+openstack_helm_infra_memcached_chart_repo_url: https://tarballs.opendev.org/openstack/openstack-helm-infra/
+openstack_helm_infra_memcached_chart_name: memcached
+
+openstack_helm_infra_memcached_diff: false
+openstack_helm_infra_memcached_values: {}
diff --git a/roles/openstack_helm_infra_memcached/meta/main.yml b/roles/openstack_helm_infra_memcached/meta/main.yml
new file mode 100644
index 0000000..0157d71
--- /dev/null
+++ b/roles/openstack_helm_infra_memcached/meta/main.yml
@@ -0,0 +1,19 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: "{{ openstack_helm_infra_memcached_chart_repo_name }}"
+      helm_repository_repo_url: "{{ openstack_helm_infra_memcached_chart_repo_url }}"
diff --git a/roles/openstack_helm_infra_memcached/tasks/main.yml b/roles/openstack_helm_infra_memcached/tasks/main.yml
new file mode 100644
index 0000000..491e3bc
--- /dev/null
+++ b/roles/openstack_helm_infra_memcached/tasks/main.yml
@@ -0,0 +1,122 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Generate OpenStack-Helm endpoints
+  ansible.builtin.include_role:
+    name: openstack_helm_endpoints
+  vars:
+    openstack_helm_endpoints_repo_name: "{{ openstack_helm_infra_memcached_chart_repo_name }}"
+    openstack_helm_endpoints_repo_url: "{{ openstack_helm_infra_memcached_chart_repo_url }}"
+    openstack_helm_endpoints_chart: "{{ openstack_helm_infra_memcached_chart_name }}"
+
+- name: Generate Helm values comparison
+  ansible.builtin.include_role:
+    name: helm_diff
+  vars:
+    helm_diff_release_name: "{{ openstack_helm_infra_memcached_chart_name }}"
+    helm_diff_release_namespace: openstack
+    helm_diff_values: "{{ _openstack_helm_infra_memcached_values }}"
+  when:
+    - openstack_helm_infra_memcached_diff | bool
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: "{{ openstack_helm_infra_memcached_chart_name }}"
+    chart_ref: "{{ openstack_helm_infra_memcached_chart_repo_name }}/{{ openstack_helm_infra_memcached_chart_name }}"
+    chart_version: 0.1.6
+    release_namespace: openstack
+    create_namespace: true
+    kubeconfig: /etc/kubernetes/admin.conf
+    values: "{{ _openstack_helm_infra_memcached_values }}"
+
+- name: Create Service for metrics
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: v1
+      kind: Service
+      metadata:
+        name: memcached-metrics
+        namespace: openstack
+        labels:
+          application: memcached
+          component: server
+      spec:
+        selector:
+          application: memcached
+          component: server
+        ports:
+          - name: metrics
+            port: 9150
+            targetPort: 9150
+
+- name: Create ServiceMonitor
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: monitoring.coreos.com/v1
+      kind: ServiceMonitor
+      metadata:
+        name: memcached
+        namespace: monitoring
+        labels:
+          release: kube-prometheus-stack
+      spec:
+        jobLabel: application
+        endpoints:
+          - port: "metrics"
+            path: "/metrics"
+            relabelings:
+              - sourceLabels: ["__meta_kubernetes_pod_name"]
+                targetLabel: "instance"
+              - action: "labeldrop"
+                regex: "^(container|endpoint|namespace|pod|service)$"
+        namespaceSelector:
+          matchNames:
+            - openstack
+        selector:
+          matchLabels:
+            application: memcached
+            component: server
+
+- name: Create PrometheusRule
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: monitoring.coreos.com/v1
+      kind: PrometheusRule
+      metadata:
+        name: memcached
+        namespace: monitoring
+        labels:
+          release: kube-prometheus-stack
+      spec:
+        groups:
+          - name: memcached
+            rules:
+              - alert: MemcachedDown
+                expr: memcached_up == 0
+                for: 5m
+                labels:
+                  severity: critical
+              - alert: MemcachedConnectionLimitApproaching
+                expr: (memcached_current_connections / memcached_max_connections * 100) > 80
+                for: 5m
+                labels:
+                  severity: warning
+              - alert: MemcachedConnectionLimitApproaching
+                expr: (memcached_current_connections / memcached_max_connections * 100) > 95
+                for: 5m
+                labels:
+                  severity: critical
diff --git a/roles/openstack_helm_infra_memcached/vars/main.yml b/roles/openstack_helm_infra_memcached/vars/main.yml
new file mode 100644
index 0000000..4acb969
--- /dev/null
+++ b/roles/openstack_helm_infra_memcached/vars/main.yml
@@ -0,0 +1,24 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+_openstack_helm_infra_memcached_values: "{{ __openstack_helm_infra_memcached_values | combine(openstack_helm_infra_memcached_values, recursive=True) }}"
+__openstack_helm_infra_memcached_values:
+  endpoints: "{{ openstack_helm_endpoints }}"
+  images:
+    tags:
+      memcached: quay.io/vexxhost/memcached:1.6.9
+      prometheus_memcached_exporter: quay.io/vexxhost/memcached-exporter:v0.9.0-1
+  monitoring:
+    prometheus:
+      enabled: true
diff --git a/roles/openstack_helm_infra_openvswitch/defaults/main.yml b/roles/openstack_helm_infra_openvswitch/defaults/main.yml
new file mode 100644
index 0000000..c8c6a93
--- /dev/null
+++ b/roles/openstack_helm_infra_openvswitch/defaults/main.yml
@@ -0,0 +1,22 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_helm_infra_openvswitch_chart_repo_name: openstack-helm-infra
+openstack_helm_infra_openvswitch_chart_repo_url: https://tarballs.opendev.org/openstack/openstack-helm-infra/
+openstack_helm_infra_openvswitch_chart_name: openvswitch
+
+openstack_helm_infra_openvswitch_image_repository: "{{ atmosphere_image_repository | default('us-docker.pkg.dev/vexxhost-infra/openstack') }}"
+openstack_helm_infra_openvswitch_image_tag: wallaby
+
+openstack_helm_infra_openvswitch_values: {}
diff --git a/roles/openstack_helm_infra_openvswitch/meta/main.yml b/roles/openstack_helm_infra_openvswitch/meta/main.yml
new file mode 100644
index 0000000..f31873c
--- /dev/null
+++ b/roles/openstack_helm_infra_openvswitch/meta/main.yml
@@ -0,0 +1,20 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: "{{ openstack_helm_infra_openvswitch_chart_repo_name }}"
+      helm_repository_repo_url: "{{ openstack_helm_infra_openvswitch_chart_repo_url }}"
+  - openstack_namespace
diff --git a/roles/openstack_helm_infra_openvswitch/tasks/main.yml b/roles/openstack_helm_infra_openvswitch/tasks/main.yml
new file mode 100644
index 0000000..372a5e4
--- /dev/null
+++ b/roles/openstack_helm_infra_openvswitch/tasks/main.yml
@@ -0,0 +1,30 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Generate OpenStack-Helm endpoints
+  ansible.builtin.include_role:
+    name: openstack_helm_endpoints
+  vars:
+    openstack_helm_endpoints_repo_name: "{{ openstack_helm_infra_openvswitch_chart_repo_name }}"
+    openstack_helm_endpoints_repo_url: "{{ openstack_helm_infra_openvswitch_chart_repo_url }}"
+    openstack_helm_endpoints_chart: "{{ openstack_helm_infra_openvswitch_chart_name }}"
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: "{{ openstack_helm_infra_openvswitch_chart_name }}"
+    chart_ref: "{{ openstack_helm_infra_openvswitch_chart_repo_name }}/{{ openstack_helm_infra_openvswitch_chart_name }}"
+    chart_version: 0.1.6
+    release_namespace: openstack
+    kubeconfig: /etc/kubernetes/admin.conf
+    values: "{{ _openstack_helm_infra_openvswitch_values | combine(openstack_helm_infra_openvswitch_values, recursive=True) }}"
diff --git a/roles/openstack_helm_infra_openvswitch/vars/main.yml b/roles/openstack_helm_infra_openvswitch/vars/main.yml
new file mode 100644
index 0000000..e84639e
--- /dev/null
+++ b/roles/openstack_helm_infra_openvswitch/vars/main.yml
@@ -0,0 +1,21 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+_openstack_helm_infra_openvswitch_values:
+  endpoints: "{{ openstack_helm_endpoints }}"
+  images:
+    tags:
+      dep_check: "{{ openstack_helm_infra_openvswitch_image_repository }}/kubernetes-entrypoint:latest"
+      openvswitch_db_server: "{{ openstack_helm_infra_openvswitch_image_repository }}/openvswitch:{{ openstack_helm_infra_openvswitch_image_tag }}"
+      openvswitch_vswitchd: "{{ openstack_helm_infra_openvswitch_image_repository }}/openvswitch:{{ openstack_helm_infra_openvswitch_image_tag }}"
diff --git a/roles/openstack_helm_infra_rabbitmq/defaults/main.yml b/roles/openstack_helm_infra_rabbitmq/defaults/main.yml
new file mode 100644
index 0000000..b248ba7
--- /dev/null
+++ b/roles/openstack_helm_infra_rabbitmq/defaults/main.yml
@@ -0,0 +1,19 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_helm_infra_rabbitmq_chart_repo_name: openstack-helm-infra
+openstack_helm_infra_rabbitmq_chart_repo_url: https://tarballs.opendev.org/openstack/openstack-helm-infra/
+openstack_helm_infra_rabbitmq_chart_name: rabbitmq
+
+openstack_helm_infra_rabbitmq_image_repository: "{{ atmosphere_image_repository | default('us-docker.pkg.dev/vexxhost-infra/openstack') }}"
diff --git a/roles/openstack_helm_infra_rabbitmq/meta/main.yml b/roles/openstack_helm_infra_rabbitmq/meta/main.yml
new file mode 100644
index 0000000..53614f8
--- /dev/null
+++ b/roles/openstack_helm_infra_rabbitmq/meta/main.yml
@@ -0,0 +1,19 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: "{{ openstack_helm_infra_rabbitmq_chart_repo_name }}"
+      helm_repository_repo_url: "{{ openstack_helm_infra_rabbitmq_chart_repo_url }}"
diff --git a/roles/openstack_helm_infra_rabbitmq/tasks/main.yml b/roles/openstack_helm_infra_rabbitmq/tasks/main.yml
new file mode 100644
index 0000000..7e50b0e
--- /dev/null
+++ b/roles/openstack_helm_infra_rabbitmq/tasks/main.yml
@@ -0,0 +1,159 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Generate OpenStack-Helm endpoints
+  ansible.builtin.include_role:
+    name: openstack_helm_endpoints
+  vars:
+    openstack_helm_endpoints_repo_name: "{{ openstack_helm_infra_rabbitmq_chart_repo_name }}"
+    openstack_helm_endpoints_repo_url: "{{ openstack_helm_infra_rabbitmq_chart_repo_url }}"
+    openstack_helm_endpoints_chart: "{{ openstack_helm_infra_rabbitmq_chart_name }}"
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: "{{ openstack_helm_infra_rabbitmq_chart_name }}"
+    chart_ref: "{{ openstack_helm_infra_rabbitmq_chart_repo_name }}/{{ openstack_helm_infra_rabbitmq_chart_name }}"
+    chart_version: 0.1.15
+    release_namespace: openstack
+    kubeconfig: /etc/kubernetes/admin.conf
+    values:
+      endpoints: "{{ openstack_helm_endpoints }}"
+      images:
+        tags:
+          dep_check: "{{ openstack_helm_infra_rabbitmq_image_repository }}/kubernetes-entrypoint:latest"
+          rabbitmq_init: "{{ openstack_helm_infra_rabbitmq_image_repository }}/cli:latest"
+          rabbitmq: "{{ openstack_helm_infra_rabbitmq_image_repository }}/rabbitmq:3.8.23"
+      pod:
+        replicas:
+          server: 3
+      conf:
+        enabled_plugins:
+          - rabbitmq_management
+          - rabbitmq_peer_discovery_k8s
+          - rabbitmq_prometheus
+      volume:
+        size: 10Gi
+      manifests:
+        ingress_management: false
+
+- name: Create Service for metrics
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: v1
+      kind: Service
+      metadata:
+        name: rabbitmq-metrics
+        namespace: openstack
+        labels:
+          application: rabbitmq
+          component: server
+      spec:
+        selector:
+          application: rabbitmq
+          component: server
+        ports:
+          - name: metrics
+            port: 15692
+            targetPort: 15692
+
+- name: Create ServiceMonitor
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: monitoring.coreos.com/v1
+      kind: ServiceMonitor
+      metadata:
+        name: rabbitmq
+        namespace: monitoring
+        labels:
+          release: kube-prometheus-stack
+      spec:
+        jobLabel: application
+        endpoints:
+          - port: "metrics"
+            path: "/metrics"
+            relabelings:
+              - sourceLabels: ["__meta_kubernetes_pod_name"]
+                targetLabel: "instance"
+              - action: "labeldrop"
+                regex: "^(container|endpoint|namespace|pod|service)$"
+        namespaceSelector:
+          matchNames:
+            - openstack
+        selector:
+          matchLabels:
+            application: rabbitmq
+            component: server
+
+- name: Create PrometheusRule
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: monitoring.coreos.com/v1
+      kind: PrometheusRule
+      metadata:
+        name: rabbitmq
+        namespace: monitoring
+        labels:
+          release: kube-prometheus-stack
+      spec:
+        groups:
+          - name: rules
+            rules:
+              - alert: RabbitmqDown
+                expr: absent(rabbitmq_build_info)
+                for: 1m
+                labels:
+                  severity: critical
+              - alert: RabbitmqNodeDown
+                expr: sum(rabbitmq_build_info) < 3
+                for: 1m
+                labels:
+                  severity: critical
+              - alert: RabbitmqNodeNotDistributed
+                expr: erlang_vm_dist_node_state < 3
+                for: 1m
+                labels:
+                  severity: critical
+              - alert: RabbitmqMemoryHigh
+                expr: rabbitmq_process_resident_memory_bytes / rabbitmq_resident_memory_limit_bytes > 0.80
+                labels:
+                  severity: warning
+              - alert: RabbitmqMemoryHigh
+                expr: rabbitmq_process_resident_memory_bytes / rabbitmq_resident_memory_limit_bytes > 0.95
+                labels:
+                  severity: critical
+              - alert: RabbitmqFileDescriptorsUsage
+                expr: rabbitmq_process_open_fds / rabbitmq_process_max_fds > 0.80
+                labels:
+                  severity: warning
+              - alert: RabbitmqFileDescriptorsUsage
+                expr: rabbitmq_process_open_fds / rabbitmq_process_max_fds > 0.95
+                labels:
+                  severity: critical
+              - alert: RabbitmqUnackedMessages
+                expr: sum(rabbitmq_queue_messages_unacked) BY (queue) > 1000
+                for: 5m
+                labels:
+                  severity: warning
+              - alert: RabbitmqUnackedMessages
+                expr: sum(rabbitmq_queue_messages_unacked) BY (queue) > 1000
+                for: 1h
+                labels:
+                  severity: critical
+              - alert: RabbitmqConnections
+                expr: rabbitmq_connections > 1000
+                labels:
+                  severity: warning
diff --git a/roles/openstack_helm_ingress/defaults/main.yml b/roles/openstack_helm_ingress/defaults/main.yml
new file mode 100644
index 0000000..9ec5bd9
--- /dev/null
+++ b/roles/openstack_helm_ingress/defaults/main.yml
@@ -0,0 +1,16 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_helm_ingress_annotations: {}
+openstack_helm_ingress_paths: []
diff --git a/roles/openstack_helm_ingress/tasks/main.yml b/roles/openstack_helm_ingress/tasks/main.yml
new file mode 100644
index 0000000..11b0cdf
--- /dev/null
+++ b/roles/openstack_helm_ingress/tasks/main.yml
@@ -0,0 +1,34 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Create Ingress ({{ openstack_helm_ingress_endpoint }})
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: v1
+      kind: Ingress
+      metadata:
+        name: "{{ openstack_helm_ingress_endpoint | replace('_', '-') }}"
+        namespace: openstack
+        annotations: "{{ _openstack_helm_ingress_annotations | combine(openstack_helm_ingress_annotations, recursive=True) }}"
+      spec:
+        ingressClassName: openstack
+        rules:
+          - host: "{{ openstack_helm_endpoints[openstack_helm_ingress_endpoint]['host_fqdn_override']['public']['host'] }}"
+            http:
+              paths: "{{ _openstack_helm_ingress_paths }}"
+        tls:
+          - secretName: "{{ openstack_helm_ingress_service_name }}-certs"
+            hosts:
+              - "{{ openstack_helm_endpoints[openstack_helm_ingress_endpoint]['host_fqdn_override']['public']['host'] }}"
diff --git a/roles/openstack_helm_ingress/vars/main.yml b/roles/openstack_helm_ingress/vars/main.yml
new file mode 100644
index 0000000..a000c50
--- /dev/null
+++ b/roles/openstack_helm_ingress/vars/main.yml
@@ -0,0 +1,26 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+_openstack_helm_ingress_annotations:
+  cert-manager.io/issuer: openstack
+
+_openstack_helm_ingress_paths: "{{ openstack_helm_ingress_paths + __openstack_helm_ingress_paths }}"
+__openstack_helm_ingress_paths:
+  - path: /
+    pathType: Prefix
+    backend:
+      service:
+        name: "{{ openstack_helm_ingress_service_name }}"
+        port:
+          number: "{{ openstack_helm_ingress_service_port }}"
diff --git a/roles/openstack_helm_keystone/defaults/main.yml b/roles/openstack_helm_keystone/defaults/main.yml
new file mode 100644
index 0000000..7f2358b
--- /dev/null
+++ b/roles/openstack_helm_keystone/defaults/main.yml
@@ -0,0 +1,23 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_helm_keystone_chart_repo_name: openstack-helm
+openstack_helm_keystone_chart_repo_url: https://tarballs.opendev.org/openstack/openstack-helm/
+openstack_helm_keystone_chart_name: keystone
+
+openstack_helm_keystone_image_repository: "{{ atmosphere_image_repository | default('us-docker.pkg.dev/vexxhost-infra/openstack') }}"
+openstack_helm_keystone_image_tag: 19.0.1.dev11
+openstack_helm_keystone_heat_image_tag: wallaby
+
+openstack_helm_keystone_values: {}
diff --git a/roles/openstack_helm_keystone/meta/main.yml b/roles/openstack_helm_keystone/meta/main.yml
new file mode 100644
index 0000000..136ce7c
--- /dev/null
+++ b/roles/openstack_helm_keystone/meta/main.yml
@@ -0,0 +1,19 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: "{{ openstack_helm_keystone_chart_repo_name }}"
+      helm_repository_repo_url: "{{ openstack_helm_keystone_chart_repo_url }}"
diff --git a/roles/openstack_helm_keystone/tasks/main.yml b/roles/openstack_helm_keystone/tasks/main.yml
new file mode 100644
index 0000000..45e2de5
--- /dev/null
+++ b/roles/openstack_helm_keystone/tasks/main.yml
@@ -0,0 +1,39 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Generate OpenStack-Helm endpoints
+  ansible.builtin.include_role:
+    name: openstack_helm_endpoints
+  vars:
+    openstack_helm_endpoints_repo_name: "{{ openstack_helm_keystone_chart_repo_name }}"
+    openstack_helm_endpoints_repo_url: "{{ openstack_helm_keystone_chart_repo_url }}"
+    openstack_helm_endpoints_chart: "{{ openstack_helm_keystone_chart_name }}"
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: "{{ openstack_helm_keystone_chart_name }}"
+    chart_ref: "{{ openstack_helm_keystone_chart_repo_name }}/{{ openstack_helm_keystone_chart_name }}"
+    chart_version: 0.2.19
+    release_namespace: openstack
+    create_namespace: true
+    kubeconfig: /etc/kubernetes/admin.conf
+    values: "{{ _openstack_helm_keystone_values | combine(openstack_helm_keystone_values, recursive=True) }}"
+
+- name: Create Ingress
+  ansible.builtin.include_role:
+    name: openstack_helm_ingress
+  vars:
+    openstack_helm_ingress_endpoint: identity
+    openstack_helm_ingress_service_name: keystone-api
+    openstack_helm_ingress_service_port: 5000
diff --git a/roles/openstack_helm_keystone/vars/main.yml b/roles/openstack_helm_keystone/vars/main.yml
new file mode 100644
index 0000000..5b384c1
--- /dev/null
+++ b/roles/openstack_helm_keystone/vars/main.yml
@@ -0,0 +1,229 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+_openstack_helm_keystone_values:
+  endpoints: "{{ openstack_helm_endpoints }}"
+  images:
+    tags:
+      bootstrap: "{{ openstack_helm_keystone_image_repository }}/heat:{{ openstack_helm_keystone_heat_image_tag }}"
+      db_drop: "{{ openstack_helm_keystone_image_repository }}/heat:{{ openstack_helm_keystone_heat_image_tag }}"
+      db_init: "{{ openstack_helm_keystone_image_repository }}/heat:{{ openstack_helm_keystone_heat_image_tag }}"
+      dep_check: "{{ openstack_helm_keystone_image_repository }}/kubernetes-entrypoint:latest"
+      keystone_api: "{{ openstack_helm_keystone_image_repository }}/keystone:{{ openstack_helm_keystone_image_tag }}"
+      keystone_credential_cleanup: "{{ openstack_helm_keystone_image_repository }}/heat:{{ openstack_helm_keystone_heat_image_tag }}"
+      keystone_credential_rotate: "{{ openstack_helm_keystone_image_repository }}/keystone:{{ openstack_helm_keystone_image_tag }}"
+      keystone_credential_setup: "{{ openstack_helm_keystone_image_repository }}/keystone:{{ openstack_helm_keystone_image_tag }}"
+      keystone_db_sync: "{{ openstack_helm_keystone_image_repository }}/keystone:{{ openstack_helm_keystone_image_tag }}"
+      keystone_domain_manage: "{{ openstack_helm_keystone_image_repository }}/keystone:{{ openstack_helm_keystone_image_tag }}"
+      keystone_fernet_rotate: "{{ openstack_helm_keystone_image_repository }}/keystone:{{ openstack_helm_keystone_image_tag }}"
+      keystone_fernet_setup: "{{ openstack_helm_keystone_image_repository }}/keystone:{{ openstack_helm_keystone_image_tag }}"
+      ks_user: "{{ openstack_helm_keystone_image_repository }}/heat:{{ openstack_helm_keystone_heat_image_tag }}"
+      rabbit_init: "{{ openstack_helm_keystone_image_repository }}/rabbitmq:3.8.23-management"
+  pod:
+    #     mounts = {
+    #       keystone_api = {
+    #         keystone_api = {
+    #           volumeMounts = [
+    #             {
+    #               name      = kubernetes_config_map.keystone_ldap_ca.metadata[0].name
+    #               mountPath = "/etc/keystone/ldap"
+    #             },
+    #             {
+    #               name      = kubernetes_config_map.keystone_openid_connect_metadata.metadata[0].name
+    #               mountPath = "/var/lib/apache2/oidc"
+    #             }
+    #           ],
+    #           volumes = [
+    #             {
+    #               name = kubernetes_config_map.keystone_ldap_ca.metadata[0].name
+    #               configMap = {
+    #                 name = kubernetes_config_map.keystone_ldap_ca.metadata[0].name
+    #               }
+    #             },
+    #             {
+    #               name = kubernetes_config_map.keystone_openid_connect_metadata.metadata[0].name
+    #               configMap = {
+    #                 name = kubernetes_config_map.keystone_openid_connect_metadata.metadata[0].name
+    #               }
+    #             }
+    #           ]
+    #         }
+    #       }
+    #     },
+    replicas:
+      api: 3
+  conf:
+    keystone:
+      DEFAULT:
+        log_config_append: null
+      auth:
+        methods: password,token,openid,application_credential
+      cors:
+        allowed_origins: "*"
+      federation:
+        assertion_prefix: OIDC-
+        remote_id_attribute: OIDC-iss
+        # TODO(mnaser): Lookup using openstack_helm_endpoints
+        trusted_dashboard: "https://{{ openstack_helm_endpoints_horizon_api_host }}/auth/websso/"
+      identity:
+        domain_configuration_from_database: true
+  manifests:
+    job_credential_cleanup: false
+    ingress_api: false
+    service_ingress_api: false
+# # LDAP configuration
+# yamlencode({
+#   conf = {
+#     ks_domains = {
+#       for domain, details in var.keystone_ldap_domains : domain => {
+#         identity = {
+#           driver = "ldap"
+#         }
+#         ldap = merge({
+#           tls_cacertfile = "/etc/keystone/ldap/${domain}.crt"
+#         }, details.conf)
+#       }
+#     }
+#   }
+# }),
+
+# # OpenID Connect
+# yamlencode({
+#   bootstrap = {
+#     script = <<-EOT
+#     # Create role for publishing images
+#     openstack role create --or-show image-publisher
+
+#     # Add member role for admin user
+#     openstack role add \
+#           --user="$${OS_USERNAME}" \
+#           --user-domain="$${OS_USER_DOMAIN_NAME}" \
+#           --project-domain="$${OS_PROJECT_DOMAIN_NAME}" \
+#           --project="$${OS_PROJECT_NAME}" \
+#           "member"
+
+#     # Create project for tempest-pushgateway
+#     openstack project create --or-show \
+#       "${kubernetes_secret.tempest_pushgateway.data.OS_PROJECT_NAME}"
+#     openstack user create --or-show \
+#       "${kubernetes_secret.tempest_pushgateway.data.OS_USERNAME}"
+#     openstack user set \
+#       --password="${kubernetes_secret.tempest_pushgateway.data.OS_PASSWORD}" \
+#       "${kubernetes_secret.tempest_pushgateway.data.OS_USERNAME}"
+#     openstack role add \
+#       --user="${kubernetes_secret.tempest_pushgateway.data.OS_USERNAME}" \
+#       --project="${kubernetes_secret.tempest_pushgateway.data.OS_PROJECT_NAME}" \
+#       "member"
+
+#     # Add admin user to default domain
+#     openstack role add \
+#           --user="$${OS_USERNAME}" \
+#           --domain="$${OS_DEFAULT_DOMAIN}" \
+#           "admin"
+#     %{for name, config in var.keystone_openid_connect_idps}
+#     # OpenID connect (${name})
+
+#     # Create Identity provider if it doesn't exist
+#     IDP_ID=$(openstack identity provider show ${name} -c id -f value || :)
+#     if [ -z "$IDP_ID" ]; then
+#         openstack identity provider create --remote-id ${config.issuer} ${name}
+#     else
+#         openstack identity provider set --remote-id ${config.issuer} ${name}
+#     fi
+
+#     # Generate mapping
+#     cat <<EOF | tee /tmp/mapping.json
+#     ${jsonencode(local.keystone_mappings[name])}
+#     EOF
+
+#     # Upload mapping to Keystone
+#     MAPPING_ID=$(openstack mapping show ${name} -c id -f value || :)
+#     if [ -z "$MAPPING_ID" ]; then
+#         openstack mapping create --rules /tmp/mapping.json ${name}
+#     else
+#         openstack mapping set --rules /tmp/mapping.json ${name}
+#     fi
+
+#     # Create federation
+#     FEDERATION_ID=$(openstack federation protocol show --identity-provider ${name} openid -c id -f value || :)
+#     if [ -z "$FEDERATION_ID" ]; then
+#         openstack federation protocol create --identity-provider ${name} --mapping ${name} openid
+#     fi
+#     %{endfor~}
+#     EOT
+#   }
+#   conf = {
+#     wsgi_keystone = <<-EOT
+#     {{- $portInt := tuple "identity" "internal" "api" $ | include "helm-toolkit.endpoints.endpoint_port_lookup" }}
+
+#     Listen 0.0.0.0:{{ $portInt }}
+
+#     LogFormat "%h %l %u %t \"%r\" %>s %b \"%%{Referer}i\" \"%%{User-Agent}i\"" combined
+#     LogFormat "%%{X-Forwarded-For}i %l %u %t \"%r\" %>s %b \"%%{Referer}i\" \"%%{User-Agent}i\"" proxy
+
+#     SetEnvIf X-Forwarded-For "^.*\..*\..*\..*" forwarded
+#     CustomLog /dev/stdout combined env=!forwarded
+#     CustomLog /dev/stdout proxy env=forwarded
+
+#     <VirtualHost *:{{ $portInt }}>
+#         WSGIDaemonProcess keystone-public processes=4 threads=1 user=keystone group=keystone display-name=%%{GROUP}
+#         WSGIProcessGroup keystone-public
+#         WSGIScriptAlias / /var/www/cgi-bin/keystone/keystone-wsgi-public
+#         WSGIApplicationGroup %%{GLOBAL}
+#         WSGIPassAuthorization On
+#         <IfVersion >= 2.4>
+#           ErrorLogFormat "%%{cu}t %M"
+#         </IfVersion>
+#         ErrorLog /dev/stdout
+
+#         SetEnvIf X-Forwarded-For "^.*\..*\..*\..*" forwarded
+#         CustomLog /dev/stdout combined env=!forwarded
+#         CustomLog /dev/stdout proxy env=forwarded
+
+#         # OpenID connect
+#         OIDCMetadataDir /var/lib/apache2/oidc
+#         OIDCClaimPrefix "OIDC-"
+#         OIDCSessionType client-cookie
+#         OIDCCryptoPassphrase ${random_password.keystone_openid_connect_crypto_passphrase.result}
+#         OIDCRedirectURLsAllowed ^https://${var.horizon_api_host}/auth/logout/$ ^https://${var.keystone_api_host}
+#         OIDCOAuthVerifyJwksUri https://vexxhost.us.auth0.com/.well-known/jwks.json
+
+#         OIDCRedirectURI https://${var.keystone_api_host}/v3/auth/OS-FEDERATION/identity_providers/redirect
+#         <Location /v3/auth/OS-FEDERATION/identity_providers/redirect>
+#             AuthType openid-connect
+#             Require valid-user
+#         </Location>
+#         <Location /v3/auth/OS-FEDERATION/websso/openid>
+#             AuthType openid-connect
+#             Require valid-user
+#         </Location>
+
+#     %{for name, config in var.keystone_openid_connect_idps}
+#         <Location /v3/auth/OS-FEDERATION/identity_providers/${name}/protocols/openid/websso>
+#             OIDCDiscoverURL https://${var.keystone_api_host}/v3/auth/OS-FEDERATION/identity_providers/redirect?iss=${urlencode(config.issuer)}
+#             AuthType openid-connect
+#             Require valid-user
+#         </Location>
+#         <Location /v3/OS-FEDERATION/identity_providers/${name}/protocols/openid/auth>
+#             LoadModule headers_module /usr/lib/apache2/modules/mod_headers.so
+#             Header set Access-Control-Allow-Headers "Authorization,Content-Type"
+#             Header set Access-Control-Allow-Origin "*"
+#             AuthType oauth20
+#             Require valid-user
+#         </Location>
+#     %{endfor}
+#     </VirtualHost>
+#     EOT
+#   }
+# }),
diff --git a/roles/openstack_helm_migrate_to_percona_xtradb_cluster/tasks/main.yml b/roles/openstack_helm_migrate_to_percona_xtradb_cluster/tasks/main.yml
new file mode 100644
index 0000000..5091f6f
--- /dev/null
+++ b/roles/openstack_helm_migrate_to_percona_xtradb_cluster/tasks/main.yml
@@ -0,0 +1,101 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Get the IP address for the legacy MariaDB service
+  kubernetes.core.k8s_info:
+    api_version: v1
+    kind: Service
+    name: mariadb
+    namespace: openstack
+  register: _openstack_helm_migrate_to_percona_xtradb_cluster_legacy_service
+  when: _openstack_helm_migrate_to_percona_xtradb_cluster_legacy_ip is undefined
+
+- name: Get the IP address for the new Percona XtraDB service
+  kubernetes.core.k8s_info:
+    api_version: v1
+    kind: Service
+    name: percona-xtradb-haproxy
+    namespace: openstack
+  register: _openstack_helm_migrate_to_percona_xtradb_cluster_service
+  when: _openstack_helm_migrate_to_percona_xtradb_cluster_ip is undefined
+
+- name: Get current values for Helm chart & fail if it already points to Percona XtraDB Cluster
+  kubernetes.core.helm_info:
+    name: "{{ openstack_helm_migrate_to_percona_xtradb_cluster_release_name }}"
+    release_namespace: "{{ openstack_helm_migrate_to_percona_xtradb_cluster_release_namespace }}"
+  register: _openstack_helm_migrate_to_percona_xtradb_cluster_helm_info
+  failed_when: _openstack_helm_migrate_to_percona_xtradb_cluster_helm_info.status['values']['endpoints']['oslo_db'].get('hosts', {}).get('default', '') == 'percona-xtradb-haproxy'
+
+- name: Set facts for database endpoints
+  ansible.builtin.set_fact:
+    _openstack_helm_migrate_to_percona_xtradb_cluster_legacy_ip: "{{ _openstack_helm_migrate_to_percona_xtradb_cluster_legacy_service.resources[0]['spec']['clusterIP'] }}"
+    _openstack_helm_migrate_to_percona_xtradb_cluster_legacy_password: "{{ _openstack_helm_migrate_to_percona_xtradb_cluster_helm_info.status['values']['endpoints']['oslo_db']['auth']['admin']['password'] }}"
+    _openstack_helm_migrate_to_percona_xtradb_cluster_ip: "{{ _openstack_helm_migrate_to_percona_xtradb_cluster_service.resources[0]['spec']['clusterIP'] }}"
+    _openstack_helm_migrate_to_percona_xtradb_cluster_password: "{{ openstack_helm_endpoints['oslo_db']['auth']['admin']['password'] }}"
+
+- name: Ensure PyMySQL packages are installed
+  ansible.builtin.pip:
+    name: PyMySQL
+
+- name: Check if database already exists & fail if it already exists
+  community.mysql.mysql_db:
+    login_host: "{{ _openstack_helm_migrate_to_percona_xtradb_cluster_ip }}"
+    login_user: root
+    login_password: "{{ _openstack_helm_migrate_to_percona_xtradb_cluster_password }}"
+    name: "{{ item }}"
+    state: present
+  check_mode: true
+  register: _openstack_helm_migrate_to_percona_xtradb_cluster_db_check
+  failed_when: _openstack_helm_migrate_to_percona_xtradb_cluster_db_check is not changed
+  loop: "{{ openstack_helm_migrate_to_percona_xtradb_cluster_databases }}"
+
+- name: Scale down replicas to 0 for database facing services
+  kubernetes.core.k8s_scale:
+    api_version: v1
+    kind: "{{ item.kind }}"
+    name: "{{ item.name }}"
+    namespace: "{{ openstack_helm_migrate_to_percona_xtradb_cluster_release_namespace }}"
+    replicas: 0
+  loop: "{{ openstack_helm_migrate_to_percona_xtradb_cluster_services }}"
+
+- name: Create temporary file for database dump
+  ansible.builtin.tempfile:
+    state: file
+    prefix: "{{ openstack_helm_migrate_to_percona_xtradb_cluster_release_name }}"
+    suffix: .sql
+  register: _openstack_helm_migrate_to_percona_xtradb_cluster_file
+
+- name: Dump all of the databases to the local system
+  community.mysql.mysql_db:
+    login_host: "{{ _openstack_helm_migrate_to_percona_xtradb_cluster_legacy_ip }}"
+    login_user: root
+    login_password: "{{ _openstack_helm_migrate_to_percona_xtradb_cluster_legacy_password }}"
+    name: "{{ openstack_helm_migrate_to_percona_xtradb_cluster_databases }}"
+    state: dump
+    target: "{{ _openstack_helm_migrate_to_percona_xtradb_cluster_file.path }}"
+    skip_lock_tables: true
+    dump_extra_args: --skip-add-locks
+  async: 7200
+  poll: 5
+
+- name: Import databases to the new Percona XtraDB Cluster
+  community.mysql.mysql_db:
+    login_host: "{{ _openstack_helm_migrate_to_percona_xtradb_cluster_ip }}"
+    login_user: root
+    login_password: "{{ _openstack_helm_migrate_to_percona_xtradb_cluster_password }}"
+    name: "{{ (openstack_helm_migrate_to_percona_xtradb_cluster_databases | length > 1) | ternary('all', openstack_helm_migrate_to_percona_xtradb_cluster_databases) }}"
+    state: import
+    target: "{{ _openstack_helm_migrate_to_percona_xtradb_cluster_file.path }}"
+  async: 7200
+  poll: 5
diff --git a/roles/openstack_helm_neutron/defaults/main.yml b/roles/openstack_helm_neutron/defaults/main.yml
new file mode 100644
index 0000000..e81f9f8
--- /dev/null
+++ b/roles/openstack_helm_neutron/defaults/main.yml
@@ -0,0 +1,25 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_helm_neutron_chart_repo_name: openstack-helm
+openstack_helm_neutron_chart_repo_url: https://tarballs.opendev.org/openstack/openstack-helm/
+openstack_helm_neutron_chart_name: neutron
+
+openstack_helm_neutron_image_repository: "{{ atmosphere_image_repository | default('us-docker.pkg.dev/vexxhost-infra/openstack') }}"
+openstack_helm_neutron_image_tag: 18.2.1.dev7-6
+openstack_helm_neutron_heat_image_tag: wallaby
+
+openstack_helm_neutron_values: {}
+
+openstack_helm_neutron_networks: []
diff --git a/roles/openstack_helm_neutron/meta/main.yml b/roles/openstack_helm_neutron/meta/main.yml
new file mode 100644
index 0000000..6c6c7e1
--- /dev/null
+++ b/roles/openstack_helm_neutron/meta/main.yml
@@ -0,0 +1,26 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: "{{ openstack_helm_neutron_chart_repo_name }}"
+      helm_repository_repo_url: "{{ openstack_helm_neutron_chart_repo_url }}"
+  - cilium
+  - openstack_namespace
+  - percona_xtradb_cluster
+  - openstack_helm_infra_memcached
+  - openstack_helm_infra_rabbitmq
+  - openstack_helm_infra_openvswitch
+  - openstack_helm_keystone
diff --git a/roles/openstack_helm_neutron/tasks/main.yml b/roles/openstack_helm_neutron/tasks/main.yml
new file mode 100644
index 0000000..bca4586
--- /dev/null
+++ b/roles/openstack_helm_neutron/tasks/main.yml
@@ -0,0 +1,87 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Generate OpenStack-Helm endpoints
+  ansible.builtin.include_role:
+    name: openstack_helm_endpoints
+  vars:
+    openstack_helm_endpoints_repo_name: "{{ openstack_helm_neutron_chart_repo_name }}"
+    openstack_helm_endpoints_repo_url: "{{ openstack_helm_neutron_chart_repo_url }}"
+    openstack_helm_endpoints_chart: "{{ openstack_helm_neutron_chart_name }}"
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: "{{ openstack_helm_neutron_chart_name }}"
+    chart_ref: "{{ openstack_helm_neutron_chart_repo_name }}/{{ openstack_helm_neutron_chart_name }}"
+    chart_version: 0.2.11
+    release_namespace: openstack
+    kubeconfig: /etc/kubernetes/admin.conf
+    values: "{{ _openstack_helm_neutron_values | combine(openstack_helm_neutron_values, recursive=True) }}"
+
+- name: Create Ingress
+  ansible.builtin.include_role:
+    name: openstack_helm_ingress
+  vars:
+    openstack_helm_ingress_endpoint: network
+    openstack_helm_ingress_service_name: neutron-server
+    openstack_helm_ingress_service_port: 9696
+
+- name: Create networks
+  openstack.cloud.network:
+    auth:
+      auth_url: "https://{{ openstack_helm_endpoints['identity']['host_fqdn_override']['public']['host'] }}"
+      username: "{{ openstack_helm_endpoints['identity']['auth']['admin']['username'] }}"
+      password: "{{ openstack_helm_endpoints['identity']['auth']['admin']['password'] }}"
+      project_name: admin
+      user_domain_name: Default
+      project_domain_name: Default
+    region_name: "{{ openstack_helm_endpoints['identity']['auth']['neutron']['region_name'] }}"
+    # Network settings
+    name: "{{ item.name }}"
+    external: "{{ item.external | default(omit) }}"
+    shared: "{{ item.shared | default(omit) }}"
+    mtu_size: "{{ item.mtu_size | default(omit) }}"
+    port_security_enabled: "{{ item.port_security_enabled | default(omit) }}"
+    provider_network_type: "{{ item.provider_network_type | default(omit) }}"
+    provider_physical_network: "{{ item.provider_physical_network | default(omit) }}"
+    provider_segmentation_id: "{{ item.provider_segmentation_id | default(omit) }}"
+  loop: "{{ openstack_helm_neutron_networks }}"
+
+- name: Create subnets
+  openstack.cloud.subnet:
+    auth:
+      auth_url: "https://{{ openstack_helm_endpoints['identity']['host_fqdn_override']['public']['host'] }}"
+      username: "{{ openstack_helm_endpoints['identity']['auth']['admin']['username'] }}"
+      password: "{{ openstack_helm_endpoints['identity']['auth']['admin']['password'] }}"
+      project_name: admin
+      user_domain_name: Default
+      project_domain_name: Default
+    region_name: "{{ openstack_helm_endpoints['identity']['auth']['neutron']['region_name'] }}"
+    # Subnet settings
+    network_name: "{{ item.0.name }}"
+    name: "{{ item.1.name }}"
+    ip_version: "{{ item.1.ip_version | default(omit) }}"
+    cidr: "{{ item.1.cidr | default(omit) }}"
+    gateway_ip: "{{ item.1.gateway_ip | default(omit) }}"
+    no_gateway_ip: "{{ item.1.no_gateway_ip | default(omit) }}"
+    allocation_pool_start: "{{ item.1.allocation_pool_start | default(omit) }}"
+    allocation_pool_end: "{{ item.1.allocation_pool_end | default(omit) }}"
+    dns_nameservers: "{{ item.1.dns_nameservers | default(omit) }}"
+    enable_dhcp: "{{ item.1.enable_dhcp | default(omit) }}"
+    host_routes: "{{ item.1.host_routes | default(omit) }}"
+    ipv6_address_mode: "{{ item.1.ipv6_address_mode | default(omit) }}"
+    ipv6_ra_mode: "{{ item.1.ipv6_ra_mode | default(omit) }}"
+  with_subelements:
+    - "{{ openstack_helm_neutron_networks }}"
+    - subnets
diff --git a/roles/openstack_helm_neutron/vars/main.yml b/roles/openstack_helm_neutron/vars/main.yml
new file mode 100644
index 0000000..d30456c
--- /dev/null
+++ b/roles/openstack_helm_neutron/vars/main.yml
@@ -0,0 +1,82 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+_openstack_helm_neutron_values:
+  endpoints: "{{ openstack_helm_endpoints }}"
+  images:
+    tags:
+      bootstrap: "{{ openstack_helm_neutron_image_repository }}/heat:{{ openstack_helm_neutron_heat_image_tag }}"
+      db_drop: "{{ openstack_helm_neutron_image_repository }}/heat:{{ openstack_helm_neutron_heat_image_tag }}"
+      db_init: "{{ openstack_helm_neutron_image_repository }}/heat:{{ openstack_helm_neutron_heat_image_tag }}"
+      dep_check: "{{ openstack_helm_neutron_image_repository }}/kubernetes-entrypoint:latest"
+      ks_endpoints: "{{ openstack_helm_neutron_image_repository }}/heat:{{ openstack_helm_neutron_heat_image_tag }}"
+      ks_service: "{{ openstack_helm_neutron_image_repository }}/heat:{{ openstack_helm_neutron_heat_image_tag }}"
+      ks_user: "{{ openstack_helm_neutron_image_repository }}/heat:{{ openstack_helm_neutron_heat_image_tag }}"
+      neutron_bagpipe_bgp: "{{ openstack_helm_neutron_image_repository }}/neutron:{{ openstack_helm_neutron_image_tag }}"
+      neutron_db_sync: "{{ openstack_helm_neutron_image_repository }}/neutron:{{ openstack_helm_neutron_image_tag }}"
+      neutron_dhcp: "{{ openstack_helm_neutron_image_repository }}/neutron:{{ openstack_helm_neutron_image_tag }}"
+      neutron_ironic_agent: "{{ openstack_helm_neutron_image_repository }}/neutron:{{ openstack_helm_neutron_image_tag }}"
+      neutron_l2gw: "{{ openstack_helm_neutron_image_repository }}/neutron:{{ openstack_helm_neutron_image_tag }}"
+      neutron_l3: "{{ openstack_helm_neutron_image_repository }}/neutron:{{ openstack_helm_neutron_image_tag }}"
+      neutron_linuxbridge_agent: "{{ openstack_helm_neutron_image_repository }}/neutron:{{ openstack_helm_neutron_image_tag }}"
+      neutron_metadata: "{{ openstack_helm_neutron_image_repository }}/neutron:{{ openstack_helm_neutron_image_tag }}"
+      neutron_netns_cleanup_cron: "{{ openstack_helm_neutron_image_repository }}/neutron:{{ openstack_helm_neutron_image_tag }}"
+      neutron_openvswitch_agent: "{{ openstack_helm_neutron_image_repository }}/neutron:{{ openstack_helm_neutron_image_tag }}"
+      neutron_server: "{{ openstack_helm_neutron_image_repository }}/neutron:{{ openstack_helm_neutron_image_tag }}"
+      neutron_sriov_agent_init: "{{ openstack_helm_neutron_image_repository }}/neutron:{{ openstack_helm_neutron_image_tag }}"
+      neutron_sriov_agent: "{{ openstack_helm_neutron_image_repository }}/neutron:{{ openstack_helm_neutron_image_tag }}"
+      rabbit_init: "{{ openstack_helm_neutron_image_repository }}/rabbitmq:3.8.23-management"
+  pod:
+    replicas:
+      server: 3
+  conf:
+    paste:
+      composite:neutronapi_v2_0:
+        keystone: cors http_proxy_to_wsgi request_id catch_errors authtoken keystonecontext extensions neutronapiapp_v2_0
+    neutron:
+      DEFAULT:
+        api_workers: 8
+        dhcp_agents_per_network: 3
+        log_config_append: null
+        rpc_workers: 8
+        service_plugins: router,vpnaas
+      cors:
+        allowed_origin: "*"
+      service_providers:
+        service_provider: VPN:strongswan:neutron_vpnaas.services.vpn.service_drivers.ipsec.IPsecVPNDriver:default
+    dhcp_agent:
+      DEFAULT:
+        dnsmasq_dns_servers: 1.1.1.1
+        enable_isolated_metadata: true
+    l3_agent:
+      AGENT:
+        extensions: vpnaas
+      vpnagent:
+        vpn_device_driver: neutron_vpnaas.services.vpn.device_drivers.strongswan_ipsec.StrongSwanDriver
+    metadata_agent:
+      DEFAULT:
+        nova_metadata_port: 8776
+        metadata_proxy_shared_secret: "{{ openstack_helm_endpoints['compute_metadata']['secret'] }}"
+    plugins:
+      ml2_conf:
+        ml2:
+          extension_drivers: port_security,dns
+          type_drivers: flat,gre,vlan,vxlan
+        ml2_type_gre:
+          tunnel_id_ranges: 1:1000
+        ml2_type_vlan:
+          network_vlan_ranges: external:1:4094
+  manifests:
+    ingress_server: false
+    service_ingress_server: false
diff --git a/roles/openstack_helm_nova/defaults/main.yml b/roles/openstack_helm_nova/defaults/main.yml
new file mode 100644
index 0000000..75ad162
--- /dev/null
+++ b/roles/openstack_helm_nova/defaults/main.yml
@@ -0,0 +1,29 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_helm_nova_chart_repo_name: openstack-helm
+openstack_helm_nova_chart_repo_url: https://tarballs.opendev.org/openstack/openstack-helm/
+openstack_helm_nova_chart_name: nova
+
+openstack_helm_nova_image_repository: "{{ atmosphere_image_repository | default('us-docker.pkg.dev/vexxhost-infra/openstack') }}"
+openstack_helm_nova_image_tag: 23.1.1.dev11
+openstack_helm_nova_ssh_image_tag: wallaby
+openstack_helm_nova_heat_image_tag: wallaby
+
+openstack_helm_nova_diff: false
+openstack_helm_nova_migrate_from_mariadb: false
+
+openstack_helm_nova_values: {}
+
+openstack_helm_nova_flavors: []
diff --git a/roles/openstack_helm_nova/meta/main.yml b/roles/openstack_helm_nova/meta/main.yml
new file mode 100644
index 0000000..6a725f3
--- /dev/null
+++ b/roles/openstack_helm_nova/meta/main.yml
@@ -0,0 +1,19 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: "{{ openstack_helm_nova_chart_repo_name }}"
+      helm_repository_repo_url: "{{ openstack_helm_nova_chart_repo_url }}"
diff --git a/roles/openstack_helm_nova/tasks/main.yml b/roles/openstack_helm_nova/tasks/main.yml
new file mode 100644
index 0000000..db1f2eb
--- /dev/null
+++ b/roles/openstack_helm_nova/tasks/main.yml
@@ -0,0 +1,104 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Generate OpenStack-Helm endpoints
+  ansible.builtin.include_role:
+    name: openstack_helm_endpoints
+  vars:
+    openstack_helm_endpoints_repo_name: "{{ openstack_helm_nova_chart_repo_name }}"
+    openstack_helm_endpoints_repo_url: "{{ openstack_helm_nova_chart_repo_url }}"
+    openstack_helm_endpoints_chart: "{{ openstack_helm_nova_chart_name }}"
+
+- name: Generate Helm values comparison
+  ansible.builtin.include_role:
+    name: helm_diff
+  vars:
+    helm_diff_release_name: "{{ openstack_helm_nova_chart_name }}"
+    helm_diff_release_namespace: openstack
+    helm_diff_values: "{{ _openstack_helm_nova_values }}"
+  when:
+    - openstack_helm_nova_diff | bool
+
+- name: Migrate database from MariaDB to Percona XtraDB Cluster
+  ansible.builtin.include_role:
+    name: openstack_helm_migrate_to_percona_xtradb_cluster
+  vars:
+    openstack_helm_migrate_to_percona_xtradb_cluster_release_name: "{{ openstack_helm_nova_chart_name }}"
+    openstack_helm_migrate_to_percona_xtradb_cluster_release_namespace: openstack
+    openstack_helm_migrate_to_percona_xtradb_cluster_databases:
+      - nova
+      - nova_api
+      - nova_cell0
+    openstack_helm_migrate_to_percona_xtradb_cluster_services:
+      - kind: Deployment
+        name: nova-api-metadata
+      - kind: Deployment
+        name: nova-api-osapi
+      - kind: Deployment
+        name: nova-conductor
+      - kind: Deployment
+        name: nova-scheduler
+  when:
+    - openstack_helm_nova_migrate_from_mariadb | bool
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: "{{ openstack_helm_nova_chart_name }}"
+    chart_ref: "{{ openstack_helm_nova_chart_repo_name }}/{{ openstack_helm_nova_chart_name }}"
+    chart_version: 0.2.30
+    release_namespace: openstack
+    kubeconfig: /etc/kubernetes/admin.conf
+    values: "{{ _openstack_helm_nova_values }}"
+    # NOTE(mnaser): This is a a workaround due to the fact that Nova's online
+    #               data migrations take forever.
+    wait_timeout: 10m
+
+- name: Create Ingress
+  ansible.builtin.include_role:
+    name: openstack_helm_ingress
+  vars:
+    openstack_helm_ingress_endpoint: compute
+    openstack_helm_ingress_service_name: nova-api
+    openstack_helm_ingress_service_port: 8774
+
+- name: Create Ingress
+  ansible.builtin.include_role:
+    name: openstack_helm_ingress
+  vars:
+    openstack_helm_ingress_endpoint: compute_novnc_proxy
+    openstack_helm_ingress_service_name: nova-novncproxy
+    openstack_helm_ingress_service_port: 6080
+
+- name: Create flavors
+  openstack.cloud.compute_flavor:
+    auth:
+      auth_url: "https://{{ openstack_helm_endpoints['identity']['host_fqdn_override']['public']['host'] }}"
+      username: "{{ openstack_helm_endpoints['identity']['auth']['admin']['username'] }}"
+      password: "{{ openstack_helm_endpoints['identity']['auth']['admin']['password'] }}"
+      project_name: admin
+      user_domain_name: Default
+      project_domain_name: Default
+    region_name: "{{ openstack_helm_endpoints['identity']['auth']['neutron']['region_name'] }}"
+    # Flavor settings
+    flavorid: "{{ item.flavorid | default(omit) }}"
+    name: "{{ item.name }}"
+    vcpus: "{{ item.vcpus }}"
+    ram: "{{ item.ram }}"
+    disk: "{{ item.disk | default(omit) }}"
+    ephemeral: "{{ item.ephemeral | default(omit) }}"
+    swap: "{{ item.swap | default(omit) }}"
+    is_public: "{{ item.is_public | default(omit) }}"
+    rxtx_factor: "{{ item.rxtx_factor | default(omit) }}"
+    extra_specs: "{{ item.extra_specs | default(omit) }}"
+  loop: "{{ openstack_helm_nova_flavors }}"
diff --git a/roles/openstack_helm_nova/vars/main.yml b/roles/openstack_helm_nova/vars/main.yml
new file mode 100644
index 0000000..bc22c7c
--- /dev/null
+++ b/roles/openstack_helm_nova/vars/main.yml
@@ -0,0 +1,117 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+_openstack_helm_nova_values: "{{ __openstack_helm_nova_values | combine(openstack_helm_nova_values, recursive=True) }}"
+__openstack_helm_nova_values:
+  endpoints: "{{ openstack_helm_endpoints }}"
+  labels:
+    agent:
+      compute_ironic:
+        node_selector_key: openstack-control-plane
+        node_selector_value: enabled
+  images:
+    tags:
+      bootstrap: "{{ openstack_helm_nova_image_repository }}/heat:{{ openstack_helm_nova_heat_image_tag }}"
+      db_drop: "{{ openstack_helm_nova_image_repository }}/heat:{{ openstack_helm_nova_heat_image_tag }}"
+      db_init: "{{ openstack_helm_nova_image_repository }}/heat:{{ openstack_helm_nova_heat_image_tag }}"
+      dep_check: "{{ openstack_helm_nova_image_repository }}/kubernetes-entrypoint:latest"
+      ks_endpoints: "{{ openstack_helm_nova_image_repository }}/heat:{{ openstack_helm_nova_heat_image_tag }}"
+      ks_service: "{{ openstack_helm_nova_image_repository }}/heat:{{ openstack_helm_nova_heat_image_tag }}"
+      ks_user: "{{ openstack_helm_nova_image_repository }}/heat:{{ openstack_helm_nova_heat_image_tag }}"
+      nova_api: "{{ openstack_helm_nova_image_repository }}/nova:{{ openstack_helm_nova_image_tag }}"
+      nova_archive_deleted_rows: "{{ openstack_helm_nova_image_repository }}/nova:{{ openstack_helm_nova_image_tag }}"
+      nova_cell_setup_init: "{{ openstack_helm_nova_image_repository }}/heat:{{ openstack_helm_nova_heat_image_tag }}"
+      nova_cell_setup: "{{ openstack_helm_nova_image_repository }}/nova:{{ openstack_helm_nova_image_tag }}"
+      # TODO(mnaser): Fix Ironic images
+      nova_compute_ironic: "docker.io/kolla/ubuntu-source-nova-compute-ironic:wallaby"
+      nova_compute_ssh: "{{ openstack_helm_nova_image_repository }}/nova-ssh:{{ openstack_helm_nova_ssh_image_tag }}"
+      nova_compute: "{{ openstack_helm_nova_image_repository }}/nova:{{ openstack_helm_nova_image_tag }}"
+      nova_conductor: "{{ openstack_helm_nova_image_repository }}/nova:{{ openstack_helm_nova_image_tag }}"
+      nova_consoleauth: "{{ openstack_helm_nova_image_repository }}/nova:{{ openstack_helm_nova_image_tag }}"
+      nova_db_sync: "{{ openstack_helm_nova_image_repository }}/nova:{{ openstack_helm_nova_image_tag }}"
+      nova_novncproxy_assets: "{{ openstack_helm_nova_image_repository }}/nova:{{ openstack_helm_nova_image_tag }}"
+      nova_novncproxy: "{{ openstack_helm_nova_image_repository }}/nova:{{ openstack_helm_nova_image_tag }}"
+      nova_placement: "{{ openstack_helm_nova_image_repository }}/nova:{{ openstack_helm_nova_image_tag }}"
+      nova_scheduler: "{{ openstack_helm_nova_image_repository }}/nova:{{ openstack_helm_nova_image_tag }}"
+      nova_service_cleaner: "{{ openstack_helm_nova_image_repository }}/cli:latest"
+      nova_spiceproxy_assets: "{{ openstack_helm_nova_image_repository }}/nova:{{ openstack_helm_nova_image_tag }}"
+      nova_spiceproxy: "{{ openstack_helm_nova_image_repository }}/nova:{{ openstack_helm_nova_image_tag }}"
+      rabbit_init: "{{ openstack_helm_nova_image_repository }}/rabbitmq:3.8.23-management"
+  bootstrap:
+    structured:
+      flavors:
+        enabled: false
+  pod:
+    replicas:
+      api_metadata: 3
+      osapi: 3
+      conductor: 3
+      scheduler: 3
+      novncproxy: 3
+      spiceproxy: 3
+  conf:
+    paste:
+      composite:openstack_compute_api_v21:
+        keystone: cors http_proxy_to_wsgi compute_req_id faultwrap sizelimit authtoken keystonecontext osapi_compute_app_v21
+      composite:openstack_compute_api_v21_legacy_v2_compatible:
+        keystone: cors http_proxy_to_wsgi compute_req_id faultwrap sizelimit authtoken keystonecontext legacy_v2_compatible osapi_compute_app_v21
+    nova:
+      DEFAULT:
+        cpu_allocation_ratio: 4.5
+        ram_allocation_ratio: 0.9
+        disk_allocation_ratio: 3.0
+        resume_guests_state_on_host_boot: true
+        osapi_compute_workers: 8
+        metadata_workers: 8
+      cache:
+        backend: oslo_cache.memcache_pool
+      cinder:
+        catalog_info: volumev3::internalURL
+      conductor:
+        workers: 8
+      cors:
+        allowed_origin: "*"
+        allow_headers: "X-Auth-Token,X-OpenStack-Nova-API-Version"
+      filter_scheduler:
+        enabled_filters: ComputeFilter, AggregateTypeAffinityFilter, ComputeCapabilitiesFilter, PciPassthroughFilter, ImagePropertiesFilter, ServerGroupAntiAffinityFilter, ServerGroupAffinityFilter
+        image_properties_default_architecture: x86_64
+        max_instances_per_host: 200
+      glance:
+        enable_rbd_download: true
+      neutron:
+        metadata_proxy_shared_secret: "{{ openstack_helm_endpoints['compute_metadata']['secret'] }}"
+      scheduler:
+        workers: 8
+    nova_ironic:
+      DEFAULT:
+        log_config_append: null
+        force_config_drive: true
+  manifests:
+    deployment_consoleauth: false
+    deployment_placement: false
+    ingress_metadata: false
+    ingress_novncproxy: false
+    ingress_osapi: false
+    ingress_placement: false
+    job_db_init_placement: false
+    job_ks_placement_endpoints: false
+    job_ks_placement_service: false
+    job_ks_placement_user: false
+    secret_keystone_placement: false
+    service_ingress_metadata: false
+    service_ingress_novncproxy: false
+    service_ingress_osapi: false
+    service_ingress_placement: false
+    service_placement: false
+    statefulset_compute_ironic: true
diff --git a/roles/openstack_helm_placement/defaults/main.yml b/roles/openstack_helm_placement/defaults/main.yml
new file mode 100644
index 0000000..113b39b
--- /dev/null
+++ b/roles/openstack_helm_placement/defaults/main.yml
@@ -0,0 +1,23 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+openstack_helm_placement_chart_repo_name: openstack-helm
+openstack_helm_placement_chart_repo_url: https://tarballs.opendev.org/openstack/openstack-helm/
+openstack_helm_placement_chart_name: placement
+
+openstack_helm_placement_image_repository: "{{ atmosphere_image_repository | default('us-docker.pkg.dev/vexxhost-infra/openstack') }}"
+openstack_helm_placement_image_tag: 5.0.1
+openstack_helm_placement_heat_image_tag: wallaby
+
+openstack_helm_placement_values: {}
diff --git a/roles/openstack_helm_placement/meta/main.yml b/roles/openstack_helm_placement/meta/main.yml
new file mode 100644
index 0000000..c0fb81a
--- /dev/null
+++ b/roles/openstack_helm_placement/meta/main.yml
@@ -0,0 +1,19 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: "{{ openstack_helm_placement_chart_repo_name }}"
+      helm_repository_repo_url: "{{ openstack_helm_placement_chart_repo_url }}"
diff --git a/roles/openstack_helm_placement/tasks/main.yml b/roles/openstack_helm_placement/tasks/main.yml
new file mode 100644
index 0000000..6c39459
--- /dev/null
+++ b/roles/openstack_helm_placement/tasks/main.yml
@@ -0,0 +1,38 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Generate OpenStack-Helm endpoints
+  ansible.builtin.include_role:
+    name: openstack_helm_endpoints
+  vars:
+    openstack_helm_endpoints_repo_name: "{{ openstack_helm_placement_chart_repo_name }}"
+    openstack_helm_endpoints_repo_url: "{{ openstack_helm_placement_chart_repo_url }}"
+    openstack_helm_endpoints_chart: "{{ openstack_helm_placement_chart_name }}"
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: "{{ openstack_helm_placement_chart_name }}"
+    chart_ref: "{{ openstack_helm_placement_chart_repo_name }}/{{ openstack_helm_placement_chart_name }}"
+    chart_version: 0.2.5
+    release_namespace: openstack
+    kubeconfig: /etc/kubernetes/admin.conf
+    values: "{{ _openstack_helm_placement_values | combine(openstack_helm_placement_values, recursive=True) }}"
+
+- name: Create Ingress
+  ansible.builtin.include_role:
+    name: openstack_helm_ingress
+  vars:
+    openstack_helm_ingress_endpoint: placement
+    openstack_helm_ingress_service_name: placement-api
+    openstack_helm_ingress_service_port: 8778
diff --git a/roles/openstack_helm_placement/vars/main.yml b/roles/openstack_helm_placement/vars/main.yml
new file mode 100644
index 0000000..cd47f82
--- /dev/null
+++ b/roles/openstack_helm_placement/vars/main.yml
@@ -0,0 +1,38 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+_openstack_helm_placement_values:
+  endpoints: "{{ openstack_helm_endpoints }}"
+  images:
+    tags:
+      bootstrap: "{{ openstack_helm_placement_image_repository }}/heat:{{ openstack_helm_placement_heat_image_tag }}"
+      db_drop: "{{ openstack_helm_placement_image_repository }}/heat:{{ openstack_helm_placement_heat_image_tag }}"
+      db_init: "{{ openstack_helm_placement_image_repository }}/heat:{{ openstack_helm_placement_heat_image_tag }}"
+      dep_check: "{{ openstack_helm_placement_image_repository }}/kubernetes-entrypoint:latest"
+      ks_endpoints: "{{ openstack_helm_placement_image_repository }}/heat:{{ openstack_helm_placement_heat_image_tag }}"
+      ks_service: "{{ openstack_helm_placement_image_repository }}/heat:{{ openstack_helm_placement_heat_image_tag }}"
+      ks_user: "{{ openstack_helm_placement_image_repository }}/heat:{{ openstack_helm_placement_heat_image_tag }}"
+      placement_db_sync: "{{ openstack_helm_placement_image_repository }}/placement:{{ openstack_helm_placement_image_tag }}"
+      placement: "{{ openstack_helm_placement_image_repository }}/placement:{{ openstack_helm_placement_image_tag }}"
+      rabbit_init: "{{ openstack_helm_placement_image_repository }}/rabbitmq:3.8.23-management"
+  pod:
+    replicas:
+      api: 3
+  conf:
+    placement:
+      DEFAULT:
+        log_config_append: null
+  manifests:
+    ingress: false
+    service_ingress: false
diff --git a/roles/openstack_namespace/tasks/main.yml b/roles/openstack_namespace/tasks/main.yml
new file mode 100644
index 0000000..a864a09
--- /dev/null
+++ b/roles/openstack_namespace/tasks/main.yml
@@ -0,0 +1,22 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Create namespace
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: v1
+      kind: Namespace
+      metadata:
+        name: openstack
diff --git a/roles/percona_xtradb_cluster/meta/main.yml b/roles/percona_xtradb_cluster/meta/main.yml
new file mode 100644
index 0000000..6b1f74e
--- /dev/null
+++ b/roles/percona_xtradb_cluster/meta/main.yml
@@ -0,0 +1,19 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: percona
+      helm_repository_repo_url: https://percona.github.io/percona-helm-charts/
diff --git a/roles/percona_xtradb_cluster/tasks/main.yml b/roles/percona_xtradb_cluster/tasks/main.yml
new file mode 100644
index 0000000..81c35cd
--- /dev/null
+++ b/roles/percona_xtradb_cluster/tasks/main.yml
@@ -0,0 +1,180 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: pxc-operator
+    chart_ref: percona/pxc-operator
+    chart_version: 1.10.0
+    release_namespace: openstack
+    create_namespace: true
+    kubeconfig: /etc/kubernetes/admin.conf
+
+- name: Deploy cluster
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: pxc.percona.com/v1-10-0
+      kind: PerconaXtraDBCluster
+      metadata:
+        name: percona-xtradb
+        namespace: openstack
+      spec:
+        crVersion: 1.10.0
+        secretsName: percona-xtradb
+        pxc:
+          size: 3
+          image: percona/percona-xtradb-cluster:5.7.36-31.55
+          autoRecovery: true
+          configuration: |
+            [mysqld]
+            max_connections=8192
+          sidecars:
+            - name: exporter
+              image: quay.io/prometheus/mysqld-exporter:v0.14.0
+              ports:
+                - name: metrics
+                  containerPort: 9104
+              livenessProbe:
+                httpGet:
+                  path: /
+                  port: 9104
+              env:
+                - name: MONITOR_PASSWORD
+                  valueFrom:
+                    secretKeyRef:
+                      name: percona-xtradb
+                      key: monitor
+                - name: DATA_SOURCE_NAME
+                  value: "monitor:$(MONITOR_PASSWORD)@(localhost:3306)/"
+          nodeSelector:
+            openstack-control-plane: enabled
+          volumeSpec:
+            persistentVolumeClaim:
+              resources:
+                requests:
+                  storage: 160Gi
+        haproxy:
+          enabled: true
+          size: 3
+          image: percona/percona-xtradb-cluster-operator:1.10.0-haproxy
+          nodeSelector:
+            openstack-control-plane: enabled
+    wait: true
+    wait_timeout: 600
+    wait_condition:
+      type: "ready"
+      status: "True"
+
+- name: Create PodMonitor
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: monitoring.coreos.com/v1
+      kind: PodMonitor
+      metadata:
+        name: percona-xtradb-pxc
+        namespace: monitoring
+        labels:
+          release: kube-prometheus-stack
+      spec:
+        jobLabel: app.kubernetes.io/component
+        podMetricsEndpoints:
+          - port: metrics
+            path: /metrics
+            relabelings:
+              - sourceLabels: ["__meta_kubernetes_pod_name"]
+                targetLabel: "instance"
+              - action: "labeldrop"
+                regex: "^(container|endpoint|namespace|pod|service)$"
+        namespaceSelector:
+          matchNames:
+            - openstack
+        selector:
+          matchLabels:
+            app.kubernetes.io/component: pxc
+            app.kubernetes.io/instance: percona-xtradb
+
+- name: Create PrometheusRule
+  kubernetes.core.k8s:
+    state: present
+    definition:
+      apiVersion: monitoring.coreos.com/v1
+      kind: PrometheusRule
+      metadata:
+        name: percona-xtradb-pxc
+        namespace: monitoring
+        labels:
+          release: kube-prometheus-stack
+      spec:
+        groups:
+          # TODO: basic rules
+          - name: general
+            rules:
+              - alert: MySQLDown
+                expr: mysql_up != 1
+                for: 5m
+                labels:
+                  severity: critical
+              - alert: MysqlTooManyConnections
+                expr: max_over_time(mysql_global_status_threads_connected[1m]) / mysql_global_variables_max_connections * 100 > 80
+                for: 2m
+                labels:
+                  severity: warning
+              - alert: MysqlHighThreadsRunning
+                expr: max_over_time(mysql_global_status_threads_running[1m]) / mysql_global_variables_max_connections * 100 > 60
+                for: 2m
+                labels:
+                  severity: warning
+              - alert: MysqlSlowQueries
+                expr: increase(mysql_global_status_slow_queries[1m]) > 0
+                for: 2m
+                labels:
+                  severity: warning
+          - name: galera
+            rules:
+              - alert: MySQLGaleraNotReady
+                expr: mysql_global_status_wsrep_ready != 1
+                for: 5m
+                labels:
+                  severity: critical
+              - alert: MySQLGaleraOutOfSync
+                expr: mysql_global_status_wsrep_local_state != 4 and mysql_global_variables_wsrep_desync == 0
+                for: 5m
+                labels:
+                  severity: critical
+              - alert: MySQLGaleraDonorFallingBehind
+                expr: mysql_global_status_wsrep_local_state == 2 and mysql_global_status_wsrep_local_recv_queue > 100
+                for: 5m
+                labels:
+                  severity: warning
+              - alert: MySQLReplicationNotRunning
+                expr: mysql_slave_status_slave_io_running == 0 or mysql_slave_status_slave_sql_running == 0
+                for: 2m
+                labels:
+                  severity: critical
+              - alert: MySQLReplicationLag
+                expr: (instance:mysql_slave_lag_seconds > 30) and on(instance) (predict_linear(instance:mysql_slave_lag_seconds[5m], 60 * 2) > 0)
+                for: 1m
+                labels:
+                  severity: critical
+              - alert: MySQLHeartbeatLag
+                expr: (instance:mysql_heartbeat_lag_seconds > 30) and on(instance) (predict_linear(instance:mysql_heartbeat_lag_seconds[5m], 60 * 2) > 0)
+                for: 1m
+                labels:
+                  severity: critical
+              - alert: MySQLInnoDBLogWaits
+                expr: rate(mysql_global_status_innodb_log_waits[15m]) > 10
+                labels:
+                  severity: warning
diff --git a/roles/prometheus_pushgateway/meta/main.yml b/roles/prometheus_pushgateway/meta/main.yml
new file mode 100644
index 0000000..8da9c00
--- /dev/null
+++ b/roles/prometheus_pushgateway/meta/main.yml
@@ -0,0 +1,21 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+dependencies:
+  - role: helm_repository
+    vars:
+      helm_repository_name: prometheus-community
+      helm_repository_repo_url: https://prometheus-community.github.io/helm-charts
+  - cilium
+  - kube_prometheus_stack
diff --git a/roles/prometheus_pushgateway/tasks/main.yml b/roles/prometheus_pushgateway/tasks/main.yml
new file mode 100644
index 0000000..5042914
--- /dev/null
+++ b/roles/prometheus_pushgateway/tasks/main.yml
@@ -0,0 +1,34 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+- name: Deploy Helm chart
+  kubernetes.core.helm:
+    name: prometheus-pushgateway
+    chart_ref: prometheus-community/prometheus-pushgateway
+    chart_version: 1.16.0
+    release_namespace: monitoring
+    kubeconfig: /etc/kubernetes/admin.conf
+    values:
+      nodeSelector:
+        openstack-control-plane: enabled
+      serviceMonitor:
+        enabled: true
+        namespace: monitoring
+        additionalLabels:
+          release: kube-prometheus-stack
+        relabelings:
+          - sourceLabels: ["__meta_kubernetes_pod_name"]
+            targetLabel: "instance"
+          - regex: "^(container|endpoint|namespace|pod|service)$"
+            action: "labeldrop"
diff --git a/tox.ini b/tox.ini
index feeae86..75f7b62 100644
--- a/tox.ini
+++ b/tox.ini
@@ -17,8 +17,16 @@
 skipsdist = True
 
 [testenv]
-usedevelop = True
+skipsdist = True
 passenv =
     OS_*
     TERM
-    STACK_NAME
\ No newline at end of file
+    STACK_NAME
+
+[testenv:molecule]
+deps =
+    ansible
+    molecule==3.5.2
+    -rmolecule/default/requirements.txt
+commands =
+    molecule {posargs}
diff --git a/zuul.d/jobs.yaml b/zuul.d/jobs.yaml
index c089928..435207e 100644
--- a/zuul.d/jobs.yaml
+++ b/zuul.d/jobs.yaml
@@ -17,6 +17,9 @@
     parent: vexxhost-tox-molecule
     pre-run:
       - zuul.d/playbooks/ansible-collection-atmosphere-tox-molecule/pre-run.yml
+    cleanup-run:
+      - zuul.d/playbooks/ansible-collection-atmosphere-tox-molecule/cleanup-run.yml
+    timeout: 3600
     vars:
       tox_environment:
         STACK_NAME: "atmosphere-{{ zuul.build }}"
diff --git a/zuul.d/playbooks/ansible-collection-atmosphere-tox-molecule/cleanup-run.yml b/zuul.d/playbooks/ansible-collection-atmosphere-tox-molecule/cleanup-run.yml
new file mode 100644
index 0000000..66f8e4b
--- /dev/null
+++ b/zuul.d/playbooks/ansible-collection-atmosphere-tox-molecule/cleanup-run.yml
@@ -0,0 +1,18 @@
+# Copyright (c) 2022 VEXXHOST, Inc.
+#
+# 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.
+
+- hosts: all
+  tasks:
+    - name: Destroy the Molecule environment
+      shell: tox -e molecule -- destroy --all