kayobe/kayobe/ansible.py

457 lines
19 KiB
Python

# Copyright (c) 2017 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.
import errno
import logging
import os
import os.path
import shutil
import subprocess
import sys
import tempfile
import ansible.constants
from ansible.parsing.yaml.objects import AnsibleVaultEncryptedUnicode
from kayobe import exception
from kayobe import utils
from kayobe import vault
DEFAULT_CONFIG_PATH = "/etc/kayobe"
CONFIG_PATH_ENV = "KAYOBE_CONFIG_PATH"
ENVIRONMENT_ENV = "KAYOBE_ENVIRONMENT"
LOG = logging.getLogger(__name__)
def add_args(parser):
"""Add arguments required for running Ansible playbooks to a parser."""
default_config_path = os.getenv(CONFIG_PATH_ENV, DEFAULT_CONFIG_PATH)
default_environment = os.getenv(ENVIRONMENT_ENV)
parser.add_argument("-b", "--become", action="store_true",
help="run operations with become (nopasswd implied)")
parser.add_argument("-C", "--check", action="store_true",
help="don't make any changes; instead, try to predict "
"some of the changes that may occur")
parser.add_argument("--config-path", default=default_config_path,
help="path to Kayobe configuration. "
"(default=$%s or %s)" %
(CONFIG_PATH_ENV, DEFAULT_CONFIG_PATH))
parser.add_argument("-D", "--diff", action="store_true",
help="when changing (small) files and templates, show "
"the differences in those files; works great "
"with --check")
parser.add_argument("--environment", default=default_environment,
help="specify environment name (default=$%s or None)" %
ENVIRONMENT_ENV)
parser.add_argument("-e", "--extra-vars", metavar="EXTRA_VARS",
action="append",
help="set additional variables as key=value or "
"YAML/JSON")
parser.add_argument("-i", "--inventory", metavar="INVENTORY",
action="append",
help="specify inventory host path "
"(default=$%s/inventory or %s/inventory) " %
(CONFIG_PATH_ENV, DEFAULT_CONFIG_PATH))
parser.add_argument("-l", "--limit", metavar="SUBSET",
help="further limit selected hosts to an additional "
"pattern")
parser.add_argument("--skip-tags", metavar="TAGS",
help="only run plays and tasks whose tags do not "
"match these values")
parser.add_argument("-t", "--tags", metavar="TAGS",
help="only run plays and tasks tagged with these "
"values")
parser.add_argument("-lt", "--list-tasks",
action="store_true",
help="only print names of tasks, don't run them, "
"note this has no affect on kolla-ansible.")
parser.add_argument("-sh", "--skip-hooks", action="store", default=None,
help="disables hooks. Specify a pattern to skip"
"specific playbooks. \"all\" skips all playbooks")
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)
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
def _validate_args(parsed_args, playbooks):
"""Validate Kayobe Ansible arguments."""
vault.enforce_single_password_source(parsed_args)
result = utils.is_readable_dir(parsed_args.config_path)
if not result["result"]:
LOG.error("Kayobe configuration path %s is invalid: %s",
parsed_args.config_path, result["message"])
sys.exit(1)
if parsed_args.environment and parsed_args.environment == "kayobe":
LOG.error("The environment name 'kayobe' is reserved for internal "
"use.")
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_paths)
for inventory in inventories:
result = utils.is_readable_dir(inventory)
if not result["result"]:
LOG.error("Kayobe inventory %s is invalid: %s",
inventory, result["message"])
sys.exit(1)
for playbook in playbooks:
result = utils.is_readable_file(playbook)
if not result["result"]:
LOG.error("Kayobe playbook %s is invalid: %s",
playbook, result["message"])
sys.exit(1)
def _get_vars_files(vars_paths):
"""Return a list of Kayobe Ansible configuration variable files.
The list of directories given as argument is searched to create the list of
variable files. The files will be sorted alphabetically by name for each
directory, but ordering of directories is kept to allow overrides.
"""
vars_files = []
for vars_path in vars_paths:
path_vars_files = []
for vars_file in os.listdir(vars_path):
abs_path = os.path.join(vars_path, vars_file)
if utils.is_readable_file(abs_path)["result"]:
root, ext = os.path.splitext(vars_file)
if ext in (".yml", ".yaml", ".json"):
path_vars_files.append(abs_path)
vars_files += sorted(path_vars_files)
return vars_files
def build_args(parsed_args, playbooks,
extra_vars=None, limit=None, tags=None, verbose_level=None,
check=None, ignore_limit=False, list_tasks=None, diff=None):
"""Build arguments required for running Ansible playbooks."""
cmd = ["ansible-playbook"]
if verbose_level:
cmd += ["-" + "v" * verbose_level]
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")
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]
for env_path in env_paths:
vars_paths.append(env_path)
vars_files = _get_vars_files(vars_paths)
for vars_file in vars_files:
cmd += ["-e", "@%s" % vars_file]
if parsed_args.extra_vars:
for extra_var in parsed_args.extra_vars:
# Don't quote or escape variables passed via the kayobe -e CLI
# argument, to match Ansible's behaviour.
cmd += ["-e", extra_var]
if extra_vars:
for extra_var_name, extra_var_value in extra_vars.items():
# Quote and escape variables originating within the python CLI.
extra_var_value = utils.quote_and_escape(extra_var_value)
cmd += ["-e", "%s=%s" % (extra_var_name, extra_var_value)]
if parsed_args.become:
cmd += ["--become"]
if check or (parsed_args.check and check is None):
cmd += ["--check"]
if diff or (parsed_args.diff and diff is None):
cmd += ["--diff"]
if not ignore_limit and (parsed_args.limit or limit):
limit_arg = utils.intersect_limits(parsed_args.limit, limit)
cmd += ["--limit", limit_arg]
if parsed_args.skip_tags:
cmd += ["--skip-tags", parsed_args.skip_tags]
if parsed_args.tags or tags:
all_tags = [t for t in [parsed_args.tags, tags] if t]
cmd += ["--tags", ",".join(all_tags)]
cmd += playbooks
return cmd
def _get_environment(parsed_args):
"""Return an environment dict for executing an Ansible playbook."""
env = os.environ.copy()
vault.update_environment(parsed_args, env)
# If the configuration path has been specified via --config-path, ensure
# the environment variable is set, so that it can be referenced by
# playbooks.
env.setdefault(CONFIG_PATH_ENV, parsed_args.config_path)
# If an environment has been specified via --environment, ensure the
# environment variable is set, so that it can be referenced by playbooks.
if parsed_args.environment:
env.setdefault(ENVIRONMENT_ENV, parsed_args.environment)
# If a custom Ansible configuration file exists, use it.
ansible_cfg_path = os.path.join(parsed_args.config_path, "ansible.cfg")
if utils.is_readable_file(ansible_cfg_path)["result"]:
env.setdefault("ANSIBLE_CONFIG", ansible_cfg_path)
# Update various role, collection and plugin paths to include the Kayobe
# roles, collections and plugins. This allows custom playbooks to use these
# resources.
roles_paths = [
os.path.join(parsed_args.config_path, "ansible", "roles"),
utils.get_data_files_path("ansible", "roles"),
] + ansible.constants.DEFAULT_ROLES_PATH
env.setdefault("ANSIBLE_ROLES_PATH", ":".join(roles_paths))
collections_paths = [
os.path.join(parsed_args.config_path, "ansible", "collections"),
utils.get_data_files_path("ansible", "collections"),
] + ansible.constants.COLLECTIONS_PATHS
env.setdefault("ANSIBLE_COLLECTIONS_PATH", ":".join(collections_paths))
action_plugins = [
os.path.join(parsed_args.config_path, "ansible", "action_plugins"),
utils.get_data_files_path("ansible", "action_plugins"),
] + ansible.constants.DEFAULT_ACTION_PLUGIN_PATH
env.setdefault("ANSIBLE_ACTION_PLUGINS", ":".join(action_plugins))
filter_plugins = [
os.path.join(parsed_args.config_path, "ansible", "filter_plugins"),
utils.get_data_files_path("ansible", "filter_plugins"),
] + ansible.constants.DEFAULT_FILTER_PLUGIN_PATH
env.setdefault("ANSIBLE_FILTER_PLUGINS", ":".join(filter_plugins))
test_plugins = [
os.path.join(parsed_args.config_path, "ansible", "test_plugins"),
utils.get_data_files_path("ansible", "test_plugins"),
] + ansible.constants.DEFAULT_TEST_PLUGIN_PATH
env.setdefault("ANSIBLE_TEST_PLUGINS", ":".join(test_plugins))
return env
def run_playbooks(parsed_args, playbooks,
extra_vars=None, limit=None, tags=None, quiet=False,
check_output=False, verbose_level=None, check=None,
ignore_limit=False, list_tasks=None, diff=None):
"""Run a Kayobe Ansible playbook."""
_validate_args(parsed_args, playbooks)
cmd = build_args(parsed_args, playbooks,
extra_vars=extra_vars, limit=limit, tags=tags,
verbose_level=verbose_level, check=check,
ignore_limit=ignore_limit, list_tasks=list_tasks,
diff=diff)
env = _get_environment(parsed_args)
try:
utils.run_command(cmd, check_output=check_output, quiet=quiet, env=env)
except subprocess.CalledProcessError as e:
LOG.error("Kayobe playbook(s) %s exited %d",
", ".join(playbooks), e.returncode)
if check_output:
LOG.error("The output was:\n%s", e.output)
sys.exit(e.returncode)
def run_playbook(parsed_args, playbook, *args, **kwargs):
"""Run a Kayobe Ansible playbook."""
return run_playbooks(parsed_args, [playbook], *args, **kwargs)
def _sanitise_hostvar(var):
"""Sanitise a host variable."""
if isinstance(var, AnsibleVaultEncryptedUnicode):
return "******"
# Recursively sanitise dicts and lists.
if isinstance(var, dict):
return {k: _sanitise_hostvar(v) for k, v in var.items()}
if isinstance(var, list):
return [_sanitise_hostvar(v) for v in var]
return var
def config_dump(parsed_args, host=None, hosts=None, var_name=None,
facts=None, extra_vars=None, tags=None, verbose_level=None):
dump_dir = tempfile.mkdtemp()
try:
if not extra_vars:
extra_vars = {}
extra_vars["dump_path"] = dump_dir
if host or hosts:
extra_vars["dump_hosts"] = host or hosts
if var_name:
extra_vars["dump_var_name"] = var_name
if facts is not None:
extra_vars["dump_facts"] = facts
# Don't use check mode or list tasks for configuration dumps as we
# won't get any results back.
playbook_path = utils.get_data_files_path("ansible", "dump-config.yml")
run_playbook(parsed_args, playbook_path,
extra_vars=extra_vars, tags=tags, check_output=True,
verbose_level=verbose_level, check=False,
list_tasks=False, diff=False)
hostvars = {}
for path in os.listdir(dump_dir):
LOG.debug("Found dump file %s", path)
inventory_hostname, ext = os.path.splitext(path)
if ext == ".yml":
dump_file = os.path.join(dump_dir, path)
hvars = utils.read_config_dump_yaml_file(dump_file)
if host:
return hvars
else:
hostvars[inventory_hostname] = hvars
else:
LOG.warning("Unexpected extension on config dump file %s",
path)
return {k: _sanitise_hostvar(v) for k, v in hostvars.items()}
finally:
shutil.rmtree(dump_dir)
def install_galaxy_roles(parsed_args, force=False):
"""Install Ansible Galaxy role dependencies.
Installs role dependencies specified in kayobe, and if present, in kayobe
configuration.
:param parsed_args: Parsed command line arguments.
:param force: Whether to force reinstallation of roles.
"""
LOG.info("Installing galaxy role dependencies from kayobe")
requirements = utils.get_data_files_path("requirements.yml")
roles_destination = utils.get_data_files_path('ansible', 'roles')
utils.galaxy_role_install(requirements, roles_destination, force=force)
# Check for requirements in kayobe configuration.
kc_reqs_path = os.path.join(parsed_args.config_path,
"ansible", "requirements.yml")
if not utils.is_readable_file(kc_reqs_path)["result"]:
LOG.info("Not installing galaxy role dependencies from kayobe config "
"- requirements.yml not present")
return
LOG.info("Installing galaxy role dependencies from kayobe config")
# Ensure a roles directory exists in kayobe-config.
kc_roles_path = os.path.join(parsed_args.config_path,
"ansible", "roles")
try:
os.makedirs(kc_roles_path)
except OSError as e:
if e.errno != errno.EEXIST:
raise exception.Error("Failed to create directory ansible/roles/ "
"in kayobe configuration at %s: %s" %
(parsed_args.config_path, str(e)))
# Install roles from kayobe-config.
utils.galaxy_role_install(kc_reqs_path, kc_roles_path, force=force)
def install_galaxy_collections(parsed_args, force=False):
"""Install Ansible Galaxy collection dependencies.
Installs collection dependencies specified in kayobe, and if present, in
kayobe configuration.
:param parsed_args: Parsed command line arguments.
:param force: Whether to force reinstallation of roles.
"""
LOG.info("Installing galaxy collection dependencies from kayobe")
requirements = utils.get_data_files_path("requirements.yml")
collections_destination = utils.get_data_files_path('ansible',
'collections')
utils.galaxy_collection_install(requirements, collections_destination,
force=force)
# Check for requirements in kayobe configuration.
kc_reqs_path = os.path.join(parsed_args.config_path,
"ansible", "requirements.yml")
if not utils.is_readable_file(kc_reqs_path)["result"]:
LOG.info("Not installing galaxy collection dependencies from kayobe "
"config - requirements.yml not present")
return
LOG.info("Installing galaxy collection dependencies from kayobe config")
# Ensure a collections directory exists in kayobe-config.
kc_collections_path = os.path.join(parsed_args.config_path,
"ansible", "collections")
try:
os.makedirs(kc_collections_path)
except OSError as e:
if e.errno != errno.EEXIST:
raise exception.Error("Failed to create directory "
"ansible/collections/ "
"in kayobe configuration at %s: %s" %
(parsed_args.config_path, str(e)))
# Install collections from kayobe-config.
utils.galaxy_collection_install(kc_reqs_path, kc_collections_path,
force=force)
def prune_galaxy_roles(parsed_args):
"""Prune galaxy roles that are no longer necessary.
:param parsed_args: Parsed command line arguments.
"""
LOG.info("Removing unnecessary galaxy roles from kayobe")
roles_to_remove = [
'resmo.ntp',
'stackhpc.ntp',
'stackhpc.os-shade',
'yatesr.timezone',
]
LOG.debug("Removing roles: %s", ",".join(roles_to_remove))
utils.galaxy_remove(roles_to_remove, "ansible/roles")
def passwords_yml_exists(parsed_args):
"""Return whether passwords.yml exists in the kayobe configuration."""
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"]