diff --git a/ansible/inventory/group_vars/all/kolla b/ansible/inventory/group_vars/all/kolla
index e34613f3fc86678296930857360b368c63854497..17575639ba7eaac0095db18a0d8eb6b966510e4f 100644
--- a/ansible/inventory/group_vars/all/kolla
+++ b/ansible/inventory/group_vars/all/kolla
@@ -571,6 +571,16 @@ kolla_enable_vitrage: "no"
 kolla_enable_watcher: "no"
 kolla_enable_zun: "no"
 
+###############################################################################
+# Kolla custom config generation.
+
+# Feature flag to add $KAYOBE_CONFIG_PATH to the list of search paths used
+# when searching for Kolla custom service configuration. Only has an effect in
+# a multiple environments setup. This allows you to configure merging between
+# your environment and the base layer. Defaults to true. Set to false for
+# backwards compatibility.
+kolla_openstack_custom_config_environment_merging_enabled: true
+
 ###############################################################################
 # Passwords and credentials.
 
diff --git a/ansible/kolla-openstack.yml b/ansible/kolla-openstack.yml
index cd331f15e36bf6297515b3c22dcb398ae1fdff0f..b6daaf0c1d4efce9e0b8bda9b1bfba8d69666642 100644
--- a/ansible/kolla-openstack.yml
+++ b/ansible/kolla-openstack.yml
@@ -100,53 +100,6 @@
     ipa_image_name: "ipa"
   pre_tasks:
     - block:
-        - name: Check whether Kolla extra configuration files exist
-          stat:
-            path: "{{ kayobe_env_config_path }}/kolla/config/{{ item.file }}"
-            get_checksum: False
-            get_md5: False
-            mime: False
-          register: stat_result
-          with_items:
-            - { name: aodh, file: aodh.conf }
-            - { name: barbican, file: barbican.conf }
-            - { name: blazar, file: blazar.conf }
-            - { name: ceilometer, file: ceilometer.conf }
-            - { name: cinder, file: cinder.conf }
-            - { name: cloudkitty, file: cloudkitty.conf }
-            - { name: designate, file: designate.conf }
-            - { name: glance, file: glance.conf }
-            - { name: global, file: global.conf }
-            - { name: gnocchi, file: gnocchi.conf }
-            - { name: grafana, file: grafana.ini }
-            - { name: heat, file: heat.conf }
-            - { name: inspector, file: ironic-inspector.conf }
-            - { name: ironic, file: ironic.conf }
-            - { name: keystone, file: keystone.conf }
-            - { name: magnum, file: magnum.conf }
-            - { name: manila, file: manila.conf }
-            - { name: mariabackup, file: backup.my.cnf }
-            - { name: mariadb, file: galera.cnf }
-            - { name: masakari, file: masakari.conf }
-            - { name: multipathd, file: multipath.conf }
-            - { name: murano, file: murano.conf }
-            - { name: neutron, file: neutron.conf }
-            - { name: neutron_ml2, file: neutron/ml2_conf.ini }
-            - { name: nova, file: nova.conf }
-            - { name: octavia, file: octavia.conf }
-            - { name: placement, file: placement.conf }
-            - { name: sahara, file: sahara.conf }
-
-        - name: Initialise a fact containing extra configuration
-          set_fact:
-            kolla_extra_config: {}
-
-        - name: Update a fact containing extra configuration
-          set_fact:
-            kolla_extra_config: "{{ kolla_extra_config | combine({item.item.name: lookup('template', '{{ item.stat.path }}')}) }}"
-          with_items: "{{ stat_result.results }}"
-          when: item.stat.exists
-
         - name: Validate switch configuration for Neutron ML2 genericswitch driver
           fail:
             msg: >
@@ -217,35 +170,11 @@
       kolla_inspector_swift_auth:
         auth_type: none
         endpoint_override: "http://{% raw %}{{ api_interface_address }}{% endraw %}:{{ inspector_store_port }}"
-      # Extra free-form user-provided configuration.
-      kolla_extra_aodh: "{{ kolla_extra_config.aodh | default }}"
-      kolla_extra_barbican: "{{ kolla_extra_config.barbican | default }}"
-      kolla_extra_blazar: "{{ kolla_extra_config.blazar | default }}"
-      kolla_extra_ceilometer: "{{ kolla_extra_config.ceilometer | default }}"
-      kolla_extra_cinder: "{{ kolla_extra_config.cinder | default }}"
-      kolla_extra_cloudkitty: "{{ kolla_extra_config.cloudkitty | default }}"
-      kolla_extra_designate: "{{ kolla_extra_config.designate | default }}"
-      kolla_extra_glance: "{{ kolla_extra_config.glance | default }}"
-      kolla_extra_global: "{{ kolla_extra_config.global | default }}"
-      kolla_extra_gnocchi: "{{ kolla_extra_config.gnocchi | default }}"
-      kolla_extra_grafana: "{{ kolla_extra_config.grafana | default }}"
-      kolla_extra_heat: "{{ kolla_extra_config.heat | default }}"
-      kolla_extra_inspector: "{{ kolla_extra_config.inspector | default }}"
-      kolla_extra_ironic: "{{ kolla_extra_config.ironic | default }}"
-      kolla_extra_keystone: "{{ kolla_extra_config.keystone | default }}"
-      kolla_extra_magnum: "{{ kolla_extra_config.magnum | default }}"
-      kolla_extra_manila: "{{ kolla_extra_config.manila | default }}"
-      kolla_extra_mariabackup: "{{ kolla_extra_config.mariabackup | default }}"
-      kolla_extra_mariadb: "{{ kolla_extra_config.mariadb | default }}"
-      kolla_extra_masakari: "{{ kolla_extra_config.masakari | default }}"
-      kolla_extra_multipathd: "{{ kolla_extra_config.multipathd | default }}"
-      kolla_extra_murano: "{{ kolla_extra_config.murano | default }}"
-      kolla_extra_neutron: "{{ kolla_extra_config.neutron | default }}"
-      kolla_extra_neutron_ml2: "{{ kolla_extra_config.neutron_ml2 | default }}"
-      kolla_extra_nova: "{{ kolla_extra_config.nova | default }}"
-      kolla_extra_octavia: "{{ kolla_extra_config.octavia | default }}"
-      kolla_extra_placement: "{{ kolla_extra_config.placement | default }}"
-      kolla_extra_sahara: "{{ kolla_extra_config.sahara | default }}"
-      kolla_extra_config_path: "{{ kayobe_env_config_path }}/kolla/config"
+      kolla_openstack_custom_config_paths_extra_multi_env:
+        - "{{ kayobe_config_path }}"
+        - "{{ kayobe_env_config_path }}"
+      kolla_openstack_custom_config_paths_extra_legacy:
+        - "{{ kayobe_env_config_path }}"
+      kolla_openstack_custom_config_paths_extra: "{{ kolla_openstack_custom_config_paths_extra_multi_env if kolla_openstack_custom_config_environment_merging_enabled | bool else kolla_openstack_custom_config_paths_extra_legacy }}"
       kolla_libvirt_tls: "{{ compute_libvirt_enable_tls | bool }}"
       kolla_nova_libvirt_certificates_src: "{{ kayobe_env_config_path }}/certificates/libvirt"
diff --git a/ansible/roles/image-download/tasks/main.yml b/ansible/roles/image-download/tasks/main.yml
index 3f905b8bf0f88363313118675b2764740364c628..4d3f6dc1eb3f09be673352d9ca804f5cd75ad080 100644
--- a/ansible/roles/image-download/tasks/main.yml
+++ b/ansible/roles/image-download/tasks/main.yml
@@ -1,4 +1,9 @@
 ---
+- name: Ensure destination directory exists
+  file:
+    state: directory
+    path: "{{ image_download_dest | dirname }}"
+
 - block:
     - block:
         - name: Fail if the checksum algorithm is not set
diff --git a/ansible/roles/kolla-openstack/action_plugins/kolla_custom_config_info.py b/ansible/roles/kolla-openstack/action_plugins/kolla_custom_config_info.py
new file mode 100644
index 0000000000000000000000000000000000000000..f5ad15429780dc63477c8ad9a308f80f0babbe35
--- /dev/null
+++ b/ansible/roles/kolla-openstack/action_plugins/kolla_custom_config_info.py
@@ -0,0 +1,229 @@
+# Copyright (c) 2023 StackHPC Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from ansible.plugins.action import ActionBase
+import os
+from collections import defaultdict
+import pathlib
+
+from wcmatch import glob
+
+def _dedup(xs):
+    # Deduplicate a list whilst maintaining order
+    seen = set()
+    result = []
+    for x in xs:
+        if x not in seen:
+            seen.add(x)
+            result.append(x)
+    return result
+
+class ConfigCollector(object):
+    def __init__(self, include_globs, ignore_globs, destination, search_paths,
+                 rules):
+        # This variable groups together files in the search paths with
+        # the same relative path, for example if the search paths were:
+        # - {{ kayobe_config_env_path }}/
+        # - {{ kayobe_config_path }}/
+        # - {{ role_path }}/templates/
+        # and one of the include_globs matched nova.conf. You'd end up
+        # with the following files grouped together:
+        # - {{ kayobe_env_path }}/etc/kolla/nova.conf
+        # - {{ kayobe_config_path }}/etc/kolla/nova.conf
+        # - {{ role_path }}/templates/etc/kolla/nova.conf
+        # The key in the dictionary is the relative path of the file. The
+        # value is a list of absolute paths. This gets populated by the
+        # collect() method.
+        self.files_in_source = defaultdict(list)
+        # Set of files in destination. This is used to cleanup up files
+        # from a previous run that are no longer templated. Before any templating
+        # this variable is populated with all the files in the output directory.
+        # Each file that would be templated in the current run will be removed
+        # from this set as they are discovered.
+        self.files_in_destination = set()
+        # Determines which files are candidates for templating
+        self.include_globs = include_globs
+        # Some files are templated by external tasks. This is a list of files
+        # to not clean up.
+        self.ignore_globs = ignore_globs
+        # Where the files are being templated to
+        self.destination = destination
+        # Where to search for the source files
+        self.search_paths = search_paths
+
+        # Rules to determine merging strategy when multiple files are found
+        # with the same relative path. Lower priority numbers win.
+        self.rules = sorted(rules, key=lambda d: d['priority'])
+
+    def filter_files_in_destination(self):
+        ignored = set()
+        for f in self.files_in_destination:
+            for item in self.ignore_globs:
+                if not item["enabled"]:
+                    continue
+                if glob.globmatch(f, item["glob"], flags=glob.GLOBSTAR):
+                    ignored.add(f)
+        result = set(self.files_in_destination) - ignored
+        return list(result)
+
+    def _find_matching_rule(self, relative_path):
+        # First match wins
+        for rule in self.rules:
+            if not rule.get('enabled', True):
+                continue
+            glob_ = rule["glob"]
+            if glob.globmatch(relative_path, glob_, flags=glob.GLOBSTAR):
+                return rule
+
+    def partition_into_actions(self):
+        actions = {
+            "merge_yaml": [],
+            "merge_configs": [],
+            "template": [],
+            "copy": [],
+            "concat": [],
+            "create_dir": [],
+            "delete": []
+        }
+        missing_directories = set()
+        files_to_delete = self.filter_files_in_destination()
+
+        # Convert to absolute paths
+        files_to_delete = {
+            os.path.join(self.destination, x) for x in files_to_delete
+        }
+
+        for relative_path, sources in self.files_in_source.items():
+            found_match = False
+            destination = os.path.join(self.destination, relative_path)
+            # Don't delete any files we are templating
+            files_to_delete.discard(destination)
+
+            dirname = os.path.dirname(destination)
+            if not os.path.exists(dirname):
+                missing_directories.add(dirname)
+
+            rule = self._find_matching_rule(relative_path)
+
+            if not rule:
+                continue
+
+            if rule["strategy"] == 'copy':
+                copy = {
+                    "src": sources[-1],
+                    "dest": destination,
+                    "params": rule.get('params', [])
+                }
+                actions["copy"].append(copy)
+                continue
+
+            if rule["strategy"] == "merge_yaml":
+                merge_yaml = {
+                    "sources": sources,
+                    "dest": destination,
+                    "params": rule.get('params', [])
+                }
+                actions["merge_yaml"].append(merge_yaml)
+                continue
+
+            if rule["strategy"] == "merge_configs":
+                merge_configs = {
+                    "sources": sources,
+                    "dest": destination,
+                    "params": rule.get('params', [])
+                }
+                actions["merge_configs"].append(merge_configs)
+                continue
+
+            if rule["strategy"] == "concat":
+                concat = {
+                    "sources": sources,
+                    "dest": destination,
+                    "params": rule.get('params', [])
+                }
+                actions["concat"].append(concat)
+                continue
+
+            if rule["strategy"] == "template":
+                template = {
+                    "src": sources[-1],
+                    "dest": destination,
+                    "params": rule.get('params', [])
+                }
+                actions["template"].append(template)
+                continue
+
+        actions["create_dir"] = list(missing_directories)
+        # Sort by length so that subdirectories are created after the parent
+        actions["create_dir"].sort(key=len)
+
+        actions["delete"] = list(files_to_delete)
+        return actions
+
+    def collect(self):
+        for item in self.include_globs:
+            self._collect_source(item)
+            self._collect_destination(item)
+
+    def _collect_source(self, item):
+        enabled = item.get("enabled", False)
+        if not isinstance(enabled, bool):
+            raise ValueError("Expecting a boolean: %s" % item)
+        if not enabled:
+            return
+        for search_path in self.search_paths:
+            abs_glob = os.path.join(search_path, item["glob"])
+            files = glob.glob(abs_glob, flags=glob.GLOBSTAR)
+            for abs_path in files:
+                if not os.path.isfile(abs_path):
+                    continue
+                relative_path = os.path.relpath(abs_path, search_path)
+                self.files_in_source[relative_path].append(abs_path)
+
+    def _collect_destination(self, item):
+        abs_glob = os.path.join(self.destination, item["glob"])
+        files = glob.glob(abs_glob, flags=glob.GLOBSTAR)
+        for abs_path in files:
+            if not os.path.isfile(abs_path):
+                continue
+            relative_path = os.path.relpath(abs_path, self.destination)
+            self.files_in_destination.add(relative_path)
+
+class ActionModule(ActionBase):
+
+    def run(self, tmp=None, task_vars=None):
+        if task_vars is None:
+            task_vars = dict()
+
+        result = super(ActionModule, self).run(tmp, task_vars)
+
+        # This class never changes anything. We only collect the extra config
+        # files and group by action.
+        result['changed'] = False
+
+        args = self._task.args
+
+        collector = ConfigCollector(
+            destination=args.get("destination"),
+            ignore_globs=args.get("ignore_globs"),
+            include_globs=args.get("include_globs"),
+            rules=args.get("rules"),
+            search_paths=_dedup(args["search_paths"])
+        )
+
+        collector.collect()
+
+        result.update(collector.partition_into_actions())
+
+        return result
diff --git a/ansible/roles/kolla-openstack/defaults/main.yml b/ansible/roles/kolla-openstack/defaults/main.yml
index ed3fb18a46364969ea396f69d5fb53b1b68291b3..5a7a96b09681cea41842cffb19ebac2393510da6 100644
--- a/ansible/roles/kolla-openstack/defaults/main.yml
+++ b/ansible/roles/kolla-openstack/defaults/main.yml
@@ -1,6 +1,19 @@
 ---
-# Path to extra kolla-ansible configuration files.
-kolla_extra_config_path:
+# Ordered list of paths to default kolla-ansible configuration files. Least
+# specific first. Default is search the role templates in
+# templates/kolla/config.
+kolla_openstack_custom_config_paths_default:
+  - "{{ role_path }}/templates"
+
+# Ordered list of paths to extra kolla-ansible configuration files. Least
+# specific first. Default is an empty list.
+kolla_openstack_custom_config_paths_extra: []
+
+# Ordered list of paths to kolla-ansible configuration files. Least specific
+# first. Default is a combination of
+# kolla_openstack_custom_config_paths_default and
+# kolla_openstack_custom_config_paths_extra.
+kolla_openstack_custom_config_paths: "{{ kolla_openstack_custom_config_paths_default + kolla_openstack_custom_config_paths_extra }}"
 
 # Directory where Kolla custom configuration files will be installed.
 kolla_node_custom_config_path: /etc/kolla/config
@@ -8,15 +21,307 @@ kolla_node_custom_config_path: /etc/kolla/config
 ###############################################################################
 # Global configuration.
 
+# Deprecated:
 # Free form extra configuration to append to global.conf.
 kolla_extra_global:
 
+###############################################################################
+# Kolla custom config generation.
+
+# Default value for kolla_openstack_custom_config_include_globs.
+kolla_openstack_custom_config_include_globs_default:
+  - enabled: '{{ kolla_enable_aodh | bool }}'
+    glob: aodh.conf
+  - enabled: '{{ kolla_enable_aodh | bool }}'
+    glob: aodh/**
+  - enabled: '{{ kolla_enable_barbican | bool }}'
+    glob: barbican.conf
+  - enabled: '{{ kolla_enable_barbican | bool }}'
+    glob: barbican/**
+  - enabled: '{{ kolla_enable_barbican | bool }}'
+    glob: barbican-api/**
+  - enabled: '{{ kolla_enable_blazar | bool }}'
+    glob: blazar.conf
+  - enabled: '{{ kolla_enable_blazar | bool }}'
+    glob: blazar/**
+  - enabled: '{{ kolla_enable_ceilometer | bool }}'
+    glob: ceilometer.conf
+  - enabled: '{{ kolla_enable_ceilometer | bool }}'
+    glob: ceilometer/**
+  - enabled: '{{ kolla_enable_cinder | bool }}'
+    glob: cinder.conf
+  - enabled: '{{ kolla_enable_cinder | bool }}'
+    glob: nfs_shares
+  - enabled: '{{ kolla_enable_cinder | bool }}'
+    glob: cinder/**
+  - enabled: '{{ kolla_enable_cloudkitty | bool }}'
+    glob: cloudkitty.conf
+  - enabled: '{{ kolla_enable_cloudkitty | bool }}'
+    glob: cloudkitty/**
+  - enabled: '{{ kolla_enable_designate | bool }}'
+    glob: designate.conf
+  - enabled: '{{ kolla_enable_designate | bool }}'
+    glob: designate/**
+  - enabled: '{{ kolla_enable_fluentd | bool }}'
+    glob: fluentd/**/*.conf
+  - enabled: '{{ kolla_enable_mariadb | bool }}'
+    glob: galera.cnf
+  - enabled: '{{ kolla_enable_glance | bool }}'
+    glob: glance*.conf
+  - enabled: '{{ kolla_enable_glance | bool }}'
+    glob: glance/**
+  - enabled: true
+    glob: global.conf
+  - enabled: '{{ kolla_enable_gnocchi | bool }}'
+    glob: gnocchi.conf
+  - enabled: '{{ kolla_enable_gnocchi | bool }}'
+    glob: gnocchi/**
+  - enabled: '{{ kolla_enable_grafana | bool }}'
+    glob: grafana.ini
+  - enabled: '{{ kolla_enable_grafana | bool }}'
+    glob: grafana/**
+  - enabled: '{{ kolla_enable_haproxy | bool }}'
+    glob: haproxy-config/**
+  - enabled: '{{ kolla_enable_haproxy | bool }}'
+    glob: haproxy/**
+  - enabled: '{{ kolla_enable_heat | bool }}'
+    glob: heat.conf
+  - enabled: '{{ kolla_enable_heat | bool }}'
+    glob: heat/**
+  - enabled: '{{ kolla_enable_horizon | bool }}'
+    glob: horizon/**
+  - enabled: '{{ kolla_enable_influxdb | bool }}'
+    glob: influx*
+  - enabled: '{{ kolla_enable_ironic | bool }}'
+    glob: ironic-inspector.conf
+  - enabled: '{{ kolla_enable_ironic | bool }}'
+    glob: ironic.conf
+  - enabled: '{{ kolla_enable_ironic | bool }}'
+    glob: ironic/**
+  - enabled: '{{ kolla_enable_keepalived | bool }}'
+    glob: keepalived/**
+  - enabled: '{{ kolla_enable_keystone | bool }}'
+    glob: keystone.conf
+  - enabled: '{{ kolla_enable_keystone | bool }}'
+    glob: keystone/**
+  - enabled: true
+    glob: kolla-toolbox/**
+  - enabled: '{{ kolla_enable_magnum | bool }}'
+    glob: magnum.conf
+  - enabled: '{{ kolla_enable_magnum | bool }}'
+    glob: magnum/**
+  - enabled: '{{ kolla_enable_manila | bool }}'
+    glob: manila.conf
+  - enabled: '{{ kolla_enable_manila | bool }}'
+    glob: manila/**
+  - enabled: '{{ kolla_enable_mariadb | bool }}'
+    glob: backup.my.cnf
+  - enabled: '{{ kolla_enable_mariadb | bool }}'
+    glob: mariadb/**
+  - enabled: '{{ kolla_enable_masakari | bool }}'
+    glob: masakari.conf
+  - enabled: '{{ kolla_enable_masakari | bool }}'
+    glob: masakari/**
+  - enabled: '{{ kolla_enable_multipathd | bool }}'
+    glob: multipath.conf
+  - enabled: '{{ kolla_enable_multipathd | bool }}'
+    glob: multipath/**
+  - enabled: '{{ kolla_enable_murano | bool }}'
+    glob: murano.conf
+  - enabled: '{{ kolla_enable_murano | bool }}'
+    glob: murano/**
+  - enabled: '{{ kolla_enable_neutron | bool }}'
+    glob: neutron.conf
+  - enabled: '{{ kolla_enable_neutron | bool }}'
+    glob: neutron/**
+  - enabled: '{{ kolla_enable_nova | bool }}'
+    glob: nova.conf
+  - enabled: '{{ kolla_enable_nova | bool }}'
+    glob: nova/**
+  - enabled: '{{ kolla_enable_nova | bool }}'
+    glob: nova_compute/**
+  - enabled: '{{ kolla_enable_octavia | bool  }}'
+    glob: octavia.conf
+  - enabled: '{{ kolla_enable_octavia | bool }}'
+    glob: octavia/**
+  - enabled: '{{ kolla_enable_opensearch | bool }}'
+    glob: opensearch.yml
+  - enabled: '{{ kolla_enable_opensearch | bool }}'
+    glob: opensearch/**
+  - enabled: '{{ kolla_enable_placement | bool }}'
+    glob: placement.conf
+  - enabled: '{{ kolla_enable_placement | bool }}'
+    glob: placement/**
+  - enabled: '{{ kolla_enable_prometheus | bool }}'
+    glob: prometheus/**
+  - enabled: '{{ kolla_enable_sahara | bool }}'
+    glob: sahara.conf
+  - enabled: '{{ kolla_enable_sahara | bool }}'
+    glob: sahara/**
+  - enabled: '{{ kolla_enable_swift | bool }}'
+    glob: swift/**
+
+# Extra items to add to kolla_openstack_custom_config_include_globs_default
+# to produce kolla_openstack_custom_config_include_globs.
+kolla_openstack_custom_config_include_globs_extra: []
+
+# List of dictionaries with the following keys:
+#   glob: a glob pattern. Any files matching this pattern will be copied to the
+#         the kolla custom config directory
+#   enabled: boolean to disable the glob.
+# This determines the list of files to copy to the generated kolla config
+# directory.
+kolla_openstack_custom_config_include_globs: "{{
+  kolla_openstack_custom_config_include_globs_default +
+  kolla_openstack_custom_config_include_globs_extra }}"
+
+# Kolla config generation rules. These operate on the list of files produced by
+# applying kolla_openstack_custom_config_include_globs. Each of the paths in
+# kolla_openstack_custom_config_paths is searched for files matching one of the
+# globs. If a match is found, any files with the same relative path are grouped
+# together. The rules determine what to do with these matching files e.g copy
+# the most specific file without templating, merge the files with
+# merge_configs, etc.
+# List of dictionaries with the following keys:
+#   glob: A glob matching files for this rule to match on (relative to the
+#     search path)
+#   priority: The rules are processed in increasing priority order with the
+#     first rule matching taking effect.
+#   strategy: How to process the matched file. One of copy, concat, template,
+#      merge_configs, merge_yaml
+#   params: List of params to pass to module enacting the strategy
+# Strategies:
+#   copy: Copy most specific file to kolla config without templating
+#   template: Template most specific file to kolla config
+#   concat: Concatenate files and copy the result to generated kolla config
+#   merge_configs: Use the merge_configs module to merge an ini file, before
+#     copying to the generated kolla-config.
+#   merge_yaml: Use the merge_yaml module to merge a file, before copying to
+#     the generated kolla-config.
+kolla_openstack_custom_config_rules: "{{ kolla_openstack_custom_config_rules_default | rejectattr('glob', 'in', kolla_openstack_custom_config_rules_default_remove) + kolla_openstack_custom_config_rules_extra }}"
+
+# Whether to enable ini merging rules in
+# kolla_openstack_custom_config_rules_default. Default is true.
+kolla_openstack_custom_config_merge_configs_enabled: true
+
+# Whether to enable yaml merging rules in
+# kolla_openstack_custom_config_rules_default. Default is true.
+kolla_openstack_custom_config_merge_yaml_enabled: true
+
+# Default merge strategy for ini files in
+# kolla_openstack_custom_config_rules_default. Default is concat.
+kolla_openstack_custom_config_ini_merge_strategy_default: concat
+
+# Default value for kolla_openstack_custom_config_rules.
+kolla_openstack_custom_config_rules_default:
+  - glob: horizon/themes/**
+    strategy: copy
+    priority: 1000
+  - glob: ironic/ironic-agent.initramfs
+    strategy: copy
+    priority: 1000
+  - glob: ironic/ironic-agent.kernel
+    strategy: copy
+    priority: 1000
+  - glob: swift/*.builder
+    strategy: copy
+    priority: 1000
+  - glob: swift/*.ring.gz
+    strategy: copy
+    priority: 1000
+  - glob: '**/*.pem'
+    strategy: copy
+    priority: 1000
+  # Exceptions for *.conf files which are not INI format
+  - glob: "**/collectd.conf"
+    strategy: template
+    priority: 1000
+  - glob: designate/**/named.conf
+    strategy: template
+    priority: 1000
+  - glob: designate/**/rndc.conf
+    strategy: template
+    priority: 1000
+  - glob: "**/dnsmasq.conf"
+    strategy: template
+    priority: 1000
+  - glob: fluentd/**/*.conf
+    strategy: template
+    priority: 1000
+  - glob: hacluster-corosync/**/corosync.conf
+    strategy: template
+    priority: 1000
+  - glob: horizon/**/horizon.conf
+    strategy: template
+    priority: 1000
+  - glob: "**/*httpd.conf"
+    strategy: template
+    priority: 1000
+  - glob: "**/influxdb.conf"
+    strategy: template
+    priority: 1000
+  - glob: "**/keepalived.conf"
+    strategy: template
+    priority: 1000
+  - glob: "**/multipath.conf"
+    strategy: template
+    priority: 1000
+  - glob: "**/rabbitmq*.conf"
+    strategy: template
+    priority: 1000
+  - glob: "**/*wsgi*.conf"
+    strategy: template
+    priority: 1000
+  # INI files
+  - glob: "**/*.conf"
+    strategy: "{{ kolla_openstack_custom_config_ini_merge_strategy_default }}"
+    priority: 2000
+    enabled: "{{ kolla_openstack_custom_config_merge_configs_enabled | bool }}"
+  - glob: "**/*.ini"
+    strategy: "{{ kolla_openstack_custom_config_ini_merge_strategy_default }}"
+    priority: 2000
+    enabled: "{{ kolla_openstack_custom_config_merge_configs_enabled | bool }}"
+  - glob: "**/galera.cnf"
+    strategy: "{{ kolla_openstack_custom_config_ini_merge_strategy_default }}"
+    priority: 2000
+    enabled: "{{ kolla_openstack_custom_config_merge_configs_enabled | bool }}"
+  - glob: "**/kafka.server.properties"
+    strategy: "{{ kolla_openstack_custom_config_ini_merge_strategy_default }}"
+    priority: 2000
+    enabled: "{{ kolla_openstack_custom_config_merge_configs_enabled | bool }}"
+  - glob: "**/*my.cnf"
+    strategy: "{{ kolla_openstack_custom_config_ini_merge_strategy_default }}"
+    priority: 2000
+    enabled: "{{ kolla_openstack_custom_config_merge_configs_enabled | bool }}"
+  # YAML files
+  - glob: "**/*.yml"
+    strategy: merge_yaml
+    priority: 2000
+    enabled: "{{ kolla_openstack_custom_config_merge_yaml_enabled | bool }}"
+  - glob: "**/*.yaml"
+    strategy: merge_yaml
+    priority: 2000
+    enabled: "{{ kolla_openstack_custom_config_merge_yaml_enabled | bool }}"
+  # Catch all. Fallback to templating to match legacy behaviour.
+  - glob: '**'
+    strategy: template
+    priority: 65535
+
+# List of globs to filter from kolla_openstack_custom_config_rules_default.
+# Default is an empty list.
+kolla_openstack_custom_config_rules_default_remove: []
+
+# Extra items to add to kolla_openstack_custom_config_rules_default
+# to produce kolla_openstack_custom_config_rules.
+kolla_openstack_custom_config_rules_extra: []
+
 ###############################################################################
 # Aodh configuration.
 
 # Whether to enable Aodh.
-kolla_enable_aodh:
+kolla_enable_aodh: false
 
+# Deprecated:
 # Free form extra configuration to append to aodh.conf.
 kolla_extra_aodh:
 
@@ -24,8 +329,9 @@ kolla_extra_aodh:
 # Barbican configuration.
 
 # Whether to enable Barbican.
-kolla_enable_barbican:
+kolla_enable_barbican: false
 
+# Deprecated:
 # Free form extra configuration to append to barbican.conf.
 kolla_extra_barbican:
 
@@ -33,8 +339,9 @@ kolla_extra_barbican:
 # Blazar configuration.
 
 # Whether to enable Blazar.
-kolla_enable_blazar:
+kolla_enable_blazar: false
 
+# Deprecated:
 # Free form extra configuration to append to blazar.conf.
 kolla_extra_blazar:
 
@@ -42,8 +349,9 @@ kolla_extra_blazar:
 # Ceilometer configuration.
 
 # Whether to enable Ceilometer.
-kolla_enable_ceilometer:
+kolla_enable_ceilometer: false
 
+# Deprecated:
 # Free form extra configuration to append to ceilometer.conf.
 kolla_extra_ceilometer:
 
@@ -51,8 +359,9 @@ kolla_extra_ceilometer:
 # cinder configuration.
 
 # Whether to enable cinder.
-kolla_enable_cinder:
+kolla_enable_cinder: false
 
+# Deprecated:
 # Free form extra configuration to append to cinder.conf.
 kolla_extra_cinder:
 
@@ -60,8 +369,9 @@ kolla_extra_cinder:
 # CloudKitty configuration.
 
 # Whether to enable CloudKitty.
-kolla_enable_cloudkitty:
+kolla_enable_cloudkitty: false
 
+# Deprecated:
 # Free form extra configuration to append to cloudkitty.conf.
 kolla_extra_cloudkitty:
 
@@ -69,17 +379,25 @@ kolla_extra_cloudkitty:
 # designate configuration.
 
 # Whether to enable designate.
-kolla_enable_designate:
+kolla_enable_designate: false
 
+# Deprecated:
 # Free form extra configuration to append to designate.conf.
 kolla_extra_designate:
 
+###############################################################################
+# Fluentd configuration.
+
+# Whether to enable Fluentd.
+kolla_enable_fluentd: false
+
 ###############################################################################
 # Glance configuration.
 
 # Whether to enable Glance.
-kolla_enable_glance:
+kolla_enable_glance: false
 
+# Deprecated:
 # Free form extra configuration to append to glance-api.conf and
 # glance-registry.conf.
 kolla_extra_glance:
@@ -88,8 +406,9 @@ kolla_extra_glance:
 # Gnocchi configuration.
 
 # Whether to enable Gnocchi.
-kolla_enable_gnocchi:
+kolla_enable_gnocchi: false
 
+# Deprecated:
 # Free form extra configuration to append to gnocchi.conf.
 kolla_extra_gnocchi:
 
@@ -97,11 +416,12 @@ kolla_extra_gnocchi:
 # Grafana configuration.
 
 # Whether to enable Grafana.
-kolla_enable_grafana:
+kolla_enable_grafana: false
 
 # Name of the admin user for Grafana.
 grafana_local_admin_user_name:
 
+# Deprecated:
 # Free form extra configuration to append to grafana.ini.
 kolla_extra_grafana:
 
@@ -109,23 +429,15 @@ kolla_extra_grafana:
 # HAProxy configuration.
 
 # Whether to enable HAProxy.
-kolla_enable_haproxy:
-
-###############################################################################
-# Keystone configuration.
-
-# Whether to enable Keystone.
-kolla_enable_keystone:
-
-# Free form extra configuration to append to Keystone.conf
-kolla_extra_keystone:
+kolla_enable_haproxy: false
 
 ##############################################################################
 # Heat configuration.
 
 # Whether to enable Heat.
-kolla_enable_heat:
+kolla_enable_heat: false
 
+# Deprecated:
 # Free form extra configuration to append to heat.conf.
 kolla_extra_heat:
 
@@ -133,19 +445,19 @@ kolla_extra_heat:
 # Horizon configuration.
 
 # Whether to enable Horizon.
-kolla_enable_horizon:
+kolla_enable_horizon: false
 
 ###############################################################################
 # InfluxDB configuration.
 
 # Whether to enable InfluxDB.
-kolla_enable_influxdb:
+kolla_enable_influxdb: false
 
 ###############################################################################
 # Ironic configuration.
 
 # Whether to enable Ironic.
-kolla_enable_ironic:
+kolla_enable_ironic: false
 
 # List of enabled Ironic drivers.
 kolla_ironic_drivers:
@@ -252,6 +564,7 @@ kolla_ironic_provisioning_network:
 # List of additional append parameters for baremetal PXE boot.
 kolla_ironic_pxe_append_params: []
 
+# Deprecated:
 # Free form extra configuration to append to ironic.conf.
 kolla_extra_ironic:
 
@@ -313,15 +626,33 @@ kolla_inspector_enable_swift:
 # store.
 kolla_inspector_swift_auth: {}
 
+# Deprecated:
 # Free form extra configuration to append to ironic-inspector.conf.
 kolla_extra_inspector:
 
+###############################################################################
+# Keepalived configuration.
+
+# Whether to enable Keepalived.
+kolla_enable_keepalived: false
+
+###############################################################################
+# Keystone configuration.
+
+# Whether to enable Keystone.
+kolla_enable_keystone: false
+
+# Deprecated:
+# Free form extra configuration to append to Keystone.conf
+kolla_extra_keystone:
+
 ###############################################################################
 # Magnum configuration.
 
 # Whether to enable Magnum.
-kolla_enable_magnum:
+kolla_enable_magnum: false
 
+# Deprecated:
 # Free form extra configuration to append to magnum.conf.
 kolla_extra_magnum:
 
@@ -329,8 +660,9 @@ kolla_extra_magnum:
 # Mariabackup configuration.
 
 # Whether to enable Mariabackup.
-kolla_enable_mariabackup:
+kolla_enable_mariabackup: false
 
+# Deprecated:
 # Free form extra configuration to append to backup.my.cnf.
 kolla_extra_mariabackup:
 
@@ -338,8 +670,9 @@ kolla_extra_mariabackup:
 # MariaDB configuration.
 
 # Whether to enable MariaDB.
-kolla_enable_mariadb:
+kolla_enable_mariadb: false
 
+# Deprecated:
 # Free form extra configuration to append to galera.cnf.
 kolla_extra_mariadb:
 
@@ -347,14 +680,19 @@ kolla_extra_mariadb:
 # Manila configuration.
 
 # Whether to enable Manila.
-kolla_enable_manila:
+kolla_enable_manila: false
+
+# Deprecated:
+# Free form extra configuration to append to manila.conf.
+kolla_extra_manila:
 
 ###############################################################################
 # Masakari configuration.
 
 # Whether to enable Masakari.
-kolla_enable_masakari:
+kolla_enable_masakari: false
 
+# Deprecated:
 # Free form extra configuration to append to masakari.conf.
 kolla_extra_masakari:
 
@@ -362,7 +700,7 @@ kolla_extra_masakari:
 # Multipathd configuration.
 
 # Whether to enable Multipathd.
-kolla_enable_multipathd:
+kolla_enable_multipathd: false
 
 # Free form extra configuration to append to multipath.conf.
 kolla_extra_multipathd:
@@ -371,8 +709,9 @@ kolla_extra_multipathd:
 # Murano configuration.
 
 # Whether to enable Murano.
-kolla_enable_murano:
+kolla_enable_murano: false
 
+# Deprecated:
 # Free form extra configuration to append to murano.conf.
 kolla_extra_murano:
 
@@ -380,7 +719,7 @@ kolla_extra_murano:
 # Neutron configuration.
 
 # Whether to enable Neutron.
-kolla_enable_neutron:
+kolla_enable_neutron: false
 
 # List of Neutron ML2 mechanism drivers to use.
 kolla_neutron_ml2_mechanism_drivers: []
@@ -416,9 +755,11 @@ kolla_neutron_ml2_generic_switches: []
 # secret: not currently supported
 kolla_neutron_ml2_generic_switch_hosts: []
 
+# Deprecated:
 # Free form extra configuration to append to neutron.conf.
 kolla_extra_neutron:
 
+# Deprecated:
 # Free form extra configuration to append to ml2_conf.ini.
 kolla_extra_neutron_ml2:
 
@@ -426,11 +767,12 @@ kolla_extra_neutron_ml2:
 # Nova configuration.
 
 # Whether to enable Nova.
-kolla_enable_nova:
+kolla_enable_nova: false
 
 # Whether to enable Nova libvirt container.
 kolla_enable_nova_libvirt_container:
 
+# Deprecated:
 # Free form extra configuration to append to nova.conf.
 kolla_extra_nova:
 
@@ -442,23 +784,28 @@ kolla_libvirt_tls:
 kolla_nova_libvirt_certificates_src:
 
 ###############################################################################
-# Octavia configuration.
+# OpenSearch configuration.
 
-# Whether to enable Octavia.
-kolla_enable_octavia:
+# Whether to enable OpenSearch.
+kolla_enable_opensearch: false
 
 ###############################################################################
-# OpenSearch configuration.
+# Octavia configuration.
+
+# Whether to enable Octavia.
+kolla_enable_octavia: false
 
-# Whether to enable opensearch.
-kolla_enable_opensearch:
+# Deprecated:
+# Free form extra configuration to append to octavia.conf
+kolla_extra_octavia:
 
 ###############################################################################
 # Placement configuration.
 
 # Whether to enable placement.
-kolla_enable_placement:
+kolla_enable_placement: false
 
+# Deprecated:
 # Free form extra configuration to append to placement.conf.
 kolla_extra_placement:
 
@@ -466,14 +813,15 @@ kolla_extra_placement:
 # Prometheus configuration.
 
 # Whether to enable Prometheus.
-kolla_enable_prometheus:
+kolla_enable_prometheus: false
 
 ###############################################################################
 # Sahara configuration.
 
 # Whether to enable sahara.
-kolla_enable_sahara:
+kolla_enable_sahara: false
 
+# Deprecated:
 # Free form extra configuration to append to sahara.conf.
 kolla_extra_sahara:
 
@@ -481,4 +829,4 @@ kolla_extra_sahara:
 # Swift configuration.
 
 # Whether to enable swift.
-kolla_enable_swift:
+kolla_enable_swift: false
diff --git a/ansible/roles/kolla-openstack/molecule/default/tests/test_default.py b/ansible/roles/kolla-openstack/molecule/default/tests/test_default.py
index ebd6309f25523245d04c842232e5391b26f2b4a0..e6c0071f809e0924c886d34e1200a03071012727 100644
--- a/ansible/roles/kolla-openstack/molecule/default/tests/test_default.py
+++ b/ansible/roles/kolla-openstack/molecule/default/tests/test_default.py
@@ -25,22 +25,15 @@ testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner(
     os.environ['MOLECULE_INVENTORY_FILE']).get_hosts('all')
 
 
-@pytest.mark.parametrize(
-    'path',
-    ['fluentd/filter',
-     'fluentd/input',
-     'fluentd/output'])
-def test_service_config_directory(host, path):
-    path = os.path.join('/etc/kolla/config', path)
-    utils.test_directory(host, path)
-
-
 @pytest.mark.parametrize(
     'path',
     ['aodh',
      'cinder',
      'cloudkitty',
      'designate',
+     'fluentd/filter',
+     'fluentd/input',
+     'fluentd/output',
      'glance',
      'grafana',
      'heat',
diff --git a/ansible/roles/kolla-openstack/molecule/enable-everything/destroy.yml b/ansible/roles/kolla-openstack/molecule/enable-everything/destroy.yml
index 59c2929ce12ebf843b2bd3aba79a904c2fac15b6..681850a0e34a3b2cea03cd52b89e316697bfd8d5 100644
--- a/ansible/roles/kolla-openstack/molecule/enable-everything/destroy.yml
+++ b/ansible/roles/kolla-openstack/molecule/enable-everything/destroy.yml
@@ -25,3 +25,8 @@
       until: docker_jobs.finished
       retries: 300
       with_items: "{{ server.results }}"
+
+    - name: Clean up temporary path
+      file:
+        path: "{{ lookup('env', 'MOLECULE_TEMP_PATH') | default('/tmp/molecule', true) }}"
+        state: absent
diff --git a/ansible/roles/kolla-openstack/molecule/enable-everything/molecule.yml b/ansible/roles/kolla-openstack/molecule/enable-everything/molecule.yml
index 264ef1cb179cd45409b25c5693ee2a5c8e49d777..45574e2e9edae7f182c5020c494336c3b16ae4be 100644
--- a/ansible/roles/kolla-openstack/molecule/enable-everything/molecule.yml
+++ b/ansible/roles/kolla-openstack/molecule/enable-everything/molecule.yml
@@ -15,7 +15,16 @@ provisioner:
   inventory:
     group_vars:
       all:
-        kolla_extra_config_path: ${MOLECULE_TEMP_PATH:-/tmp}/molecule/kolla/config
+        kolla_extra_config_path: ${MOLECULE_TEMP_PATH:-/tmp/molecule}/kolla/config
+        kolla_openstack_custom_config_paths_extra:
+          - "{{ kolla_extra_config_path }}/../.."
+        kolla_openstack_custom_config_rules_extra:
+          - glob: aodh/dummy.yml
+            strategy: merge_yaml
+            priority: 1000
+          - glob: aodh/dummy.ini
+            strategy: merge_configs
+            priority: 1000
         kolla_enable_aodh: true
         kolla_extra_aodh: |
           [extra-aodh.conf]
@@ -44,6 +53,7 @@ provisioner:
         kolla_extra_designate: |
           [extra-designate.conf]
           foo=bar
+        kolla_enable_fluentd: true
         kolla_enable_glance: true
         kolla_extra_glance: |
           [extra-glance.conf]
@@ -72,8 +82,9 @@ provisioner:
         kolla_extra_inspector: |
           [extra-ironic-inspector.conf]
           foo=bar
-        kolla_inspector_ipa_kernel_path: ${MOLECULE_TEMP_PATH:-/tmp}/ironic-agent.kernel
-        kolla_inspector_ipa_ramdisk_path: ${MOLECULE_TEMP_PATH:-/tmp}/ironic-agent.initramfs
+        kolla_inspector_ipa_kernel_path: ${MOLECULE_TEMP_PATH:-/tmp/molecule}/ironic-agent.kernel
+        kolla_inspector_ipa_ramdisk_path: ${MOLECULE_TEMP_PATH:-/tmp/molecule}/ironic-agent.initramfs
+        kolla_enable_keepalived: true
         kolla_enable_keystone: true
         kolla_extra_keystone: |
           [extra-keystone.conf]
@@ -119,7 +130,7 @@ provisioner:
           [extra-nova.conf]
           foo=bar
         kolla_libvirt_tls: true
-        kolla_nova_libvirt_certificates_src: ${MOLECULE_TEMP_PATH:-/tmp}/molecule/nova-libvirt/certificates
+        kolla_nova_libvirt_certificates_src: ${MOLECULE_TEMP_PATH:-/tmp/molecule}/nova-libvirt/certificates
         kolla_enable_octavia: true
         kolla_extra_octavia: |
           [extra-octavia.conf]
diff --git a/ansible/roles/kolla-openstack/molecule/enable-everything/prepare.yml b/ansible/roles/kolla-openstack/molecule/enable-everything/prepare.yml
index 8514e90f3a0467eb90c1e7ca180247e5dfc6e7a4..66be3443455110811a8b37e4e65d54e5ecce8939 100644
--- a/ansible/roles/kolla-openstack/molecule/enable-everything/prepare.yml
+++ b/ansible/roles/kolla-openstack/molecule/enable-everything/prepare.yml
@@ -26,6 +26,99 @@
         - "{{ kolla_inspector_ipa_kernel_path }}"
         - "{{ kolla_inspector_ipa_ramdisk_path }}"
 
+    - name: Ensure parent directories of configuration files exist
+      file:
+        path: "{{ kolla_extra_config_path }}/{{ item }}"
+        state: directory
+        recurse: yes
+      delegate_to: localhost
+      run_once: true
+      with_items:
+        - neutron
+        - aodh
+        # To check that subdirectories are handled correctly
+        - prometheus/prometheus.yml.d
+        # Example of non-ini files that should be templated but not merged
+        - fluentd/input/
+
+    - name: Ensure extra INI configuration files exist
+      copy:
+        content: |
+          [extra-file-{{ item | basename }}]
+          bar=baz
+        dest: "{{ kolla_extra_config_path }}/{{ item }}"
+      run_once: true
+      delegate_to: localhost
+      loop:
+        - aodh.conf
+        - barbican.conf
+        - blazar.conf
+        - ceilometer.conf
+        - cinder.conf
+        - cloudkitty.conf
+        - designate.conf
+        - glance.conf
+        - global.conf
+        - gnocchi.conf
+        - grafana.ini
+        - heat.conf
+        - ironic.conf
+        - ironic-inspector.conf
+        - keystone.conf
+        - magnum.conf
+        - manila.conf
+        - murano.conf
+        - backup.my.cnf
+        - galera.cnf
+        - masakari.conf
+        - neutron.conf
+        - neutron/ml2_conf.ini
+        - nova.conf
+        - octavia.conf
+        - sahara.conf
+        - placement.conf
+
+    - name: Ensure extra YAML configuration files exist
+      copy:
+        content: |
+          dummy_variable: 123
+        dest: "{{ kolla_extra_config_path }}/{{ item }}"
+      run_once: true
+      delegate_to: localhost
+      loop:
+        - aodh/dummy.yml
+        - opensearch.yml
+        - prometheus/prometheus.yml.d/dummy.yml
+
+    - name: Template extra custom config files
+      # These correspond to globs defined in molecule.yml
+      copy:
+        content: "{{ item.content }}"
+        dest: "{{ kolla_extra_config_path }}/{{ item.dest }}"
+      run_once: true
+      delegate_to: localhost
+      with_items:
+        - dest: aodh/dummy.ini
+          content: |
+            [dummy-section]
+            dummy_variable = 123
+        - dest: fluentd/input/01-test.conf
+          content: |
+            <source>
+              @type tail
+              path /grepme
+              pos_file /var/run/td-agent/rabbit.pos
+              tag infra.rabbit
+              enable_watch_timer false
+              <parse>
+                @type multiline
+                format_firstline /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}/
+                format1 /^(?<Timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}) \[(?<log_level>\w+)\] (?<Payload>.*)/
+              </parse>
+            </source>
+      loop_control:
+        label: "{{ item.dest }}"
+
     - name: Ensure nova libvirt certificates directory exists
       local_action:
         module: file
diff --git a/ansible/roles/kolla-openstack/molecule/enable-everything/tests/test_default.py b/ansible/roles/kolla-openstack/molecule/enable-everything/tests/test_default.py
index aabd19d103143529c4766ca82345e64b1135ba92..664100501deb216519a2187048b6c54291df3baa 100644
--- a/ansible/roles/kolla-openstack/molecule/enable-everything/tests/test_default.py
+++ b/ansible/roles/kolla-openstack/molecule/enable-everything/tests/test_default.py
@@ -19,46 +19,12 @@ from kayobe.tests.molecule import utils
 
 import pytest
 import testinfra.utils.ansible_runner
+import yaml
 
 
 testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner(
     os.environ['MOLECULE_INVENTORY_FILE']).get_hosts('all')
 
-
-@pytest.mark.parametrize(
-    'path',
-    ['aodh',
-     'barbican',
-     'cinder',
-     'cloudkitty',
-     'designate',
-     'fluentd/filter',
-     'fluentd/input',
-     'fluentd/output',
-     'glance',
-     'grafana',
-     'heat',
-     'horizon',
-     'ironic',
-     'keystone',
-     'magnum',
-     'manila',
-     'mariadb',
-     'masakari',
-     'murano',
-     'neutron',
-     'nova',
-     'nova/nova-libvirt',
-     'octavia',
-     'placement',
-     'prometheus',
-     'sahara',
-     'swift'])
-def test_service_config_directory(host, path):
-    path = os.path.join('/etc/kolla/config', path)
-    utils.test_directory(host, path)
-
-
 @pytest.mark.parametrize(
     'path',
     ['aodh.conf',
@@ -87,11 +53,44 @@ def test_service_config_directory(host, path):
      'backup.my.cnf'])
 def test_service_ini_file(host, path):
     # TODO(mgoddard): Check more of config file contents.
+    # Tests config added with extra vars e.g kolla_extra_aodh. I.e the
+    # the internal role templates.
     path = os.path.join('/etc/kolla/config', path)
     extra_section = 'extra-%s' % os.path.basename(path)
     expected = {extra_section: {'foo': 'bar'}}
     utils.test_ini_file(host, path, expected=expected)
 
+@pytest.mark.parametrize(
+    'path',
+    ['aodh.conf',
+     'barbican.conf',
+     'cinder.conf',
+     'cloudkitty.conf',
+     'designate.conf',
+     'galera.cnf',
+     'glance.conf',
+     'grafana.ini',
+     'heat.conf',
+     'ironic.conf',
+     'ironic-inspector.conf',
+     'keystone.conf',
+     'magnum.conf',
+     'manila.conf',
+     'masakari.conf',
+     'murano.conf',
+     'neutron/ml2_conf.ini',
+     'neutron.conf',
+     'nova.conf',
+     'octavia.conf',
+     'placement.conf',
+     'sahara.conf',
+     'backup.my.cnf'])
+def test_service_ini_file_extra_confs(host, path):
+    # Tests config added via extra config files
+    path = os.path.join('/etc/kolla/config', path)
+    extra_section = 'extra-file-%s' % os.path.basename(path)
+    expected = {extra_section: {'bar': 'baz'}}
+    utils.test_ini_file(host, path, expected=expected)
 
 @pytest.mark.parametrize(
     'path',
@@ -104,3 +103,30 @@ def test_service_non_ini_file(host, path):
     # TODO(mgoddard): Check config file contents.
     path = os.path.join('/etc/kolla/config', path)
     utils.test_file(host, path)
+
+@pytest.mark.parametrize(
+    'path,regex',
+    [('fluentd/input/01-test.conf', 'grepme')])
+def test_service_non_ini_file_regex(host, path, regex):
+    path = os.path.join('/etc/kolla/config', path)
+    utils.test_regex_in_file(host, path, regex=regex)
+
+@pytest.mark.parametrize(
+    'relative_path',
+    ['aodh/dummy.yml',
+     'opensearch.yml',
+     'prometheus/prometheus.yml.d/dummy.yml'])
+def test_service_extra_yml_config(host, relative_path):
+    path = os.path.join('/etc/kolla/config', relative_path)
+    utils.test_file(host, path)
+    content = yaml.safe_load(host.file(path).content_string)
+    assert content["dummy_variable"] == 123
+
+def test_service_extra_ini_config(host):
+    relative_path = "aodh/dummy.ini"
+    path = os.path.join('/etc/kolla/config', relative_path)
+    utils.test_file(host, path)
+    expected = {
+        "dummy-section": {"dummy_variable": "123"}
+    }
+    utils.test_ini_file(host, path, expected=expected)
diff --git a/ansible/roles/kolla-openstack/tasks/config.yml b/ansible/roles/kolla-openstack/tasks/config.yml
index 42f1775ea0cbed1aa24e6310e14eebb0a0efdc68..ea999ad7b2f475d97450a9e95790ba7b570f3940 100644
--- a/ansible/roles/kolla-openstack/tasks/config.yml
+++ b/ansible/roles/kolla-openstack/tasks/config.yml
@@ -1,48 +1,4 @@
 ---
-- name: Ensure the Kolla OpenStack configuration directories exist
-  file:
-    path: "{{ item.dest }}"
-    state: directory
-    mode: 0750
-  with_items: "{{ kolla_openstack_custom_config }}"
-  when: item.enabled | bool
-
-- name: Ensure the Kolla OpenStack configuration files exist
-  template:
-    src: "{{ item.src }}"
-    dest: "{{ kolla_node_custom_config_path }}/{{ item.dest }}"
-    mode: 0640
-  with_items:
-    - { src: aodh.conf.j2, dest: aodh.conf, enabled: "{{ kolla_enable_aodh }}" }
-    - { src: barbican.conf.j2, dest: barbican.conf, enabled: "{{ kolla_enable_barbican }}" }
-    - { src: blazar.conf.j2, dest: blazar.conf, enabled: "{{ kolla_enable_blazar }}" }
-    - { src: ceilometer.conf.j2, dest: ceilometer.conf, enabled: "{{ kolla_enable_ceilometer }}" }
-    - { src: cinder.conf.j2, dest: cinder.conf, enabled: "{{ kolla_enable_cinder }}" }
-    - { src: cloudkitty.conf.j2, dest: cloudkitty.conf, enabled: "{{ kolla_enable_cloudkitty }}" }
-    - { src: designate.conf.j2, dest: designate.conf, enabled: "{{ kolla_enable_designate }}" }
-    - { src: galera.cnf.j2, dest: galera.cnf, enabled: "{{ kolla_enable_mariadb }}" }
-    - { src: glance.conf.j2, dest: glance.conf, enabled: "{{ kolla_enable_glance }}" }
-    - { src: global.conf.j2, dest: global.conf, enabled: true }
-    - { src: gnocchi.conf.j2, dest: gnocchi.conf, enabled: "{{ kolla_enable_gnocchi }}" }
-    - { src: grafana.ini.j2, dest: grafana.ini, enabled: "{{ kolla_enable_grafana }}" }
-    - { src: heat.conf.j2, dest: heat.conf, enabled: "{{ kolla_enable_heat }}" }
-    - { src: ironic.conf.j2, dest: ironic.conf, enabled: "{{ kolla_enable_ironic }}" }
-    - { src: ironic-inspector.conf.j2, dest: ironic-inspector.conf, enabled: "{{ kolla_enable_ironic }}" }
-    - { src: keystone.conf.j2, dest: keystone.conf, enabled: "{{ kolla_enable_keystone }}" }
-    - { src: magnum.conf.j2, dest: magnum.conf, enabled: "{{ kolla_enable_magnum }}" }
-    - { src: manila.conf.j2, dest: manila.conf, enabled: "{{ kolla_enable_manila }}" }
-    - { src: backup.my.cnf.j2, dest: backup.my.cnf, enabled: "{{ kolla_enable_mariabackup }}" }
-    - { src: masakari.conf.j2, dest: masakari.conf, enabled: "{{ kolla_enable_masakari }}" }
-    - { src: ml2_conf.ini.j2, dest: neutron/ml2_conf.ini, enabled: "{{ kolla_enable_neutron }}" }
-    - { src: multipath.conf.j2, dest: multipath.conf, enabled: "{{ kolla_enable_multipathd }}" }
-    - { src: murano.conf.j2, dest: murano.conf, enabled: "{{ kolla_enable_murano }}" }
-    - { src: neutron.conf.j2, dest: neutron.conf, enabled: "{{ kolla_enable_neutron }}" }
-    - { src: nova.conf.j2, dest: nova.conf, enabled: "{{ kolla_enable_nova }}" }
-    - { src: octavia.conf.j2, dest: octavia.conf, enabled: "{{ kolla_enable_octavia }}" }
-    - { src: placement.conf.j2, dest: placement.conf, enabled: "{{ kolla_enable_placement }}" }
-    - { src: sahara.conf.j2, dest: sahara.conf, enabled: "{{ kolla_enable_sahara }}" }
-  when: item.enabled | bool
-
 - name: Ensure ironic inspector kernel and ramdisk images are present
   vars:
     image_download_url: "{{ item.url }}"
@@ -67,84 +23,110 @@
   loop_control:
     label: "{{ item.dest }}"
 
+- name: Make destination directory for Nova certificates
+  file:
+    state: directory
+    path: "{{ kolla_node_custom_config_path }}/nova/nova-libvirt/"
+  when: kolla_enable_nova | bool and kolla_libvirt_tls | bool
+
+- name: Copy client TLS certificates for Nova
+  vars:
+    certificates:
+      - clientcert.pem
+      - clientkey.pem
+      - cacert.pem
+  copy:
+    src: "{{ kolla_nova_libvirt_certificates_src }}/{{ item }}"
+    dest: "{{ kolla_node_custom_config_path }}/nova/nova-libvirt/{{ item }}"
+  loop: "{{ certificates if kolla_enable_nova | bool and kolla_libvirt_tls | bool else [] }}"
+
+- name: Copy server TLS certificates for Nova
+  vars:
+    certificates:
+      - servercert.pem
+      - serverkey.pem
+  copy:
+    src: "{{ kolla_nova_libvirt_certificates_src }}/{{ item }}"
+    dest: "{{ kolla_node_custom_config_path }}/nova/nova-libvirt/{{ item }}"
+  loop: "{{ certificates if kolla_enable_nova | bool and kolla_enable_nova_libvirt_container | bool and kolla_libvirt_tls | bool else [] }}"
+
 # We support a fairly flexible mechanism of dropping config file templates into
 # an 'extra' config directory, and passing these through to kolla-ansible. We
 # look for matching files in the source directory to template, and also remove
 # any unexpected files from the destination, to support removal of files.
 
-- name: Find extra configuration files
-  find:
-    path: "{{ item.src }}"
-    patterns: "{{ item.patterns }}"
-    recurse: true
-  with_items: "{{ kolla_openstack_custom_config }}"
-  register: find_src_result
-  delegate_to: localhost
+- name: Collect details about custom config
+  kolla_custom_config_info:
+    destination: "{{ kolla_node_custom_config_path }}"
+    ignore_globs: "{{ _kolla_openstack_custom_config_cleanup_ignore_globs }}"
+    include_globs: "{{ kolla_openstack_custom_config_include_globs }}"
+    rules: "{{ kolla_openstack_custom_config_rules }}"
+    search_paths: "{{ kolla_openstack_custom_config_paths | product(['/kolla/config']) | map('join') | list }}"
+  register: kolla_custom_config_info
 
-- name: Find previously generated extra configuration files
-  find:
-    path: "{{ item.dest }}"
-    patterns: "{{ item.patterns }}"
-  with_items: "{{ kolla_openstack_custom_config }}"
-  register: find_dest_result
+- name: Print kolla_custom_config_info when using -v
+  debug:
+    msg: "{{ kolla_custom_config_info }}"
+    verbosity: 1
 
 - name: Ensure extra configuration parent directories are present
   file:
-    path: "{{ item.0.item.dest }}/{{ item.1.path | relpath(item.0.item.src) | dirname }}"
+    path: "{{ item }}"
+    recurse: true
     state: directory
-    mode: 0750
-  with_subelements:
-    - "{{ find_src_result.results }}"
-    - files
-    - skip_missing: true
-  when:
-    - item.0.item.enabled | bool
-    - item.1.path | basename not in item.0.item.ignore | default([])
+  with_items: "{{ kolla_custom_config_info.create_dir }}"
 
-- name: Ensure templated extra configuration files exist
-  template:
-    src: "{{ item.1.path }}"
-    dest: "{{ item.0.item.dest }}/{{ item.1.path | relpath(item.0.item.src) }}"
-    mode: 0640
-  with_subelements:
-    - "{{ find_src_result.results }}"
-    - files
-    - skip_missing: true
-  when:
-    - item.0.item.enabled | bool
-    - item.1.path | basename not in item.0.item.ignore | default([])
-    - item.1.path | basename not in item.0.item.untemplated | default([])
-    - (item.1.path | dirname | relpath(item.0.item.src)).split("/")[0] not in item.0.item.untemplated_dirs | default([])
+- name: "Ensure extra configuration files exist (strategy: template)"
+  vars:
+    params:
+      src: "{{ item.src }}"
+      dest: "{{ item.dest }}"
+      mode: 0640
+  template: "{{ params | combine(item.params) }}"
+  with_items: "{{ kolla_custom_config_info.template }}"
 
-- name: Ensure untemplated extra configuration files exist
-  copy:
-    src: "{{ item.1.path }}"
-    dest: "{{ item.0.item.dest }}/{{ item.1.path | relpath(item.0.item.src) }}"
-    mode: 0640
-  with_subelements:
-    - "{{ find_src_result.results }}"
-    - files
-    - skip_missing: true
-  when:
-    - item.0.item.enabled | bool
-    - item.1.path | basename not in item.0.item.ignore | default([])
-    - (item.1.path | basename in item.0.item.untemplated | default([])) or
-      ((item.1.path | dirname | relpath(item.0.item.src)).split("/")[0] in item.0.item.untemplated_dirs | default([]))
+- name: "Ensure extra configuration files exist (strategy: copy)"
+  vars:
+    params:
+      src: "{{ item.src }}"
+      dest: "{{ item.dest }}"
+      mode: 0640
+  copy: "{{ params | combine(item.params) }}"
+  # NOTE: .copy is ambiguous with copy method
+  with_items: "{{ kolla_custom_config_info['copy'] }}"
+
+- name: "Ensure extra configuration files exist (strategy: merge_configs)"
+  vars:
+    params:
+      sources: "{{ item.sources }}"
+      dest: "{{ item.dest }}"
+      mode: 0640
+  merge_configs: "{{ params | combine(item.params) }}"
+  with_items: "{{ kolla_custom_config_info.merge_configs }}"
+
+- name: "Ensure extra configuration files exist (strategy: merge_yaml)"
+  vars:
+    params:
+      sources: "{{ item.sources }}"
+      dest: "{{ item.dest }}"
+      mode: 0640
+  merge_yaml: "{{ params | combine(item.params) }}"
+  with_items: "{{ kolla_custom_config_info.merge_yaml }}"
+
+- name: "Ensure extra configuration files exist (strategy: concat)"
+  vars:
+    params:
+      content: |
+        {%- for path in item.sources -%}
+        {{ lookup('template', path) }}
+        {%- endfor -%}
+      dest: "{{ item.dest }}"
+      mode: 0640
+  copy: "{{ params | combine(item.params) }}"
+  with_items: "{{ kolla_custom_config_info.concat }}"
 
 - name: Ensure unnecessary extra configuration files are absent
   file:
-    path: "{{ item.1.path }}"
+    path: "{{ item }}"
     state: absent
-  with_subelements:
-    - "{{ find_dest_result.results }}"
-    - files
-    - skip_missing: true
-  when:
-    - not item.0.item.enabled or
-      item.1.path | basename not in src_files
-    - item.1.path | basename not in item.0.item.ignore | default([])
-  vars:
-    # Find the source result that corresponds to this one.
-    src_result: "{{ (find_src_result.results | selectattr('item', 'equalto', item.0.item) | list)[0] }}"
-    # Find the list of files in the source.
-    src_files: "{{ src_result.files | map(attribute='path') | map('basename') | list }}"
+  with_items: "{{ kolla_custom_config_info.delete }}"
diff --git a/ansible/roles/kolla-openstack/templates/aodh.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/aodh.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/aodh.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/aodh.conf
index 5d7d4c1e0636d9323d7be9db721fd9998cbdd724..50df0b62cebb34ba0101d73ef011dfda83ccd959 100644
--- a/ansible/roles/kolla-openstack/templates/aodh.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/aodh.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_aodh %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/backup.my.cnf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/backup.my.cnf
similarity index 85%
rename from ansible/roles/kolla-openstack/templates/backup.my.cnf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/backup.my.cnf
index 7213d824ddbb4b45f68023400700ee92fa61ab18..95d5cd004037dac47358ade9a237e1ab29e48019 100644
--- a/ansible/roles/kolla-openstack/templates/backup.my.cnf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/backup.my.cnf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_mariabackup %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/barbican.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/barbican.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/barbican.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/barbican.conf
index 2a33517a83a5883e157d940cdc9e4f0773375e21..eb4138b3dd8c1fe61e2784153d96c490a4cdfb4a 100644
--- a/ansible/roles/kolla-openstack/templates/barbican.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/barbican.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_barbican %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/blazar.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/blazar.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/blazar.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/blazar.conf
index aab01021d1c890eec49401fbb167c30c8ec02e05..f6c35e85240bbeac941eca7d2d0127b3da68714a 100644
--- a/ansible/roles/kolla-openstack/templates/blazar.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/blazar.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_blazar %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/ceilometer.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/ceilometer.conf
similarity index 85%
rename from ansible/roles/kolla-openstack/templates/ceilometer.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/ceilometer.conf
index 5a9187e19dd1813ccef6de45b4803919b50ae12b..474bbc564c2c5cc06f0ff4f0b1464efabe95af1c 100644
--- a/ansible/roles/kolla-openstack/templates/ceilometer.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/ceilometer.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_ceilometer %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/cinder.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/cinder.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/cinder.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/cinder.conf
index 9acf122df82ae38b8546e7311df6dbd3fb487f8b..5f0bc7cded6fe30d804fc2f87fb6336e619f2421 100644
--- a/ansible/roles/kolla-openstack/templates/cinder.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/cinder.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_cinder %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/cloudkitty.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/cloudkitty.conf
similarity index 85%
rename from ansible/roles/kolla-openstack/templates/cloudkitty.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/cloudkitty.conf
index 75929ef15552fc2d63639ecff6c4b1288afc8bab..c2d5a35b88ea8c4e9a2b0bfb3baa37a04fd39b5f 100644
--- a/ansible/roles/kolla-openstack/templates/cloudkitty.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/cloudkitty.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_cloudkitty %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/designate.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/designate.conf
similarity index 85%
rename from ansible/roles/kolla-openstack/templates/designate.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/designate.conf
index 96386b0771cf78d593be2053c2a259680e50fda4..632ecb9ffb7268098bb5f444c9020758944a1740 100644
--- a/ansible/roles/kolla-openstack/templates/designate.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/designate.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_designate %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/galera.cnf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/galera.cnf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/galera.cnf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/galera.cnf
index 55122ebb04ca590d17cae9f39c547b0213b86ee7..49f1df7dc57f60751e9e795900d99a169e87dcd6 100644
--- a/ansible/roles/kolla-openstack/templates/galera.cnf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/galera.cnf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_mariadb %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/glance.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/glance.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/glance.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/glance.conf
index 34692b6d3a01a29e2026fcfbc428ec70573953be..837aef50f2ae59fefd303f818f5efcae9915d21c 100644
--- a/ansible/roles/kolla-openstack/templates/glance.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/glance.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_glance %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/global.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/global.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/global.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/global.conf
index 5b27f3623cae15493289b31e073f04d03612e6f3..d6be39c4b08708bf62b474f7f81f4ecd66cd8231 100644
--- a/ansible/roles/kolla-openstack/templates/global.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/global.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_global %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/gnocchi.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/gnocchi.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/gnocchi.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/gnocchi.conf
index a9781dfca63ca8b0c2e63cd9d2fec63835632241..a44f667ebce44e87eedaea6da1f613ca309d8dc8 100644
--- a/ansible/roles/kolla-openstack/templates/gnocchi.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/gnocchi.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_gnocchi %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/grafana.ini.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/grafana.ini
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/grafana.ini.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/grafana.ini
index d1c34be6adee8db099560f601f0d6d7137bdf6e9..efcc475a317a411c499c4198817fd311d4c8c612 100644
--- a/ansible/roles/kolla-openstack/templates/grafana.ini.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/grafana.ini
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_grafana %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/heat.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/heat.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/heat.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/heat.conf
index 586fcc32ab4b5988fb8d367a7416978c7a4df0ef..70312bf44fe2f000573e96529b16a61498e7fc2a 100644
--- a/ansible/roles/kolla-openstack/templates/heat.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/heat.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_heat %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/ironic-inspector.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/ironic-inspector.conf
similarity index 100%
rename from ansible/roles/kolla-openstack/templates/ironic-inspector.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/ironic-inspector.conf
diff --git a/ansible/roles/kolla-openstack/templates/ironic.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/ironic.conf
similarity index 98%
rename from ansible/roles/kolla-openstack/templates/ironic.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/ironic.conf
index cdf5e401d6ce05c0a76045b7048c50d24f56af5d..49c5203e88316ea390df4a17233f9f7b740a861a 100644
--- a/ansible/roles/kolla-openstack/templates/ironic.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/ironic.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 [DEFAULT]
 enabled_hardware_types: {{ kolla_ironic_enabled_hardware_types | join(',') }}
 
diff --git a/ansible/roles/kolla-openstack/templates/keystone.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/keystone.conf
similarity index 85%
rename from ansible/roles/kolla-openstack/templates/keystone.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/keystone.conf
index 753e98bb86e9467d9245d2ab9e39643dca776113..47c9b31bfb8bcee34bf1ad47514bb07ef29c8d3c 100644
--- a/ansible/roles/kolla-openstack/templates/keystone.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/keystone.conf
@@ -1,4 +1,3 @@
-# {{ ansible_managed }}
 {% if kolla_extra_keystone %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/magnum.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/magnum.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/magnum.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/magnum.conf
index 03e40fc9edd5d408903989b7374970855c4d53a6..574afcbb1a21a0c5a84e271236b6232e4dad12e7 100644
--- a/ansible/roles/kolla-openstack/templates/magnum.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/magnum.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_magnum %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/manila.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/manila.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/manila.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/manila.conf
index 63faff851e9f5fdb7c51314988600bcbf7e78151..fa0a7f62c524f655e5c8168ebcb50a18689e0989 100644
--- a/ansible/roles/kolla-openstack/templates/manila.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/manila.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_manila %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/masakari.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/masakari.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/masakari.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/masakari.conf
index 5a6848c8fdd5eccd8f06f4a5a4e6dbf02d535966..3a36fc1acbc62b31a056eefbc28914695641fe71 100644
--- a/ansible/roles/kolla-openstack/templates/masakari.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/masakari.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_masakari %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/multipath.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/multipath.conf
similarity index 100%
rename from ansible/roles/kolla-openstack/templates/multipath.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/multipath.conf
diff --git a/ansible/roles/kolla-openstack/templates/murano.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/murano.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/murano.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/murano.conf
index 5d6af4b974548da7b186553d91ca52c0946bfb48..effbc2fd25e565b0c900e244beb35845d03473ce 100644
--- a/ansible/roles/kolla-openstack/templates/murano.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/murano.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_murano %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/neutron.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/neutron.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/neutron.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/neutron.conf
index 1cf183d8fef46031fd0aa87702b4f2882df2b2ef..f1af1f7107b3f03486145a12db1b9ed391961592 100644
--- a/ansible/roles/kolla-openstack/templates/neutron.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/neutron.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_neutron %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/ml2_conf.ini.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/neutron/ml2_conf.ini
similarity index 98%
rename from ansible/roles/kolla-openstack/templates/ml2_conf.ini.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/neutron/ml2_conf.ini
index 1e49ae17a5b4e8d51cbb6888a0fdc6fe9b58abeb..b6674fd1f4cbb0353414336fcfb5817d880546fa 100644
--- a/ansible/roles/kolla-openstack/templates/ml2_conf.ini.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/neutron/ml2_conf.ini
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 [ml2]
 {% if kolla_neutron_ml2_mechanism_drivers %}
 mechanism_drivers = {{ kolla_neutron_ml2_mechanism_drivers | join(',') }}
diff --git a/ansible/roles/kolla-openstack/templates/nova.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/nova.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/nova.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/nova.conf
index 772261a59e9c0cc5c0eafb7e582713f95d3d2e7b..b70bbb3ff5032e8f0b7142b2acd1223f52bfa3c8 100644
--- a/ansible/roles/kolla-openstack/templates/nova.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/nova.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_nova %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/octavia.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/octavia.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/octavia.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/octavia.conf
index 4210d8485550c1fc97013270ed5a38249e60e815..58131fef749f4f41e10ffa050429f2ac64497cdc 100644
--- a/ansible/roles/kolla-openstack/templates/octavia.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/octavia.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_octavia %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/placement.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/placement.conf
similarity index 85%
rename from ansible/roles/kolla-openstack/templates/placement.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/placement.conf
index 287a13d034dac46ee9c3679d8e10b4633dc15603..cdbee0070f2b3d5f41ef16f9e47832a4e8b9a3df 100644
--- a/ansible/roles/kolla-openstack/templates/placement.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/placement.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_placement %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/templates/sahara.conf.j2 b/ansible/roles/kolla-openstack/templates/kolla/config/sahara.conf
similarity index 84%
rename from ansible/roles/kolla-openstack/templates/sahara.conf.j2
rename to ansible/roles/kolla-openstack/templates/kolla/config/sahara.conf
index 995cfe9bb92ba0ed72b9a9a5e4303c1563616049..d850f95ec677e54c52f70a6018b0ef04f3254282 100644
--- a/ansible/roles/kolla-openstack/templates/sahara.conf.j2
+++ b/ansible/roles/kolla-openstack/templates/kolla/config/sahara.conf
@@ -1,5 +1,3 @@
-# {{ ansible_managed }}
-
 {% if kolla_extra_sahara %}
 #######################
 # Extra configuration
diff --git a/ansible/roles/kolla-openstack/vars/main.yml b/ansible/roles/kolla-openstack/vars/main.yml
index a72a7da4bd2b75b968b2b688c8f270301e3fcd46..40b892b0146c45cd84bba8540ada548ba7cc25a9 100644
--- a/ansible/roles/kolla-openstack/vars/main.yml
+++ b/ansible/roles/kolla-openstack/vars/main.yml
@@ -1,222 +1,16 @@
 ---
-# List of custom configuration directories.
-# Each item is a dict containing the following items:
-# src: Path to directory containing configuration file templates.
-# dest: Path to directory in which generated files will be created.
-# patterns: One or more file name patterns to match.
-# enabled: Whether these files should be templated.
-# ignore: Optional list of files to ignore. These files will not be copied to
-#         the destination, and will not be removed from the destination, even
-#         if disabled or unexpected.
-kolla_openstack_custom_config:
-  # Aodh.
-  - src: "{{ kolla_extra_config_path }}/aodh"
-    dest: "{{ kolla_node_custom_config_path }}/aodh"
-    patterns: "*"
-    enabled: "{{ kolla_enable_aodh }}"
-  # Barbican.
-  - src: "{{ kolla_extra_config_path }}/barbican"
-    dest: "{{ kolla_node_custom_config_path }}/barbican"
-    patterns: "*"
-    enabled: "{{ kolla_enable_barbican }}"
-  # Blazar.
-  - src: "{{ kolla_extra_config_path }}/blazar"
-    dest: "{{ kolla_node_custom_config_path }}/blazar"
-    patterns: "*"
-    enabled: "{{ kolla_enable_blazar }}"
-  # Ceilometer.
-  - src: "{{ kolla_extra_config_path }}/ceilometer"
-    dest: "{{ kolla_node_custom_config_path }}/ceilometer"
-    patterns: "*"
-    enabled: "{{ kolla_enable_ceilometer }}"
-  # Cinder.
-  - src: "{{ kolla_extra_config_path }}/cinder"
-    dest: "{{ kolla_node_custom_config_path }}/cinder"
-    patterns: "*"
-    enabled: "{{ kolla_enable_cinder }}"
-  # CloudKitty.
-  - src: "{{ kolla_extra_config_path }}/cloudkitty"
-    dest: "{{ kolla_node_custom_config_path }}/cloudkitty"
-    patterns: "*"
-    enabled: "{{ kolla_enable_cloudkitty }}"
-  # Designate.
-  - src: "{{ kolla_extra_config_path }}/designate"
-    dest: "{{ kolla_node_custom_config_path }}/designate"
-    patterns: "*"
-    enabled: "{{ kolla_enable_designate }}"
-  # Fluentd filters.
-  - src: "{{ kolla_extra_config_path }}//fluentd/filter"
-    dest: "{{ kolla_node_custom_config_path }}/fluentd/filter"
-    patterns: "*.conf"
-    enabled: true
-  # Fluentd inputs.
-  - src: "{{ kolla_extra_config_path }}//fluentd/input"
-    dest: "{{ kolla_node_custom_config_path }}/fluentd/input"
-    patterns: "*.conf"
-    enabled: true
-  # Fluentd outputs.
-  - src: "{{ kolla_extra_config_path }}/fluentd/output"
-    dest: "{{ kolla_node_custom_config_path }}/fluentd/output"
-    patterns: "*.conf"
-    enabled: true
-  # Glance.
-  - src: "{{ kolla_extra_config_path }}/glance"
-    dest: "{{ kolla_node_custom_config_path }}/glance"
-    patterns: "*"
-    enabled: "{{ kolla_enable_glance }}"
-  # Gnocchi.
-  - src: "{{ kolla_extra_config_path }}/gnocchi"
-    dest: "{{ kolla_node_custom_config_path }}/gnocchi"
-    patterns: "*"
-    enabled: "{{ kolla_enable_gnocchi }}"
-  # Grafana.
-  - src: "{{ kolla_extra_config_path }}/grafana"
-    dest: "{{ kolla_node_custom_config_path }}/grafana"
-    patterns: "*"
-    enabled: "{{ kolla_enable_grafana }}"
-  # HAProxy.
-  - src: "{{ kolla_extra_config_path }}/haproxy"
-    dest: "{{ kolla_node_custom_config_path }}/haproxy"
-    patterns: "*"
-    enabled: "{{ kolla_enable_haproxy }}"
-  - src: "{{ kolla_extra_config_path }}/haproxy-config"
-    dest: "{{ kolla_node_custom_config_path }}/haproxy-config"
-    patterns: "*"
-    enabled: "{{ kolla_enable_haproxy }}"
-  # Heat.
-  - src: "{{ kolla_extra_config_path }}/heat"
-    dest: "{{ kolla_node_custom_config_path }}/heat"
-    patterns: "*"
-    enabled: "{{ kolla_enable_heat }}"
-  # Horizon.
-  - src: "{{ kolla_extra_config_path }}/horizon"
-    dest: "{{ kolla_node_custom_config_path }}/horizon"
-    patterns: "*"
-    enabled: "{{ kolla_enable_horizon }}"
-    untemplated_dirs:
-    # Do not attempt to template themes directory.
-      - "themes"
-  # InfluxDB.
-  - src: "{{ kolla_extra_config_path }}/"
-    dest: "{{ kolla_node_custom_config_path }}/"
-    patterns: "influx*"
-    enabled: "{{ kolla_enable_influxdb }}"
-  # Ironic.
-  - src: "{{ kolla_extra_config_path }}/ironic"
-    dest: "{{ kolla_node_custom_config_path }}/ironic"
-    patterns: "*"
-    enabled: "{{ kolla_enable_ironic }}"
-    ignore:
-      # These are templated by kayobe, so don't remove them.
-      - ironic-agent.initramfs
-      - ironic-agent.kernel
-  # Keystone.
-  - src: "{{ kolla_extra_config_path }}/keystone"
-    dest: "{{ kolla_node_custom_config_path }}/keystone"
-    patterns: "*"
-    enabled: "{{ kolla_enable_keystone }}"
-  # Keepalived.
-  - src: "{{ kolla_extra_config_path }}/keepalived"
-    dest: "{{ kolla_node_custom_config_path }}/keepalived"
-    patterns: "*"
-    enabled: "{{ kolla_enable_haproxy }}"
-  # Magnum.
-  - src: "{{ kolla_extra_config_path }}/magnum"
-    dest: "{{ kolla_node_custom_config_path }}/magnum"
-    patterns: "*"
-    enabled: "{{ kolla_enable_magnum }}"
-  # Manila.
-  - src: "{{ kolla_extra_config_path }}/manila"
-    dest: "{{ kolla_node_custom_config_path }}/manila"
-    patterns: "*"
-    enabled: "{{ kolla_enable_manila }}"
-  # MariaDB.
-  - src: "{{ kolla_extra_config_path }}/mariadb"
-    dest: "{{ kolla_node_custom_config_path }}/mariadb"
-    patterns: "*"
-    enabled: "{{ kolla_enable_mariadb }}"
-  # Masakari.
-  - src: "{{ kolla_extra_config_path }}/masakari"
-    dest: "{{ kolla_node_custom_config_path }}/masakari"
-    patterns: "*"
-    enabled: "{{ kolla_enable_masakari }}"
-  # Murano.
-  - src: "{{ kolla_extra_config_path }}/murano"
-    dest: "{{ kolla_node_custom_config_path }}/murano"
-    patterns: "*"
-    enabled: "{{ kolla_enable_murano }}"
-  # Neutron.
-  - src: "{{ kolla_extra_config_path }}/neutron"
-    dest: "{{ kolla_node_custom_config_path }}/neutron"
-    patterns: "*"
-    enabled: "{{ kolla_enable_neutron }}"
-    ignore:
-      # These are templated by kayobe, so don't remove them.
-      - ml2_conf.ini
-  # Nova.
-  - src: "{{ kolla_extra_config_path }}/nova"
-    dest: "{{ kolla_node_custom_config_path }}/nova"
-    patterns: "*"
-    enabled: "{{ kolla_enable_nova }}"
-  - src: "{{ kolla_extra_config_path }}/nova_compute"
-    dest: "{{ kolla_node_custom_config_path }}/nova_compute"
-    patterns: "*"
-    enabled: "{{ kolla_enable_nova }}"
-  - src: "{{ kolla_nova_libvirt_certificates_src }}"
-    dest: "{{ kolla_node_custom_config_path }}/nova/nova-libvirt"
-    patterns:
-      - clientcert.pem
-      - clientkey.pem
-      - cacert.pem
-    enabled: "{{ kolla_enable_nova | bool and kolla_libvirt_tls | bool }}"
-    untemplated:
-      - clientcert.pem
-      - clientkey.pem
-      - cacert.pem
-  - src: "{{ kolla_nova_libvirt_certificates_src }}"
-    dest: "{{ kolla_node_custom_config_path }}/nova/nova-libvirt"
-    patterns:
-      - servercert.pem
-      - serverkey.pem
+
+# An internal variable used for cleaning files from the generated config
+# directory. Should not be exposed as we may wish to change the cleanup
+# mechanism to something more robust.
+_kolla_openstack_custom_config_cleanup_ignore_globs:
+  - glob: ironic/ironic-agent.initramfs
+    enabled: "{{ kolla_enable_ironic | bool }}"
+  - glob: ironic/ironic-agent.kernel
+    enabled: "{{ kolla_enable_ironic | bool }}"
+  - glob: nova/nova-libvirt/server*.pem
     enabled: "{{ kolla_enable_nova | bool and kolla_enable_nova_libvirt_container | bool and kolla_libvirt_tls | bool }}"
-    untemplated:
-      - servercert.pem
-      - serverkey.pem
-  # Octavia.
-  - src: "{{ kolla_extra_config_path }}/octavia"
-    dest: "{{ kolla_node_custom_config_path }}/octavia"
-    patterns: "*"
-    enabled: "{{ kolla_enable_octavia }}"
-  # OpenSearch.
-  - src: "{{ kolla_extra_config_path }}/opensearch"
-    dest: "{{ kolla_node_custom_config_path }}/opensearch"
-    patterns: "*"
-    enabled: "{{ kolla_enable_opensearch }}"
-  # Placement
-  - src: "{{ kolla_extra_config_path }}/placement"
-    dest: "{{ kolla_node_custom_config_path }}/placement"
-    patterns: "*"
-    enabled: "{{ kolla_enable_placement }}"
-  # Prometheus config
-  - src: "{{ kolla_extra_config_path }}/prometheus"
-    dest: "{{ kolla_node_custom_config_path }}/prometheus"
-    patterns: "*"
-    enabled: "{{ kolla_enable_prometheus }}"
-  # Sahara.
-  - src: "{{ kolla_extra_config_path }}/sahara"
-    dest: "{{ kolla_node_custom_config_path }}/sahara"
-    patterns: "*"
-    enabled: "{{ kolla_enable_sahara }}"
-  # Swift.
-  - src: "{{ kolla_extra_config_path }}/swift"
-    dest: "{{ kolla_node_custom_config_path }}/swift"
-    patterns: "*"
-    enabled: "{{ kolla_enable_swift }}"
-    untemplated:
-      # These are binary files, and should not be templated.
-      - account.builder
-      - account.ring.gz
-      - container.builder
-      - container.ring.gz
-      - object.builder
-      - object.ring.gz
+  - glob: nova/nova-libvirt/client*.pem
+    enabled: "{{ kolla_enable_nova | bool and kolla_libvirt_tls | bool }}"
+  - glob: nova/nova-libvirt/cacert.pem
+    enabled: "{{ kolla_enable_nova | bool and kolla_libvirt_tls | bool }}"
diff --git a/doc/source/multiple-environments.rst b/doc/source/multiple-environments.rst
index bf2097d9825cf712464317645153784350c6dd30..86ba0e328c37d369b2a3ad0a74061a62a58a8f98 100644
--- a/doc/source/multiple-environments.rst
+++ b/doc/source/multiple-environments.rst
@@ -177,10 +177,8 @@ Kolla Configuration
 -------------------
 
 In the Wallaby release, Kolla configuration was independent in each
-environment.
-
-As of the Xena release, the following files support combining the
-environment-specific and shared configuration file content:
+environment. The Xena release supported combining environment-specific
+and shared configuration file content for the following subset of the files:
 
 * ``kolla/config/bifrost/bifrost.yml``
 * ``kolla/config/bifrost/dib.yml``
@@ -189,8 +187,156 @@ environment-specific and shared configuration file content:
 * ``kolla/kolla-build.conf``
 * ``kolla/repos.yml`` or ``kolla/repos.yaml``
 
-Options in the environment-specific files take precedence over those in the
-shared files.
+The Antelope release expands upon this list to add support for combining Kolla
+Ansible custom service configuration. This behaviour is configured using two
+variables:
+
+* ``kolla_openstack_custom_config_include_globs``: Specifies which files are
+  considered when templating the Kolla configuration. The Kayobe defaults
+  are set using ``kolla_openstack_custom_config_include_globs_default``.
+  An optional list of additional globs can be set using:
+  ``kolla_openstack_custom_config_include_globs_extra``. These are
+  combined with ``kolla_openstack_custom_config_include_globs_default``
+  to produce ``kolla_openstack_custom_config_include_globs``.
+  Each list entry is a dictionary with the following keys:
+
+   * ``enabled``: Boolean which determines if this rule is used. Set to
+     ``false`` to disable the rule.
+   * ``glob``: String glob matching a relative path in the ``kolla/config``
+     directory
+
+   An example of such a rule:
+
+   .. code-block:: yaml
+
+      enabled: '{{ kolla_enable_aodh | bool }}'
+      glob: aodh/**
+
+* ``kolla_openstack_custom_config_rules``: List of rules that specify the
+  strategy to use when generating a particular file. The Kayobe defaults
+  are set using ``kolla_openstack_custom_config_rules_default``.
+  An optional list of additional rules can be set using:
+  ``kolla_openstack_custom_config_rules_extra``. These are
+  combined with ``kolla_openstack_custom_config_rules_default``
+  to produce ``kolla_openstack_custom_config_rules``.
+  Each list entry is a dictionary with the format:
+
+   * ``glob``: A glob matching files for this rule to match on (relative to the
+     search path)
+   * ``priority``: The rules are processed in increasing priority order with the
+     first rule matching taking effect
+   * ``strategy``: How to process the matched file. One of ``copy``,
+     ``concat``, ``template``, ``merge_configs``, ``merge_yaml``
+   * ``params``: Optional list of additional params to pass to module enacting
+     the strategy
+
+   An example of such a rule:
+
+   .. code-block:: yaml
+
+      glob: a/path/test.yml
+      strategy: merge_yaml
+      priority: 1000
+      params:
+        extend_lists: true
+
+The Kayobe defaults fallback to using the ``template`` strategy, with a
+priority of 65535. To override this behaviour configure a rule with a lower
+priority e.g:
+
+   .. code-block:: yaml
+
+      glob: horizon/themes/**
+      strategy: copy
+      priority: 1000
+
+The default INI merging strategy can be configured using:
+``kolla_openstack_custom_config_ini_merge_strategy_default``. It defaults to ``concat``
+for backwards compatibility. An alternative strategy is ``merge_configs`` which will
+merge the two INI files so that values set in the environment take precedence over values
+set in the shared files. The caveat with the ``merge_configs`` strategy is that files
+must template to valid INI. This is mostly an issue when you use raw Jinja
+tags, for example:
+
+   .. code-block:: ini
+
+      [defaults]
+      {% raw %}
+      {% if inventory_hostname in 'compute' %}
+      foo=bar
+      {% else %}
+      foo=baz
+      {% endif %}
+      {% endraw %}
+
+After the first round of templating by Kayobe the raw tags are stripped. This leaves:
+
+   .. code-block:: ini
+
+      [defaults]
+      {% if inventory_hostname in 'compute' %}
+      foo=bar
+      {% else %}
+      foo=baz
+      {% endif %}
+
+Which isn't valid INI (due to the Jinja if blocks) and cannot be merged. In most cases
+the templating can be refactored:
+
+   .. code-block:: ini
+
+      [defaults]
+      {% raw %}
+      foo={{ 'bar' if inventory_hostname in 'compute' else 'baz' }}
+      {% endraw %}
+
+Alternatively, you can use Kolla host or group variables.
+
+Disabling the default rules
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+There are some convenience variables to disable a subset of the
+rules in ``kolla_openstack_custom_config_rules_default``:
+
+* ``kolla_openstack_custom_config_rules_default_remove``: Allows you remove
+  a rule by matching on the glob:
+
+   .. code-block:: yaml
+
+      kolla_openstack_custom_config_rules_default_remove:
+         - "**/*.ini"
+
+* ``kolla_openstack_custom_config_merge_configs_enabled``: Enables rules for
+  matching INI files. Default is ``true``.
+
+* ``kolla_openstack_custom_config_merge_yaml_enabled``: Enables rules for
+  matching YAML files. Default is ``true``.
+
+These allow you to more easily keep in sync with the upstream defaults. If
+you had an override on ``kolla_openstack_custom_config_rules``, that
+replicated most of ``kolla_openstack_custom_config_rules_default`` you'd have
+to keep this in sync with the upstream kayobe defaults.
+
+Search paths
+^^^^^^^^^^^^
+
+When merging config files the following locations are "searched" to find
+files with an identical relative path:
+
+- ``<environment-path>/kolla/config``
+- ``<shared-files-path>/kolla/config``
+- ``<kolla-openstack-role-path>/templates/kolla/config``
+
+Not all strategies use all of the files when generating the kolla config.
+For instance, the copy strategy will use the first file found when searching
+each of the paths.
+
+There is a feature flag: ``kolla_openstack_custom_config_environment_merging_enabled``,
+that may be set to ``false`` to prevent Kayobe searching the shared files path
+when merging configs. This is to replicate the legacy behaviour where the
+environment Kolla custom service configuration was not merged with the base
+layer. We still merge the files with Kayobe's defaults in the
+``kolla-openstack`` role's internal templates.
 
 Managing Independent Environment Files
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/etc/kayobe/kolla.yml b/etc/kayobe/kolla.yml
index 81e48c55bcf111af37a2e609235b6549d29f0cdd..19e54c809262d0e1a351c63df66059c7871ad112 100644
--- a/etc/kayobe/kolla.yml
+++ b/etc/kayobe/kolla.yml
@@ -447,6 +447,79 @@
 #kolla_enable_watcher:
 #kolla_enable_zun:
 
+###############################################################################
+# Kolla custom config generation.
+
+# Feature flag to add $KAYOBE_CONFIG_PATH to the list of search paths used
+# when searching for Kolla custom service configuration. Only has an effect in
+# a multiple environments setup. This allows you to configure merging between
+# your environment and the base layer. Defaults to true. Set to false to for
+# backwards compatability.
+#kolla_openstack_custom_config_environment_merging_enabled:
+
+# Default value for kolla_openstack_custom_config_include_globs.
+#kolla_openstack_custom_config_include_globs_default:
+
+# Extra items to add to kolla_openstack_custom_config_include_globs_default
+# to produce kolla_openstack_custom_config_include_globs.
+#kolla_openstack_custom_config_include_globs_extra:
+
+# List of dictionaries with the following keys:
+#   glob: a glob pattern. Any files matching this pattern will be copied to the
+#         the kolla custom config directory
+#   enabled: boolean to disable the glob.
+# This determines the list of files to copy to the generated kolla config
+# directory.
+#kolla_openstack_custom_config_include_globs:
+
+# Kolla config generation rules. These operate on the list of files produced by
+# applying kolla_openstack_custom_config_include_globs. Each of the paths in
+# kolla_openstack_custom_config_paths is searched for files matching one of the
+# globs. If a match is found, any files with the same relative path are grouped
+# together. The rules determine what to do with these matching files e.g copy
+# the most specific file without templating, merge the files with
+# merge_configs, etc.
+# List of dictionaries with the following keys:
+#   glob: A glob matching files for this rule to match on (relative to the
+#     search path)
+#   priority: The rules are processed in increasing priority order with the
+#     first rule matching taking effect.
+#   strategy: How to process the matched file. One of copy, concat, template,
+#      merge_configs, merge_yaml
+#   params: List of params to pass to module enacting the strategy
+# Strategies:
+#   copy: Copy most specific file to kolla config without templating
+#   template: Template most specific file to kolla config
+#   concat: Concatenate files and copy the result to generated kolla config
+#   merge_configs: Use the merge_configs module to merge an ini file, before
+#     copying to the generated kolla-config.
+#   merge_yaml: Use the merge_yaml module to merge a file, before copying to
+#     the generated kolla-config.
+#kolla_openstack_custom_config_rules:
+
+# Whether to enable ini merging rules in
+# kolla_openstack_custom_config_rules_default. Default is true.
+#kolla_openstack_custom_config_merge_configs_enabled:
+
+# Whether to enable yaml merging rules in
+# kolla_openstack_custom_config_rules_default. Default is true.
+#kolla_openstack_custom_config_merge_yaml_enabled:
+
+# Default merge strategy for ini files in
+# kolla_openstack_custom_config_rules_default. Default is concat.
+#kolla_openstack_custom_config_ini_merge_strategy_default:
+
+# Default value for kolla_openstack_custom_config_rules.
+#kolla_openstack_custom_config_rules_default:
+
+# List of globs to filter from kolla_openstack_custom_config_rules_default.
+# Default is an empty list.
+#kolla_openstack_custom_config_rules_default_remove:
+
+# Extra items to add to kolla_openstack_custom_config_rules_default
+# to produce kolla_openstack_custom_config_rules.
+#kolla_openstack_custom_config_rules_extra:
+
 ###############################################################################
 # Passwords and credentials.
 
diff --git a/kayobe/tests/molecule/utils.py b/kayobe/tests/molecule/utils.py
index 07e311b7f323bd18c9bde50398ec1a8b3fe5cb5c..fa129e93da9e1ebc65ab80fd5a71f90763b5a226 100644
--- a/kayobe/tests/molecule/utils.py
+++ b/kayobe/tests/molecule/utils.py
@@ -12,6 +12,8 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
+import re
+
 import configparser
 from io import StringIO
 
@@ -53,6 +55,21 @@ def test_ini_file(host, path, owner='root', group='root', expected=None):
             assert parser.get(exp_section_name, exp_key) == exp_value
 
 
+def test_regex_in_file(host, path, owner='root', group='root', regex=None):
+    """Test that a regex exists in file
+
+    Validate that the file exists, has the correct ownership, format and
+    expected contents.
+
+    :param regex to search for in file
+    """
+    test_file(host, path, owner, group)
+
+    matches = re.findall(regex, host.file(path).content_string)
+
+    assert len(matches) > 0
+
+
 def test_directory(host, path, owner='root', group='root'):
     """Test an expected directory.
 
diff --git a/releasenotes/notes/feature-merge-kolla-configs-99773e2f0af2ea4b.yaml b/releasenotes/notes/feature-merge-kolla-configs-99773e2f0af2ea4b.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..74f5773f0c44274fced5361aaccf7a0ccfc712ac
--- /dev/null
+++ b/releasenotes/notes/feature-merge-kolla-configs-99773e2f0af2ea4b.yaml
@@ -0,0 +1,52 @@
+---
+features:
+  - |
+    Adds new functionality to merge Kolla custom service configuration in a
+    Kayobe environment with Kolla configuration in the base configuration
+    layer.
+upgrade:
+  - |
+    Environment-specific Kolla custom service configuration is now merged with
+    Kolla configuration in the base configuration layer.  Config options
+    duplicated in the base layer and the environment will need to be
+    de-deduplicated to avoid the config option showing up multiple times in the
+    generated output (although in general this should not be a problem).
+
+    Set ``kolla_openstack_custom_config_environment_merging_enabled`` to
+    ``false`` to revert back to the previous behavior where only the config in
+    the environment was considered.
+deprecations:
+  - |
+    Deprecates the following variables for removal in the Bobcat release:
+
+    * ``kolla_extra_global``
+    * ``kolla_extra_aodh``
+    * ``kolla_extra_barbican``
+    * ``kolla_extra_blazar``
+    * ``kolla_extra_ceilometer``
+    * ``kolla_extra_cinder``
+    * ``kolla_extra_cloudkitty``
+    * ``kolla_extra_designate``
+    * ``kolla_extra_gnocchi``
+    * ``kolla_extra_grafana``
+    * ``kolla_extra_heat``
+    * ``kolla_extra_ironic``
+    * ``kolla_extra_inspector``
+    * ``kolla_extra_keystone``
+    * ``kolla_extra_magnum``
+    * ``kolla_extra_mariabackup``
+    * ``kolla_extra_mariadb``
+    * ``kolla_extra_manila``
+    * ``kolla_extra_masakari``
+    * ``kolla_extra_murano``
+    * ``kolla_extra_neutron``
+    * ``kolla_extra_neutron_ml2``
+    * ``kolla_extra_nova``
+    * ``kolla_extra_octavia``
+    * ``kolla_extra_placement``
+    * ``kolla_extra_sahara``
+
+    Use of Kolla custom service configuration files in
+    ``etc/kayobe/kolla/config`` and
+    ``etc/kayobe/environments/<environment>/kolla/config`` should be used
+    instead.
diff --git a/requirements.txt b/requirements.txt
index 752bc6d20e226b3506d8703d651a25b892ede1e3..4ab09abd0890ee2fd14ed3d89b6e71a9e8395b9d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -9,4 +9,5 @@ selinux # MIT
 oslo.config>=5.2.0 # Apache-2.0
 paramiko # LGPL
 jsonschema<5 # MIT
+wcmatch>=8.2,<=9.0 # MIT
 hvac>=0.10.1
diff --git a/tools/test-molecule.sh b/tools/test-molecule.sh
index 41b9f8a770525602b492b11db0c54222048def14..89b4a24c4c24b2034cefabebd13eda27d5a25136 100755
--- a/tools/test-molecule.sh
+++ b/tools/test-molecule.sh
@@ -7,6 +7,12 @@ set -e
 
 molecules="$(find ansible/roles/ -name molecule -type d)"
 
+PARENT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# FIXME: doesn't get passed through to linter.
+export ANSIBLE_ACTION_PLUGINS="$PARENT/../kayobe/plugins/action:~/.ansible/plugins/action:/usr/share/ansible/plugins/action"
+export ANSIBLE_FORCE_COLOR=True
+
 failed=0
 ran=0
 for molecule in $molecules; do