Fix use of --ask-vault-pass argument

Currently, this argument does not work correctly, since the vault
password is not passed to kayobe via $KAYOBE_VAULT_PASSWORD, meaning
that it cannot update the kolla-ansible passwords.yml file.

It also works non-optimally, since every invocation of ansible-playbook
will prompt for a password.

This change fixes the issue by prompting for a password once in the
kayobe CLI, and storing the password in the $KAYOBE_VAULT_PASSWORD
environment variable. The kayobe-vault-password-helper command is then
used as the --vault-password-file to ansible-playbook, and the helper
pulls the password out of the environment.

Change-Id: I88b1b7b9e9be15b52e730d353ce1b1a6feacceb8
Story: 2001664
Task: 27009
This commit is contained in:
Mark Goddard 2018-10-11 14:58:22 +01:00 committed by Doug Szumski
parent 17c1e316df
commit 358b5c6882
7 changed files with 262 additions and 62 deletions

View File

@ -79,6 +79,7 @@ def _get_inventory_path(parsed_args):
def _validate_args(parsed_args, playbooks):
"""Validate Kayobe Ansible arguments."""
vault.validate_args(parsed_args)
result = utils.is_readable_dir(parsed_args.config_path)
if not result["result"]:
LOG.error("Kayobe configuration path %s is invalid: %s",
@ -124,7 +125,7 @@ def build_args(parsed_args, playbooks,
cmd += ["-" + "v" * verbose_level]
if parsed_args.list_tasks:
cmd += ["--list-tasks"]
cmd += vault.build_args(parsed_args)
cmd += vault.build_args(parsed_args, "--vault-password-file")
inventory = _get_inventory_path(parsed_args)
cmd += ["--inventory", inventory]
vars_files = _get_vars_files(parsed_args.config_path)
@ -152,13 +153,6 @@ def build_args(parsed_args, playbooks,
return cmd
def _read_vault_password_file(vault_password_file):
"""Return the password from a vault password file."""
vault_password = utils.read_file(vault_password_file)
vault_password = vault_password.strip()
return vault_password
def run_playbooks(parsed_args, playbooks,
extra_vars=None, limit=None, tags=None, quiet=False,
verbose_level=None, check=None):
@ -168,13 +162,7 @@ def run_playbooks(parsed_args, playbooks,
extra_vars=extra_vars, limit=limit, tags=tags,
verbose_level=verbose_level, check=check)
env = os.environ.copy()
# If the Vault password has been specified via --vault-password-file,
# ensure the environment variable is set, so that it can be referenced by
# playbooks to generate the kolla-ansible passwords.yml file.
if vault.VAULT_PASSWORD_ENV not in env and parsed_args.vault_password_file:
vault_password = _read_vault_password_file(
parsed_args.vault_password_file)
env[vault.VAULT_PASSWORD_ENV] = vault_password
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.

View File

@ -19,6 +19,7 @@ import subprocess
import sys
from kayobe import utils
from kayobe import vault
DEFAULT_CONFIG_PATH = "/etc/kolla"
@ -78,6 +79,7 @@ def _get_inventory_path(parsed_args, inventory_filename):
def _validate_args(parsed_args, inventory_filename):
"""Validate Kayobe Ansible arguments."""
vault.validate_args(parsed_args)
result = utils.is_readable_dir(parsed_args.kolla_config_path)
if not result["result"]:
LOG.error("Kolla configuration path %s is invalid: %s",
@ -106,8 +108,7 @@ def build_args(parsed_args, command, inventory_filename, extra_vars=None,
cmd += ["kolla-ansible", command]
if verbose_level:
cmd += ["-" + "v" * verbose_level]
if parsed_args.vault_password_file:
cmd += ["--key", parsed_args.vault_password_file]
cmd += vault.build_args(parsed_args, "--key")
inventory = _get_inventory_path(parsed_args, inventory_filename)
cmd += ["--inventory", inventory]
if parsed_args.kolla_config_path != DEFAULT_CONFIG_PATH:
@ -144,8 +145,10 @@ def run(parsed_args, command, inventory_filename, extra_vars=None,
verbose_level=verbose_level,
extra_args=extra_args,
limit=limit)
env = os.environ.copy()
vault.update_environment(parsed_args, env)
try:
utils.run_command(" ".join(cmd), quiet=quiet, shell=True)
utils.run_command(" ".join(cmd), quiet=quiet, shell=True, env=env)
except subprocess.CalledProcessError as e:
LOG.error("kolla-ansible %s exited %d", command, e.returncode)
sys.exit(e.returncode)

View File

@ -28,12 +28,12 @@ from kayobe import utils
from kayobe import vault
@mock.patch.dict(os.environ, clear=True)
class TestCase(unittest.TestCase):
@mock.patch.object(utils, "run_command")
@mock.patch.object(ansible, "_get_vars_files")
@mock.patch.object(ansible, "_validate_args")
@mock.patch.dict(os.environ, clear=True)
def test_run_playbooks(self, mock_validate, mock_vars, mock_run):
mock_vars.return_value = ["/etc/kayobe/vars-file1.yml",
"/etc/kayobe/vars-file2.yaml"]
@ -58,7 +58,6 @@ class TestCase(unittest.TestCase):
@mock.patch.object(utils, "run_command")
@mock.patch.object(ansible, "_get_vars_files")
@mock.patch.object(ansible, "_validate_args")
@mock.patch.dict(os.environ, clear=True)
def test_run_playbooks_all_the_args(self, mock_validate, mock_vars,
mock_run):
mock_vars.return_value = ["/path/to/config/vars-file1.yml",
@ -102,14 +101,15 @@ class TestCase(unittest.TestCase):
@mock.patch.object(utils, "run_command")
@mock.patch.object(ansible, "_get_vars_files")
@mock.patch.object(ansible, "_validate_args")
@mock.patch.dict(os.environ, clear=True)
def test_run_playbooks_all_the_long_args(self, mock_validate, mock_vars,
mock_run):
@mock.patch.object(vault, "_ask_vault_pass")
def test_run_playbooks_all_the_long_args(self, mock_ask, mock_validate,
mock_vars, mock_run):
mock_vars.return_value = ["/path/to/config/vars-file1.yml",
"/path/to/config/vars-file2.yaml"]
parser = argparse.ArgumentParser()
ansible.add_args(parser)
vault.add_args(parser)
mock_ask.return_value = "test-pass"
args = [
"--ask-vault-pass",
"--become",
@ -123,11 +123,12 @@ class TestCase(unittest.TestCase):
"--list-tasks",
]
parsed_args = parser.parse_args(args)
mock_run.return_value = "/path/to/kayobe-vault-password-helper"
ansible.run_playbooks(parsed_args, ["playbook1.yml", "playbook2.yml"])
expected_cmd = [
"ansible-playbook",
"--list-tasks",
"--ask-vault-pass",
"--vault-password-file", "/path/to/kayobe-vault-password-helper",
"--inventory", "/path/to/inventory",
"-e", "@/path/to/config/vars-file1.yml",
"-e", "@/path/to/config/vars-file2.yaml",
@ -140,20 +141,24 @@ class TestCase(unittest.TestCase):
"playbook1.yml",
"playbook2.yml",
]
expected_env = {"KAYOBE_CONFIG_PATH": "/path/to/config"}
mock_run.assert_called_once_with(expected_cmd, quiet=False,
env=expected_env)
expected_env = {"KAYOBE_CONFIG_PATH": "/path/to/config",
"KAYOBE_VAULT_PASSWORD": "test-pass"}
expected_calls = [
mock.call(["which", "kayobe-vault-password-helper"],
check_output=True),
mock.call(expected_cmd, quiet=False, env=expected_env)
]
self.assertEqual(expected_calls, mock_run.mock_calls)
mock_vars.assert_called_once_with("/path/to/config")
@mock.patch.object(utils, "run_command")
@mock.patch.object(ansible, "_get_vars_files")
@mock.patch.object(ansible, "_validate_args")
@mock.patch.object(ansible, "_read_vault_password_file")
@mock.patch.dict(os.environ, clear=True)
def test_run_playbooks_vault_password_file(self, mock_read, mock_validate,
@mock.patch.object(vault, "update_environment")
def test_run_playbooks_vault_password_file(self, mock_update,
mock_validate,
mock_vars, mock_run):
mock_vars.return_value = []
mock_read.return_value = "test-pass"
parser = argparse.ArgumentParser()
ansible.add_args(parser)
vault.add_args(parser)
@ -168,10 +173,10 @@ class TestCase(unittest.TestCase):
"--inventory", "/etc/kayobe/inventory",
"playbook1.yml",
]
expected_env = {"KAYOBE_CONFIG_PATH": "/etc/kayobe",
"KAYOBE_VAULT_PASSWORD": "test-pass"}
expected_env = {"KAYOBE_CONFIG_PATH": "/etc/kayobe"}
mock_run.assert_called_once_with(expected_cmd, quiet=False,
env=expected_env)
mock_update.assert_called_once_with(mock.ANY, expected_env)
@mock.patch.dict(os.environ, {"KAYOBE_VAULT_PASSWORD": "test-pass"},
clear=True)
@ -204,7 +209,6 @@ class TestCase(unittest.TestCase):
@mock.patch.object(utils, "run_command")
@mock.patch.object(ansible, "_get_vars_files")
@mock.patch.object(ansible, "_validate_args")
@mock.patch.dict(os.environ, clear=True)
def test_run_playbooks_vault_ask_and_file(self, mock_validate, mock_vars,
mock_run):
mock_vars.return_value = []
@ -220,7 +224,6 @@ class TestCase(unittest.TestCase):
@mock.patch.object(utils, "run_command")
@mock.patch.object(ansible, "_get_vars_files")
@mock.patch.object(ansible, "_validate_args")
@mock.patch.dict(os.environ, clear=True)
def test_run_playbooks_func_args(self, mock_validate, mock_vars, mock_run):
mock_vars.return_value = ["/etc/kayobe/vars-file1.yml",
"/etc/kayobe/vars-file2.yaml"]
@ -263,7 +266,6 @@ class TestCase(unittest.TestCase):
@mock.patch.object(utils, "run_command")
@mock.patch.object(ansible, "_get_vars_files")
@mock.patch.object(ansible, "_validate_args")
@mock.patch.dict(os.environ, clear=True)
def test_run_playbooks_failure(self, mock_validate, mock_vars, mock_run):
parser = argparse.ArgumentParser()
ansible.add_args(parser)
@ -387,10 +389,3 @@ class TestCase(unittest.TestCase):
mock_is_readable.assert_called_once_with(
"/etc/kayobe/ansible/requirements.yml")
mock_mkdirs.assert_called_once_with("/etc/kayobe/ansible/roles")
@mock.patch.object(utils, 'read_file')
def test__read_vault_password_file(self, mock_read):
mock_read.return_value = "test-pass\n"
result = ansible._read_vault_password_file("/path/to/file")
self.assertEqual("test-pass", result)
mock_read.assert_called_once_with("/path/to/file")

View File

@ -25,6 +25,7 @@ from kayobe import vault
@mock.patch.object(os, "getcwd", new=lambda: "/path/to/cwd")
@mock.patch.dict(os.environ, clear=True)
class TestCase(unittest.TestCase):
@mock.patch.object(utils, "run_command")
@ -41,7 +42,8 @@ class TestCase(unittest.TestCase):
"--inventory", "/etc/kolla/inventory/overcloud",
]
expected_cmd = " ".join(expected_cmd)
mock_run.assert_called_once_with(expected_cmd, shell=True, quiet=False)
mock_run.assert_called_once_with(expected_cmd, shell=True, quiet=False,
env={})
@mock.patch.object(utils, "run_command")
@mock.patch.object(kolla_ansible, "_validate_args")
@ -69,14 +71,17 @@ class TestCase(unittest.TestCase):
"--tags", "tag1,tag2",
]
expected_cmd = " ".join(expected_cmd)
mock_run.assert_called_once_with(expected_cmd, shell=True, quiet=False)
mock_run.assert_called_once_with(expected_cmd, shell=True, quiet=False,
env={})
@mock.patch.object(utils, "run_command")
@mock.patch.object(kolla_ansible, "_validate_args")
def test_run_all_the_long_args(self, mock_validate, mock_run):
@mock.patch.object(vault, "_ask_vault_pass")
def test_run_all_the_long_args(self, mock_ask, mock_validate, mock_run):
parser = argparse.ArgumentParser()
kolla_ansible.add_args(parser)
vault.add_args(parser)
mock_ask.return_value = "test-pass"
args = [
"--ask-vault-pass",
"--kolla-config-path", "/path/to/config",
@ -87,10 +92,12 @@ class TestCase(unittest.TestCase):
"--kolla-tags", "tag1,tag2",
]
parsed_args = parser.parse_args(args)
mock_run.return_value = "/path/to/kayobe-vault-password-helper"
kolla_ansible.run(parsed_args, "command", "overcloud")
expected_cmd = [
".", "/path/to/cwd/venvs/kolla-ansible/bin/activate", "&&",
"kolla-ansible", "command",
"--key", "/path/to/kayobe-vault-password-helper",
"--inventory", "/path/to/inventory",
"--configdir", "/path/to/config",
"--passwords", "/path/to/config/passwords.yml",
@ -100,11 +107,19 @@ class TestCase(unittest.TestCase):
"--tags", "tag1,tag2",
]
expected_cmd = " ".join(expected_cmd)
mock_run.assert_called_once_with(expected_cmd, shell=True, quiet=False)
expected_env = {"KAYOBE_VAULT_PASSWORD": "test-pass"}
expected_calls = [
mock.call(["which", "kayobe-vault-password-helper"],
check_output=True),
mock.call(expected_cmd, shell=True, quiet=False, env=expected_env)
]
self.assertEqual(expected_calls, mock_run.mock_calls)
@mock.patch.object(utils, "run_command")
@mock.patch.object(kolla_ansible, "_validate_args")
def test_run_vault_password_file(self, mock_validate, mock_run):
@mock.patch.object(vault, "update_environment")
def test_run_vault_password_file(self, mock_update, mock_validate,
mock_run):
parser = argparse.ArgumentParser()
kolla_ansible.add_args(parser)
vault.add_args(parser)
@ -120,12 +135,15 @@ class TestCase(unittest.TestCase):
"--inventory", "/etc/kolla/inventory/overcloud",
]
expected_cmd = " ".join(expected_cmd)
mock_run.assert_called_once_with(expected_cmd, shell=True, quiet=False)
mock_run.assert_called_once_with(expected_cmd, shell=True, quiet=False,
env={})
mock_update.assert_called_once_with(mock.ANY, {})
@mock.patch.dict(os.environ, {"KAYOBE_VAULT_PASSWORD": "test-pass"})
@mock.patch.object(utils, "run_command")
@mock.patch.object(kolla_ansible, "_validate_args")
def test_run_vault_password_helper(self, mock_vars, mock_run):
@mock.patch.object(vault, "update_environment")
def test_run_vault_password_helper(self, mock_update, mock_vars, mock_run):
mock_vars.return_value = []
parser = argparse.ArgumentParser()
mock_run.return_value = "/path/to/kayobe-vault-password-helper"
@ -143,7 +161,10 @@ class TestCase(unittest.TestCase):
"--inventory", "/etc/kolla/inventory/overcloud",
]
expected_cmd = " ".join(expected_cmd)
mock_run.assert_called_once_with(expected_cmd, shell=True, quiet=False)
expected_env = {"KAYOBE_VAULT_PASSWORD": "test-pass"}
mock_run.assert_called_once_with(expected_cmd, shell=True, quiet=False,
env=expected_env)
mock_update.assert_called_once_with(mock.ANY, expected_env)
@mock.patch.object(utils, "run_command")
@mock.patch.object(kolla_ansible, "_validate_args")
@ -174,7 +195,8 @@ class TestCase(unittest.TestCase):
"--arg1", "--arg2",
]
expected_cmd = " ".join(expected_cmd)
mock_run.assert_called_once_with(expected_cmd, shell=True, quiet=False)
mock_run.assert_called_once_with(expected_cmd, shell=True, quiet=False,
env={})
@mock.patch.object(utils, "run_command")
@mock.patch.object(kolla_ansible, "_validate_args")

View File

@ -0,0 +1,104 @@
# Copyright (c) 2018 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 argparse
import os
import unittest
import mock
from kayobe import utils
from kayobe import vault
class TestCase(unittest.TestCase):
def test_validate_args_ok(self):
parser = argparse.ArgumentParser()
vault.add_args(parser)
parsed_args = parser.parse_args([])
vault.validate_args(parsed_args)
@mock.patch.dict(os.environ, {"KAYOBE_VAULT_PASSWORD": "test-pass"})
def test_validate_args_env(self):
parser = argparse.ArgumentParser()
vault.add_args(parser)
parsed_args = parser.parse_args([])
vault.validate_args(parsed_args)
@mock.patch.dict(os.environ, {"KAYOBE_VAULT_PASSWORD": "test-pass"})
def test_validate_args_ask_vault_pass(self):
parser = argparse.ArgumentParser()
vault.add_args(parser)
parsed_args = parser.parse_args(["--ask-vault-pass"])
self.assertRaises(SystemExit, vault.validate_args, parsed_args)
@mock.patch.dict(os.environ, {"KAYOBE_VAULT_PASSWORD": "test-pass"})
def test_validate_args_vault_password_file(self):
parser = argparse.ArgumentParser()
vault.add_args(parser)
parsed_args = parser.parse_args(["--vault-password-file",
"/path/to/file"])
self.assertRaises(SystemExit, vault.validate_args, parsed_args)
@mock.patch.object(vault.getpass, 'getpass')
def test__ask_vault_pass(self, mock_getpass):
mock_getpass.return_value = 'test-pass'
# Call twice to verify that the user is only prompted once.
result = vault._ask_vault_pass()
self.assertEqual('test-pass', result)
mock_getpass.assert_called_once_with("Vault password: ")
result = vault._ask_vault_pass()
self.assertEqual('test-pass', result)
mock_getpass.assert_called_once_with("Vault password: ")
@mock.patch.object(utils, 'read_file')
def test__read_vault_password_file(self, mock_read):
mock_read.return_value = "test-pass\n"
result = vault._read_vault_password_file("/path/to/file")
self.assertEqual("test-pass", result)
mock_read.assert_called_once_with("/path/to/file")
def test_update_environment_no_vault(self):
parser = argparse.ArgumentParser()
vault.add_args(parser)
parsed_args = parser.parse_args([])
env = {}
vault.update_environment(parsed_args, env)
self.assertEqual({}, env)
@mock.patch.object(vault, '_ask_vault_pass')
def test_update_environment_prompt(self, mock_ask):
mock_ask.return_value = "test-pass"
parser = argparse.ArgumentParser()
vault.add_args(parser)
parsed_args = parser.parse_args(["--ask-vault-pass"])
env = {}
vault.update_environment(parsed_args, env)
self.assertEqual({"KAYOBE_VAULT_PASSWORD": "test-pass"}, env)
mock_ask.assert_called_once_with()
@mock.patch.object(vault, '_read_vault_password_file')
def test_update_environment_file(self, mock_read):
mock_read.return_value = "test-pass"
parser = argparse.ArgumentParser()
vault.add_args(parser)
args = ["--vault-password-file", "/path/to/file"]
parsed_args = parser.parse_args(args)
env = {}
vault.update_environment(parsed_args, env)
self.assertEqual({"KAYOBE_VAULT_PASSWORD": "test-pass"}, env)
mock_read.assert_called_once_with("/path/to/file")

View File

@ -12,15 +12,30 @@
# License for the specific language governing permissions and limitations
# under the License.
import getpass
import logging
import os
import subprocess
import sys
from kayobe import utils
LOG = logging.getLogger(__name__)
VAULT_PASSWORD_ENV = "KAYOBE_VAULT_PASSWORD"
def _get_vault_password_helper():
"""Return the path to the kayobe-vault-password-helper executable."""
cmd = ["which", "kayobe-vault-password-helper"]
try:
output = utils.run_command(cmd, check_output=True)
except subprocess.CalledProcessError:
return None
return output.strip()
def _get_default_vault_password_file():
"""Return the default value for the vault password file argument.
@ -29,12 +44,7 @@ def _get_default_vault_password_file():
"""
if not os.getenv(VAULT_PASSWORD_ENV):
return None
cmd = ["which", "kayobe-vault-password-helper"]
try:
output = utils.run_command(cmd, check_output=True)
except subprocess.CalledProcessError:
return None
return output.strip()
return _get_vault_password_helper()
def add_args(parser):
@ -48,11 +58,82 @@ def add_args(parser):
help="vault password file")
def build_args(parsed_args):
def build_args(parsed_args, password_file_arg_name):
"""Build a list of command line arguments for use with ansible-playbook."""
cmd = []
vault_password_file = None
if parsed_args.ask_vault_pass:
cmd += ["--ask-vault-pass"]
vault_password_file = _get_vault_password_helper()
elif parsed_args.vault_password_file:
cmd += ["--vault-password-file", parsed_args.vault_password_file]
vault_password_file = parsed_args.vault_password_file
cmd = []
if vault_password_file:
cmd += [password_file_arg_name, vault_password_file]
return cmd
def validate_args(parsed_args):
"""Validate command line arguments."""
# Ensure that a password prompt or file has not been requested if the
# password environment variable is set.
if VAULT_PASSWORD_ENV not in os.environ:
return
helper = _get_vault_password_helper()
invalid_arg = None
if parsed_args.ask_vault_pass:
invalid_arg = "--ask-vault-pass"
elif parsed_args.vault_password_file != helper:
invalid_arg = "--vault-password-file"
if invalid_arg:
LOG.error("Cannot specify %s when $%s is specified" %
(invalid_arg, VAULT_PASSWORD_ENV))
sys.exit(1)
def _ask_vault_pass():
"""Prompt the user for a Vault password.
The first time this function is called, the user is prompted for a
password. To avoid prompting the user multiple times per invocation of
kayobe, we cache the password and return it without prompting on subsequent
calls.
:return: The password entered by the user.
"""
if not hasattr(_ask_vault_pass, "password"):
password = getpass.getpass("Vault password: ")
setattr(_ask_vault_pass, "password", password)
return getattr(_ask_vault_pass, "password")
def _read_vault_password_file(vault_password_file):
"""Return the password from a vault password file."""
vault_password = utils.read_file(vault_password_file)
vault_password = vault_password.strip()
return vault_password
def update_environment(parsed_args, env):
"""Update environment variables with the vault password if necessary.
:param parsed_args: Parsed command line arguments.
:params env: Dict of environment variables to update.
"""
# If the Vault password has been specified via --vault-password-file, or a
# prompt has been requested via --ask-vault-pass, ensure the environment
# variable is set, so that it can be referenced by playbooks to generate
# the kolla-ansible passwords.yml file.
if VAULT_PASSWORD_ENV in env:
return
vault_password = None
if parsed_args.ask_vault_pass:
vault_password = _ask_vault_pass()
elif parsed_args.vault_password_file:
vault_password = _read_vault_password_file(
parsed_args.vault_password_file)
if vault_password is not None:
env[VAULT_PASSWORD_ENV] = vault_password

View File

@ -0,0 +1,7 @@
---
fixes:
- |
Fixes an issue with the ``--ask-vault-pass`` argument, where Kayobe would
fail to generate the Kolla Ansible ``passwords.yml`` file. Also ensures
that the user is only prompted for the password once per execution of
kayobe.