Kayobe environment dependencies
Allows you to combine multiple environments by declaring any dependencies. Story: 2002009 Task: 42911 Change-Id: I4d9f96ec4cf3c6cd0d28dfe5ddb239d863498a72
This commit is contained in:
parent
a46b3ebc8e
commit
0ae9e8d489
@ -16,6 +16,10 @@ kayobe_environment: "{{ lookup('env', 'KAYOBE_ENVIRONMENT') }}"
|
||||
# environment path appended if kayobe_environment is set.
|
||||
kayobe_env_config_path: "{{ kayobe_config_path ~ ('/environments/' ~ kayobe_environment if kayobe_environment else '') }}"
|
||||
|
||||
# Ordered list of paths containing kayobe_env_config_path and all its dependent
|
||||
# environments.
|
||||
kayobe_env_search_paths: "{{ query('cached', 'kayobe_environments') }}"
|
||||
|
||||
###############################################################################
|
||||
# Remote path configuration (seed, seed-hypervisor and overcloud hosts).
|
||||
|
||||
|
@ -91,9 +91,9 @@
|
||||
kolla_external_fqdn_cert: "{{ kolla_config_path }}/certificates/haproxy.pem"
|
||||
kolla_internal_fqdn_cert: "{{ kolla_config_path }}/certificates/haproxy-internal.pem"
|
||||
kolla_ansible_passwords_path: "{{ kayobe_env_config_path }}/kolla/passwords.yml"
|
||||
kolla_overcloud_inventory_search_paths:
|
||||
kolla_overcloud_inventory_search_paths_static:
|
||||
- "{{ kayobe_config_path }}"
|
||||
- "{{ kayobe_env_config_path }}"
|
||||
kolla_overcloud_inventory_search_paths: "{{ kolla_overcloud_inventory_search_paths_static + kayobe_env_search_paths }}"
|
||||
kolla_ansible_certificates_path: "{{ kayobe_env_config_path }}/kolla/certificates"
|
||||
# NOTE: This differs from the default SELinux mode in kolla ansible,
|
||||
# which is permissive. The justification for using this mode is twofold:
|
||||
@ -109,9 +109,9 @@
|
||||
kolla_inspector_extra_kernel_options: "{{ inspector_extra_kernel_options }}"
|
||||
kolla_libvirt_tls: "{{ compute_libvirt_enable_tls | bool }}"
|
||||
kolla_enable_host_ntp: false
|
||||
kolla_globals_paths_extra:
|
||||
kolla_globals_paths_static:
|
||||
- "{{ kayobe_config_path }}"
|
||||
- "{{ kayobe_env_config_path }}"
|
||||
kolla_globals_paths_extra: "{{ kolla_globals_paths_static + kayobe_env_search_paths }}"
|
||||
|
||||
- name: Generate Kolla Ansible host vars for the seed host
|
||||
hosts: seed
|
||||
|
@ -14,6 +14,6 @@
|
||||
kolla_bifrost_dnsmasq_dns_servers: "{{ resolv_nameservers | default([]) }}"
|
||||
kolla_bifrost_domain: "{{ resolv_domain | default }}"
|
||||
kolla_bifrost_download_ipa: "{{ not ipa_build_images | bool }}"
|
||||
kolla_bifrost_config_paths_extra:
|
||||
kolla_bifrost_config_paths_static:
|
||||
- "{{ kayobe_config_path }}"
|
||||
- "{{ kayobe_env_config_path }}"
|
||||
kolla_bifrost_config_paths_extra: "{{ kolla_bifrost_config_paths_static + kayobe_env_search_paths }}"
|
||||
|
@ -7,7 +7,7 @@
|
||||
- role: kolla
|
||||
kolla_install_epel: "{{ dnf_install_epel }}"
|
||||
- role: kolla-build
|
||||
kolla_build_config_paths_extra:
|
||||
kolla_build_config_paths_static:
|
||||
- "{{ kayobe_config_path }}"
|
||||
- "{{ kayobe_env_config_path }}"
|
||||
kolla_build_config_paths_extra: "{{ kolla_build_config_paths_static + kayobe_env_search_paths }}"
|
||||
kolla_base_tag: "{{ kolla_base_distro_version }}"
|
||||
|
@ -170,9 +170,9 @@
|
||||
kolla_inspector_swift_auth:
|
||||
auth_type: none
|
||||
endpoint_override: "http://{% raw %}{{ api_interface_address }}{% endraw %}:{{ inspector_store_port }}"
|
||||
kolla_openstack_custom_config_paths_extra_multi_env:
|
||||
kolla_openstack_custom_config_paths_extra_multi_env_static:
|
||||
- "{{ kayobe_config_path }}"
|
||||
- "{{ kayobe_env_config_path }}"
|
||||
kolla_openstack_custom_config_paths_extra_multi_env: "{{ kolla_openstack_custom_config_paths_extra_multi_env_static + kayobe_env_search_paths }}"
|
||||
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 }}"
|
||||
|
19
ansible/lookup_plugins/kayobe_environments.py
Normal file
19
ansible/lookup_plugins/kayobe_environments.py
Normal file
@ -0,0 +1,19 @@
|
||||
# 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.
|
||||
|
||||
__metaclass__ = type
|
||||
|
||||
import kayobe.plugins.lookup.environments
|
||||
|
||||
LookupModule = kayobe.plugins.lookup.environments.LookupModule
|
@ -379,6 +379,63 @@ This would configure the external FQDN for the staging environment at
|
||||
``staging-api.example.com``, while the production external FQDN would be at
|
||||
``production-api.example.com``.
|
||||
|
||||
Environment Dependencies
|
||||
------------------------
|
||||
|
||||
.. warning::
|
||||
|
||||
This is an experimental feature and is still subject to change whilst
|
||||
the design is finalised.
|
||||
|
||||
Since the Antelope 14.0.0 release, multiple environments can be layered on top
|
||||
of each of each other by declaring dependencies in a ``.kayobe-environment``
|
||||
file located in the environment subdirectory. For example:
|
||||
|
||||
.. code-block:: yaml
|
||||
:caption: ``$KAYOBE_CONFIG_PATH/environments/environment-C/.kayobe-environment``
|
||||
|
||||
dependencies:
|
||||
- environment-B
|
||||
|
||||
.. code-block:: yaml
|
||||
:caption: ``$KAYOBE_CONFIG_PATH/environments/environment-B/.kayobe-environment``
|
||||
|
||||
dependencies:
|
||||
- environment-A
|
||||
|
||||
Kayobe uses a dependency resolver to order these environments into a linear
|
||||
chain. Any dependency cycles in will result in an error. Using the example
|
||||
above the chain would be resolved to:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
C -> B -> A
|
||||
|
||||
Where C is the environment with highest precedence. Kayobe will make sure to
|
||||
include the inventory and extra-vars in an order matching this chain when
|
||||
running any playbooks.
|
||||
|
||||
Mixin environments
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Environment dependencies can be used to design fragments of re-useable
|
||||
configuration that can be shared across multiple environments. For example:
|
||||
|
||||
.. code-block:: yaml
|
||||
:caption: ``$KAYOBE_CONFIG_PATH/environments/environment-A/.kayobe-environment``
|
||||
|
||||
dependencies:
|
||||
- environment-mixin-1
|
||||
- environment-mixin-2
|
||||
- environment-mixin-3
|
||||
|
||||
In this case, each environment dependency could provide the configuration
|
||||
necessary for one or more features. The mixin environments do not necessarily
|
||||
need to define any dependencies between them, however Kayobe will perform a
|
||||
topological sort to determine a suitable precedence. Care should be taken to
|
||||
make sure that environments without an explicit ordering do not modify the same
|
||||
variables.
|
||||
|
||||
Final Considerations
|
||||
--------------------
|
||||
|
||||
|
@ -84,34 +84,26 @@ def add_args(parser):
|
||||
"specific playbooks. \"all\" skips all playbooks")
|
||||
|
||||
|
||||
def _get_kayobe_environment_path(parsed_args):
|
||||
"""Return the path to the Kayobe environment or None if not specified."""
|
||||
env_path = None
|
||||
if parsed_args.environment:
|
||||
# Specified via --environment or KAYOBE_ENVIRONMENT.
|
||||
kc_environments = os.path.join(parsed_args.config_path, "environments")
|
||||
env_path = os.path.join(kc_environments, parsed_args.environment)
|
||||
return env_path
|
||||
|
||||
|
||||
def _get_inventories_paths(parsed_args, env_path):
|
||||
def _get_inventories_paths(parsed_args, env_paths):
|
||||
"""Return the paths to the Kayobe inventories."""
|
||||
default_inventory = utils.get_data_files_path("ansible", "inventory")
|
||||
inventories = [default_inventory]
|
||||
if parsed_args.inventory:
|
||||
inventories.extend(parsed_args.inventory)
|
||||
else:
|
||||
shared_inventory = os.path.join(parsed_args.config_path, "inventory")
|
||||
if env_path:
|
||||
if os.path.exists(shared_inventory):
|
||||
inventories.append(shared_inventory)
|
||||
env_inventory = os.path.join(env_path, "inventory")
|
||||
if os.path.exists(env_inventory):
|
||||
inventories.append(env_inventory)
|
||||
else:
|
||||
# Preserve existing behaviour: don't check if an inventory
|
||||
# directory exists when no environment is specified
|
||||
return inventories
|
||||
|
||||
shared_inventory = os.path.join(parsed_args.config_path, "inventory")
|
||||
if env_paths:
|
||||
if os.path.exists(shared_inventory):
|
||||
inventories.append(shared_inventory)
|
||||
else:
|
||||
# Preserve existing behaviour: don't check if an inventory
|
||||
# directory exists when no environment is specified
|
||||
inventories.append(shared_inventory)
|
||||
for env_path in env_paths or []:
|
||||
env_inventory = os.path.join(env_path, "inventory")
|
||||
if os.path.exists(env_inventory):
|
||||
inventories.append(env_inventory)
|
||||
return inventories
|
||||
|
||||
|
||||
@ -129,15 +121,18 @@ def _validate_args(parsed_args, playbooks):
|
||||
"use.")
|
||||
sys.exit(1)
|
||||
|
||||
env_path = _get_kayobe_environment_path(parsed_args)
|
||||
if env_path:
|
||||
result = utils.is_readable_dir(env_path)
|
||||
if not result["result"]:
|
||||
LOG.error("Kayobe environment %s is invalid: %s",
|
||||
env_path, result["message"])
|
||||
sys.exit(1)
|
||||
environment_finder = utils.EnvironmentFinder(
|
||||
parsed_args.config_path, parsed_args.environment)
|
||||
env_paths = environment_finder.ordered_paths()
|
||||
for env_path in env_paths:
|
||||
if env_path:
|
||||
result = utils.is_readable_dir(env_path)
|
||||
if not result["result"]:
|
||||
LOG.error("Kayobe environment %s is invalid: %s",
|
||||
env_path, result["message"])
|
||||
sys.exit(1)
|
||||
|
||||
inventories = _get_inventories_paths(parsed_args, env_path)
|
||||
inventories = _get_inventories_paths(parsed_args, env_paths)
|
||||
for inventory in inventories:
|
||||
result = utils.is_readable_dir(inventory)
|
||||
if not result["result"]:
|
||||
@ -184,12 +179,14 @@ def build_args(parsed_args, playbooks,
|
||||
if list_tasks or (parsed_args.list_tasks and list_tasks is None):
|
||||
cmd += ["--list-tasks"]
|
||||
cmd += vault.build_args(parsed_args, "--vault-password-file")
|
||||
env_path = _get_kayobe_environment_path(parsed_args)
|
||||
inventories = _get_inventories_paths(parsed_args, env_path)
|
||||
environment_finder = utils.EnvironmentFinder(
|
||||
parsed_args.config_path, parsed_args.environment)
|
||||
env_paths = environment_finder.ordered_paths()
|
||||
inventories = _get_inventories_paths(parsed_args, env_paths)
|
||||
for inventory in inventories:
|
||||
cmd += ["--inventory", inventory]
|
||||
vars_paths = [parsed_args.config_path]
|
||||
if env_path:
|
||||
for env_path in env_paths:
|
||||
vars_paths.append(env_path)
|
||||
vars_files = _get_vars_files(vars_paths)
|
||||
for vars_file in vars_files:
|
||||
@ -438,7 +435,8 @@ def prune_galaxy_roles(parsed_args):
|
||||
|
||||
def passwords_yml_exists(parsed_args):
|
||||
"""Return whether passwords.yml exists in the kayobe configuration."""
|
||||
env_path = _get_kayobe_environment_path(parsed_args)
|
||||
env_path = utils.get_kayobe_environment_path(
|
||||
parsed_args.config_path, parsed_args.environment)
|
||||
path = env_path if env_path else parsed_args.config_path
|
||||
passwords_path = os.path.join(path, 'kolla', 'passwords.yml')
|
||||
return utils.is_readable_file(passwords_path)["result"]
|
||||
|
@ -78,22 +78,23 @@ def _get_inventory_paths(parsed_args, inventory_filename):
|
||||
else:
|
||||
paths = [os.path.join(parsed_args.kolla_config_path, "inventory",
|
||||
inventory_filename)]
|
||||
|
||||
def append_path(directory):
|
||||
candidate_path = os.path.join(
|
||||
parsed_args.kolla_config_path, "extra-inventories",
|
||||
directory)
|
||||
if utils.is_readable_dir(candidate_path)["result"]:
|
||||
paths.append(candidate_path)
|
||||
|
||||
# Inventory in the base layer is placed in the "kayobe"
|
||||
# directory. This means that you can't have an environment
|
||||
# called kayobe as it would conflict.
|
||||
append_path("kayobe")
|
||||
|
||||
environments = ["kayobe"]
|
||||
if parsed_args.environment:
|
||||
append_path(parsed_args.environment)
|
||||
|
||||
environments.append(parsed_args.environment)
|
||||
else:
|
||||
environment_finder = utils.EnvironmentFinder(
|
||||
parsed_args.config_path, parsed_args.environment)
|
||||
for environment in environment_finder.ordered():
|
||||
environments.append(environment)
|
||||
for environment in environments:
|
||||
candidate_path = os.path.join(
|
||||
parsed_args.kolla_config_path, "extra-inventories",
|
||||
environment)
|
||||
if utils.is_readable_dir(candidate_path)["result"]:
|
||||
paths.append(candidate_path)
|
||||
return paths
|
||||
|
||||
|
||||
|
49
kayobe/plugins/lookup/environments.py
Normal file
49
kayobe/plugins/lookup/environments.py
Normal file
@ -0,0 +1,49 @@
|
||||
# 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.errors import AnsibleError
|
||||
from ansible.plugins.loader import lookup_loader
|
||||
from ansible.plugins.lookup import LookupBase
|
||||
|
||||
from kayobe.utils import EnvironmentFinder
|
||||
|
||||
__version__ = "1.0.0"
|
||||
|
||||
try:
|
||||
from __main__ import display
|
||||
except ImportError:
|
||||
from ansible.utils.display import Display
|
||||
|
||||
display = Display()
|
||||
|
||||
|
||||
class LookupModule(LookupBase):
|
||||
def run(self, terms, variables=None, **kwargs):
|
||||
lookup = lookup_loader.get(
|
||||
'vars', loader=self._loader, templar=self._templar
|
||||
)
|
||||
# Values in variables are untemplated, e.g:
|
||||
# {{ lookup('env', 'KAYOBE_CONFIG_PATH') | default('/etc/kayobe', true) }} # noqa
|
||||
environment = lookup.run(
|
||||
["kayobe_environment"],
|
||||
variables=variables, default='')[0]
|
||||
kayobe_config_path = lookup.run(
|
||||
["kayobe_config_path"],
|
||||
variables=variables, default='')[0]
|
||||
if not environment:
|
||||
return []
|
||||
if not kayobe_config_path:
|
||||
raise AnsibleError("kayobe_config_path is unset")
|
||||
environment_finder = EnvironmentFinder(kayobe_config_path, environment)
|
||||
return environment_finder.ordered_paths()
|
@ -877,11 +877,14 @@ class TestCase(unittest.TestCase):
|
||||
def test_multiple_inventories(self, mock_validate, mock_vars, mock_run,
|
||||
mock_exists):
|
||||
mock_vars.return_value = []
|
||||
# os.path.exists gets called three times:
|
||||
# 1) shared inventory
|
||||
# 2) environment inventory
|
||||
# 3) ansible.cfg
|
||||
mock_exists.side_effect = [True, True, False]
|
||||
|
||||
def exists_replacement(path):
|
||||
if path == "/etc/kayobe/inventory":
|
||||
return True
|
||||
if path == "/etc/kayobe/environments/test-env/inventory":
|
||||
return True
|
||||
return False
|
||||
mock_exists.side_effect = exists_replacement
|
||||
parser = argparse.ArgumentParser()
|
||||
ansible.add_args(parser)
|
||||
vault.add_args(parser)
|
||||
@ -907,12 +910,6 @@ class TestCase(unittest.TestCase):
|
||||
"ANSIBLE_FILTER_PLUGINS": mock.ANY,
|
||||
"ANSIBLE_TEST_PLUGINS": mock.ANY,
|
||||
}
|
||||
expected_calls = [
|
||||
mock.call("/etc/kayobe/inventory"),
|
||||
mock.call("/etc/kayobe/environments/test-env/inventory"),
|
||||
mock.call("/etc/kayobe/ansible.cfg"),
|
||||
]
|
||||
self.assertListEqual(expected_calls, mock_exists.mock_calls)
|
||||
mock_run.assert_called_once_with(expected_cmd, check_output=False,
|
||||
quiet=False, env=expected_env)
|
||||
mock_vars.assert_called_once_with(
|
||||
@ -925,11 +922,12 @@ class TestCase(unittest.TestCase):
|
||||
def test_shared_inventory_only(self, mock_validate, mock_vars, mock_run,
|
||||
mock_exists):
|
||||
mock_vars.return_value = []
|
||||
# os.path.exists gets called three times:
|
||||
# 1) shared inventory
|
||||
# 2) environment inventory
|
||||
# 3) ansible.cfg
|
||||
mock_exists.side_effect = [True, False, False]
|
||||
|
||||
def exists_replacement(path):
|
||||
if path == "/etc/kayobe/inventory":
|
||||
return True
|
||||
return False
|
||||
mock_exists.side_effect = exists_replacement
|
||||
parser = argparse.ArgumentParser()
|
||||
ansible.add_args(parser)
|
||||
vault.add_args(parser)
|
||||
@ -954,12 +952,6 @@ class TestCase(unittest.TestCase):
|
||||
"ANSIBLE_FILTER_PLUGINS": mock.ANY,
|
||||
"ANSIBLE_TEST_PLUGINS": mock.ANY,
|
||||
}
|
||||
expected_calls = [
|
||||
mock.call("/etc/kayobe/inventory"),
|
||||
mock.call("/etc/kayobe/environments/test-env/inventory"),
|
||||
mock.call("/etc/kayobe/ansible.cfg"),
|
||||
]
|
||||
self.assertListEqual(expected_calls, mock_exists.mock_calls)
|
||||
mock_run.assert_called_once_with(expected_cmd, check_output=False,
|
||||
quiet=False, env=expected_env)
|
||||
mock_vars.assert_called_once_with(
|
||||
@ -972,11 +964,13 @@ class TestCase(unittest.TestCase):
|
||||
def test_env_inventory_only(self, mock_validate, mock_vars, mock_run,
|
||||
mock_exists):
|
||||
mock_vars.return_value = []
|
||||
# os.path.exists gets called three times:
|
||||
# 1) shared inventory
|
||||
# 2) environment inventory
|
||||
# 3) ansible.cfg
|
||||
mock_exists.side_effect = [False, True, False]
|
||||
# We only want it to find the inventory in the environment
|
||||
|
||||
def exists_replacement(path):
|
||||
if path == "/etc/kayobe/environments/test-env/inventory":
|
||||
return True
|
||||
return False
|
||||
mock_exists.side_effect = exists_replacement
|
||||
parser = argparse.ArgumentParser()
|
||||
ansible.add_args(parser)
|
||||
vault.add_args(parser)
|
||||
@ -1001,13 +995,138 @@ class TestCase(unittest.TestCase):
|
||||
"ANSIBLE_FILTER_PLUGINS": mock.ANY,
|
||||
"ANSIBLE_TEST_PLUGINS": mock.ANY,
|
||||
}
|
||||
expected_calls = [
|
||||
mock.call("/etc/kayobe/inventory"),
|
||||
mock.call("/etc/kayobe/environments/test-env/inventory"),
|
||||
mock.call("/etc/kayobe/ansible.cfg"),
|
||||
]
|
||||
self.assertListEqual(expected_calls, mock_exists.mock_calls)
|
||||
mock_run.assert_called_once_with(expected_cmd, check_output=False,
|
||||
quiet=False, env=expected_env)
|
||||
mock_vars.assert_called_once_with(
|
||||
["/etc/kayobe", "/etc/kayobe/environments/test-env"])
|
||||
|
||||
@mock.patch.object(utils.EnvironmentFinder, "ordered")
|
||||
@mock.patch.object(os.path, "exists")
|
||||
@mock.patch.object(utils, "run_command")
|
||||
@mock.patch.object(ansible, "_get_vars_files")
|
||||
@mock.patch.object(ansible, "_validate_args")
|
||||
def test_multi_env_inventory_only(self, mock_validate, mock_vars,
|
||||
mock_run, mock_exists, mock_finder):
|
||||
mock_vars.return_value = []
|
||||
mock_finder.return_value = ["dependency-env", "test-env"]
|
||||
|
||||
def exists_replacement(path):
|
||||
if path == "/etc/kayobe/environments/test-env/inventory":
|
||||
return True
|
||||
if path == "/etc/kayobe/environments/dependency-env/inventory":
|
||||
return True
|
||||
return False
|
||||
mock_exists.side_effect = exists_replacement
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
ansible.add_args(parser)
|
||||
vault.add_args(parser)
|
||||
args = [
|
||||
"--environment", "test-env",
|
||||
]
|
||||
parsed_args = parser.parse_args(args)
|
||||
ansible.run_playbooks(parsed_args, ["playbook1.yml", "playbook2.yml"])
|
||||
expected_cmd = [
|
||||
"ansible-playbook",
|
||||
"--inventory", utils.get_data_files_path("ansible", "inventory"),
|
||||
"--inventory", "/etc/kayobe/environments/dependency-env/inventory",
|
||||
"--inventory", "/etc/kayobe/environments/test-env/inventory",
|
||||
"playbook1.yml",
|
||||
"playbook2.yml",
|
||||
]
|
||||
expected_env = {
|
||||
"KAYOBE_CONFIG_PATH": "/etc/kayobe",
|
||||
"KAYOBE_ENVIRONMENT": "test-env",
|
||||
"ANSIBLE_ROLES_PATH": mock.ANY,
|
||||
"ANSIBLE_COLLECTIONS_PATH": mock.ANY,
|
||||
"ANSIBLE_ACTION_PLUGINS": mock.ANY,
|
||||
"ANSIBLE_FILTER_PLUGINS": mock.ANY,
|
||||
"ANSIBLE_TEST_PLUGINS": mock.ANY,
|
||||
}
|
||||
mock_run.assert_called_once_with(expected_cmd, check_output=False,
|
||||
quiet=False, env=expected_env)
|
||||
mock_vars.assert_called_once_with(
|
||||
["/etc/kayobe",
|
||||
"/etc/kayobe/environments/dependency-env",
|
||||
"/etc/kayobe/environments/test-env"]
|
||||
)
|
||||
|
||||
@mock.patch.object(utils.EnvironmentFinder, "ordered")
|
||||
@mock.patch.object(os.path, "exists")
|
||||
@mock.patch.object(utils, "run_command")
|
||||
@mock.patch.object(ansible, "_get_vars_files")
|
||||
@mock.patch.object(ansible, "_validate_args")
|
||||
def test_multi_env_vars(self, mock_validate, mock_vars,
|
||||
mock_run, mock_exists, mock_finder):
|
||||
|
||||
def get_vars_replacement(paths):
|
||||
result = []
|
||||
for path in paths:
|
||||
if path == "/etc/kayobe/environments/test-env":
|
||||
result.extend(
|
||||
["vars-test-env-1.yml", "vars-test-env-2.yml"]
|
||||
)
|
||||
continue
|
||||
if path == "/etc/kayobe/environments/dependency-env":
|
||||
result.extend(
|
||||
["vars-dependency-env-1.yml",
|
||||
"vars-dependency-env-2.yml"]
|
||||
)
|
||||
continue
|
||||
if path == "/etc/kayobe":
|
||||
result.extend(
|
||||
["vars-1.yml", "vars-2.yml"]
|
||||
)
|
||||
continue
|
||||
return result
|
||||
mock_vars.side_effect = get_vars_replacement
|
||||
|
||||
mock_finder.return_value = ["dependency-env", "test-env"]
|
||||
|
||||
def exists_replacement(path):
|
||||
if path == "/etc/kayobe/environments/test-env/inventory":
|
||||
return True
|
||||
if path == "/etc/kayobe/environments/dependency-env/inventory":
|
||||
return True
|
||||
return False
|
||||
|
||||
mock_exists.side_effect = exists_replacement
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
ansible.add_args(parser)
|
||||
vault.add_args(parser)
|
||||
args = [
|
||||
"--environment", "test-env",
|
||||
]
|
||||
parsed_args = parser.parse_args(args)
|
||||
ansible.run_playbooks(parsed_args, ["playbook1.yml", "playbook2.yml"])
|
||||
expected_cmd = [
|
||||
"ansible-playbook",
|
||||
"--inventory", utils.get_data_files_path("ansible", "inventory"),
|
||||
"--inventory", "/etc/kayobe/environments/dependency-env/inventory",
|
||||
"--inventory", "/etc/kayobe/environments/test-env/inventory",
|
||||
'-e', '@vars-1.yml',
|
||||
'-e', '@vars-2.yml',
|
||||
'-e', '@vars-dependency-env-1.yml',
|
||||
'-e', '@vars-dependency-env-2.yml',
|
||||
'-e', '@vars-test-env-1.yml',
|
||||
'-e', '@vars-test-env-2.yml',
|
||||
"playbook1.yml",
|
||||
"playbook2.yml",
|
||||
]
|
||||
expected_env = {
|
||||
"KAYOBE_CONFIG_PATH": "/etc/kayobe",
|
||||
"KAYOBE_ENVIRONMENT": "test-env",
|
||||
"ANSIBLE_ROLES_PATH": mock.ANY,
|
||||
"ANSIBLE_COLLECTIONS_PATH": mock.ANY,
|
||||
"ANSIBLE_ACTION_PLUGINS": mock.ANY,
|
||||
"ANSIBLE_FILTER_PLUGINS": mock.ANY,
|
||||
"ANSIBLE_TEST_PLUGINS": mock.ANY,
|
||||
}
|
||||
mock_run.assert_called_once_with(expected_cmd, check_output=False,
|
||||
quiet=False, env=expected_env)
|
||||
mock_vars.assert_called_once_with(
|
||||
["/etc/kayobe",
|
||||
"/etc/kayobe/environments/dependency-env",
|
||||
"/etc/kayobe/environments/test-env"]
|
||||
)
|
||||
|
@ -223,3 +223,56 @@ key2: value2
|
||||
def test_intersect_limits_arg_and_cli_colon(self):
|
||||
result = utils.intersect_limits("foo:bar", "baz")
|
||||
self.assertEqual("foo:bar:&baz", result)
|
||||
|
||||
def test_environment_finder_with_single_environment(self):
|
||||
finder = utils.EnvironmentFinder('/etc/kayobe', 'environment-A')
|
||||
environments = finder.ordered()
|
||||
expected = ["environment-A"]
|
||||
self.assertEqual(expected, environments)
|
||||
|
||||
expected = ["/etc/kayobe/environments/environment-A"]
|
||||
paths = finder.ordered_paths()
|
||||
self.assertEqual(expected, paths)
|
||||
|
||||
@mock.patch.object(utils.EnvironmentFinder, "_read_metadata")
|
||||
def test_environment_finder_with_dependency_chain(self, mock_yaml):
|
||||
def yaml_replacement(path):
|
||||
if path == ("/etc/kayobe/environments/environment-C/"
|
||||
".kayobe-environment"):
|
||||
return {"dependencies": ["environment-A", "environment-B"]}
|
||||
if path == ("/etc/kayobe/environments/environment-B/"
|
||||
".kayobe-environment"):
|
||||
return {"dependencies": ["environment-A"]}
|
||||
return {}
|
||||
mock_yaml.side_effect = yaml_replacement
|
||||
finder = utils.EnvironmentFinder('/etc/kayobe', 'environment-C')
|
||||
result = finder.ordered()
|
||||
expected = ["environment-A", "environment-B", "environment-C"]
|
||||
self.assertEqual(expected, result)
|
||||
|
||||
expected = ["/etc/kayobe/environments/environment-A",
|
||||
"/etc/kayobe/environments/environment-B",
|
||||
"/etc/kayobe/environments/environment-C"]
|
||||
paths = finder.ordered_paths()
|
||||
self.assertEqual(expected, paths)
|
||||
|
||||
@mock.patch.object(utils.EnvironmentFinder, "_read_metadata")
|
||||
def test_environment_finder_with_cycle(self, mock_yaml):
|
||||
# The cycle is: C - B - C
|
||||
def yaml_replacement(path):
|
||||
if path == ("/etc/kayobe/environments/environment-C/"
|
||||
".kayobe-environment"):
|
||||
return {"dependencies": ["environment-A", "environment-B"]}
|
||||
if path == ("/etc/kayobe/environments/environment-B/"
|
||||
".kayobe-environment"):
|
||||
return {"dependencies": ["environment-A", "environment-C"]}
|
||||
return {}
|
||||
mock_yaml.side_effect = yaml_replacement
|
||||
finder = utils.EnvironmentFinder('/etc/kayobe', 'environment-C')
|
||||
self.assertRaises(exception.Error, finder.ordered)
|
||||
self.assertRaises(exception.Error, finder.ordered_paths)
|
||||
|
||||
def test_environment_finder_no_environment(self):
|
||||
finder = utils.EnvironmentFinder('/etc/kayobe', None)
|
||||
self.assertEqual([], finder.ordered())
|
||||
self.assertEqual([], finder.ordered_paths())
|
||||
|
@ -13,7 +13,9 @@
|
||||
# under the License.
|
||||
|
||||
import base64
|
||||
from collections import defaultdict
|
||||
import glob
|
||||
import graphlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
@ -265,3 +267,99 @@ def copy_dir(src, dest, exclude=None):
|
||||
copy_dir(src_path, dest_path)
|
||||
else:
|
||||
shutil.copy2(src_path, dest_path)
|
||||
|
||||
|
||||
def get_kayobe_environment_path(base_path, environment):
|
||||
"""Return the path to the Kayobe environment or None if not specified."""
|
||||
env_path = None
|
||||
if environment:
|
||||
# Specified via --environment or KAYOBE_ENVIRONMENT.
|
||||
kc_environments = os.path.join(base_path, "environments")
|
||||
env_path = os.path.join(kc_environments, environment)
|
||||
return env_path
|
||||
|
||||
|
||||
class EnvironmentFinder(object):
|
||||
"""Dependency resolver for kayobe environments
|
||||
|
||||
The constraints are specified via a .kayobe-environment file.
|
||||
"""
|
||||
|
||||
def __new__(cls, base_path, environment):
|
||||
# Singleton instance so we don't have to resolve dependencies multiple
|
||||
# times or pass round a single instance.
|
||||
it = cls.__dict__.get("__it__")
|
||||
if it is None:
|
||||
it = {}
|
||||
if (base_path, environment) in it:
|
||||
return it[(base_path, environment)]
|
||||
singleton = object.__new__(cls)
|
||||
singleton._init(base_path, environment)
|
||||
it[(base_path, environment)] = singleton
|
||||
return singleton
|
||||
|
||||
def _init(self, base_path, environment):
|
||||
self._base_path = base_path
|
||||
self._environment = environment
|
||||
self._ordering = None
|
||||
|
||||
@staticmethod
|
||||
def _read_metadata(path):
|
||||
if os.path.exists(path) and os.path.isfile(path):
|
||||
metadata = read_yaml_file(path)
|
||||
return metadata
|
||||
return {}
|
||||
|
||||
def _collect(self, environment, result, visited):
|
||||
# Updates result to contain dependency graph
|
||||
base = self._base_path
|
||||
env_path = os.path.join(base, 'environments', environment)
|
||||
dot_environment_path = os.path.join(env_path, '.kayobe-environment')
|
||||
if dot_environment_path in visited:
|
||||
return
|
||||
visited.add(dot_environment_path)
|
||||
metadata = EnvironmentFinder._read_metadata(dot_environment_path)
|
||||
dependencies = metadata.get("dependencies", [])
|
||||
if not isinstance(dependencies, list):
|
||||
raise exception.Error(".kayobe-environment: dependencies field "
|
||||
"should be a list")
|
||||
result[environment] |= set(dependencies)
|
||||
for dependency in dependencies:
|
||||
if not isinstance(dependency, str):
|
||||
raise exception.Error("Kayobe environment dependency items "
|
||||
"should be strings")
|
||||
self._collect(dependency, result, visited)
|
||||
|
||||
def ordered(self):
|
||||
"""List of environments ordered by the constraints"""
|
||||
environment = self._environment
|
||||
if not environment:
|
||||
return []
|
||||
if self._ordering is not None:
|
||||
return self._ordering.copy()
|
||||
graph = defaultdict(set)
|
||||
self._collect(environment, graph, set())
|
||||
ts = graphlib.TopologicalSorter(graph)
|
||||
try:
|
||||
ordering = list(ts.static_order())
|
||||
except graphlib.CycleError as e:
|
||||
# https://docs.python.org/3/library/graphlib.html#graphlib.CycleError
|
||||
cycle = e.args[1]
|
||||
raise exception.Error("You have created a cycle with your "
|
||||
"environment dependencies. Please break "
|
||||
"this cycle and try again. The cycle is: %s"
|
||||
% cycle)
|
||||
self._ordering = ordering if ordering else [environment]
|
||||
return self._ordering.copy()
|
||||
|
||||
def ordered_paths(self):
|
||||
"""Paths to each environment ordered by the constraints"""
|
||||
result = []
|
||||
environments = self.ordered()
|
||||
for environment in environments:
|
||||
full_path = get_kayobe_environment_path(
|
||||
self._base_path,
|
||||
environment
|
||||
)
|
||||
result.append(full_path)
|
||||
return result
|
||||
|
@ -0,0 +1,5 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
Adds a experimental support for layering multiple environments using a
|
||||
.kayobe-environment file.
|
@ -12,3 +12,5 @@ jsonschema<5 # MIT
|
||||
wcmatch>=8.2,<=9.0 # MIT
|
||||
hvac>=0.10.1
|
||||
ansible-cached-lookup<=2.0.0 # MIT
|
||||
# NOTE(wszusmki): Remove this when min python>=3.9
|
||||
graphlib-backport<2.0.0; python_version<"3.9" # PSF
|
||||
|
Loading…
Reference in New Issue
Block a user