Files
zaza-openstack-tests/zaza/openstack/charm_tests/vault/tests.py
Alex Kavanagh 752e643c33 Add vault cachine of secrets on relation test
This specific test is for the certificates relation to ensure that the
data presented to units related to vault have a consistent set of data.
2023-07-20 12:17:57 +01:00

429 lines
16 KiB
Python

#!/usr/bin/env python3
# Copyright 2018 Canonical 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.
"""Collection of tests for vault."""
import contextlib
import json
import logging
import subprocess
import unittest
import uuid
import tenacity
from hvac.exceptions import InternalServerError
import zaza.charm_lifecycle.utils as lifecycle_utils
import zaza.openstack.charm_tests.test_utils as test_utils
import zaza.openstack.charm_tests.vault.utils as vault_utils
import zaza.openstack.utilities.cert
import zaza.openstack.utilities.openstack
import zaza.model
@tenacity.retry(
retry=tenacity.retry_if_exception_type(InternalServerError),
retry_error_callback=lambda retry_state: False,
wait=tenacity.wait_fixed(2), # interval between retries
stop=tenacity.stop_after_attempt(10)) # retry 10 times
def retry_hvac_client_authenticated(client):
"""Check hvac client is authenticated with retry.
If is_authenticated() raise exception for all retries,
return False(which is done by `retry_error_callback`).
Otherwise, return whatever the returned value.
"""
return client.hvac_client.is_authenticated()
class BaseVaultTest(test_utils.OpenStackBaseTest):
"""Base class for vault tests."""
@classmethod
def setUpClass(cls):
"""Run setup for Vault tests."""
cls.model_name = zaza.model.get_juju_model()
cls.lead_unit = zaza.model.get_lead_unit_name(
"vault", model_name=cls.model_name)
cls.clients = vault_utils.get_clients()
cls.vip_client = vault_utils.get_vip_client()
if cls.vip_client:
cls.clients.append(cls.vip_client)
cls.vault_creds = vault_utils.get_credentials()
# This little dance is to ensure a correct init and unseal sequence,
# for the case of vault with the raft backend.
# It will also work fine in other cases.
# The wait functions will raise AssertionErrors on timeouts.
init_client = vault_utils.wait_and_get_initialized_client(cls.clients)
vault_utils.unseal_all([init_client], cls.vault_creds['keys'][0])
vault_utils.wait_until_all_initialised(cls.clients)
vault_utils.unseal_all(cls.clients, cls.vault_creds['keys'][0])
vault_utils.auth_all(cls.clients, cls.vault_creds['root_token'])
vault_utils.wait_for_ha_settled(cls.clients)
vault_utils.ensure_secret_backend(cls.clients[0])
def tearDown(self):
"""Tun test cleanup for Vault tests."""
vault_utils.unseal_all(self.clients, self.vault_creds['keys'][0])
@contextlib.contextmanager
def pause_resume(self, services, pgrep_full=False):
"""Override pause_resume for Vault behavior."""
zaza.model.block_until_service_status(
self.lead_unit,
services,
'running',
model_name=self.model_name)
zaza.model.block_until_unit_wl_status(
self.lead_unit,
'active',
model_name=self.model_name)
zaza.model.block_until_all_units_idle(model_name=self.model_name)
zaza.model.run_action(
self.lead_unit,
'pause',
model_name=self.model_name)
zaza.model.block_until_service_status(
self.lead_unit,
services,
'blocked', # Service paused
model_name=self.model_name)
yield
zaza.model.run_action(
self.lead_unit,
'resume',
model_name=self.model_name)
zaza.model.block_until_service_status(
self.lead_unit,
services,
'blocked', # Service sealed
model_name=self.model_name)
class UnsealVault(BaseVaultTest):
"""Unseal Vault only.
Useful for bootstrapping Vault when it is present in test bundles for other
charms.
"""
@classmethod
def setUpClass(cls):
"""Run setup for UnsealVault class."""
super(UnsealVault, cls).setUpClass()
def test_unseal(self, test_config=None):
"""Unseal Vault.
:param test_config: (Optional) Zaza test config
:type test_config: charm_lifecycle.utils.get_charm_config()
"""
vault_utils.run_charm_authorize(self.vault_creds['root_token'])
if not test_config:
test_config = lifecycle_utils.get_charm_config()
try:
del test_config['target_deploy_status']['vault']
except KeyError:
# Already removed
pass
zaza.model.wait_for_application_states(
states=test_config.get('target_deploy_status', {}))
class VaultTest(BaseVaultTest):
"""Encapsulate vault tests."""
@classmethod
def setUpClass(cls):
"""Run setup for Vault tests."""
super(VaultTest, cls).setUpClass()
def test_csr(self):
"""Test generating a csr and uploading a signed certificate."""
vault_actions = zaza.model.get_actions(
'vault')
if 'get-csr' not in vault_actions:
raise unittest.SkipTest('Action not defined')
try:
zaza.model.get_application(
'keystone')
except KeyError:
raise unittest.SkipTest('No client to test csr')
action = vault_utils.run_charm_authorize(
self.vault_creds['root_token'])
action = vault_utils.run_get_csr()
intermediate_csr = action.data['results']['output']
(cakey, cacert) = zaza.openstack.utilities.cert.generate_cert(
'DivineAuthority',
generate_ca=True)
intermediate_cert = zaza.openstack.utilities.cert.sign_csr(
intermediate_csr,
cakey.decode(),
cacert.decode(),
generate_ca=True)
action = vault_utils.run_upload_signed_csr(
pem=intermediate_cert,
root_ca=cacert,
allowed_domains='openstack.local')
test_config = lifecycle_utils.get_charm_config()
try:
del test_config['target_deploy_status']['vault']
except KeyError:
# Already removed
pass
zaza.model.wait_for_application_states(
states=test_config.get('target_deploy_status', {}))
vault_utils.validate_ca(cacert)
def test_all_clients_authenticated(self):
"""Check all vault clients are authenticated."""
for client in self.clients:
self.assertTrue(retry_hvac_client_authenticated(client))
def check_read(self, key, value):
"""Check reading the key from all vault units."""
for client in self.clients:
self.assertEqual(
client.hvac_client.read('secret/uuids')['data']['uuid'],
value)
def test_consistent_read_write(self):
"""Test reading and writing data to vault."""
key = 'secret/uuids'
for client in self.clients:
value = str(uuid.uuid1())
client.hvac_client.write(key, uuid=value, lease='1h')
# Now check all clients read the same value back
self.check_read(key, value)
@test_utils.skipIfNotHA('vault')
def test_vault_ha_statuses(self):
"""Check Vault charm HA status."""
leader = []
leader_address = []
leader_cluster_address = []
for client in self.clients:
self.assertTrue(client.hvac_client.ha_status['ha_enabled'])
leader_address.append(
client.hvac_client.ha_status['leader_address'])
leader_cluster_address.append(
client.hvac_client.ha_status['leader_cluster_address'])
if (client.hvac_client.ha_status['is_self'] and not
client.vip_client):
leader.append(client.addr)
# Check there is exactly one leader
self.assertEqual(len(leader), 1)
# Check both cluster addresses match accross the cluster
self.assertEqual(len(set(leader_address)), 1)
self.assertEqual(len(set(leader_cluster_address)), 1)
def test_check_vault_status(self):
"""Check Vault charm status."""
for client in self.clients:
self.assertFalse(client.hvac_client.seal_status['sealed'])
self.assertTrue(client.hvac_client.seal_status['cluster_name'])
def test_vault_authorize_charm_action(self):
"""Test the authorize_charm action."""
vault_actions = zaza.model.get_actions(
'vault')
if 'authorize-charm' not in vault_actions:
raise unittest.SkipTest('Action not defined')
action = vault_utils.run_charm_authorize(
self.vault_creds['root_token'])
self.assertEqual(action.status, 'completed')
client = self.clients[0]
self.assertIn(
'local-charm-policy',
client.hvac_client.list_policies())
def test_zzz_pause_resume(self):
"""Run pause and resume tests.
Pause service and check services are stopped, then resume and check
they are started.
"""
vault_actions = zaza.model.get_actions(
'vault')
if 'pause' not in vault_actions or 'resume' not in vault_actions:
raise unittest.SkipTest("The version of charm-vault tested does "
"not have pause/resume actions")
# this pauses and resumes the LEAD unit
with self.pause_resume(['vault']):
logging.info("Testing pause resume")
lead_client = vault_utils.extract_lead_unit_client(self.clients)
self.assertTrue(lead_client.hvac_client.seal_status['sealed'])
def test_vault_reload(self):
"""Run reload tests.
Reload service and check services were restarted
by doing simple change in the running config by API.
Then confirm that service is not sealed
"""
vault_actions = zaza.model.get_actions(
'vault')
if 'reload' not in vault_actions:
raise unittest.SkipTest("The version of charm-vault tested does "
"not have reload action")
container_results = zaza.model.run_on_leader(
"vault", "systemd-detect-virt --container"
)
container_rc = json.loads(container_results["Code"])
if container_rc == 0:
raise unittest.SkipTest(
"Vault unit is running in a container. Cannot use mlock."
)
lead_client = vault_utils.get_cluster_leader(self.clients)
running_config = vault_utils.get_running_config(lead_client)
value_to_set = not running_config['data']['disable_mlock']
logging.info("Setting disable-mlock to {}".format(str(value_to_set)))
zaza.model.set_application_config(
'vault',
{'disable-mlock': str(value_to_set)})
logging.info("Waiting for model to be idle ...")
zaza.model.block_until_all_units_idle(model_name=self.model_name)
# Reload all vault units to ensure the new value is loaded.
# Note that charm-vault since 4fccd710 will auto-reload
# vault on config change, so this will be unecessary.
for unit in zaza.model.get_units(
application_name="vault",
model_name=self.model_name
):
zaza.model.run_action(
unit.name, 'reload',
model_name=self.model_name)
logging.info("Getting new value ...")
new_value = vault_utils.get_running_config(lead_client)[
'data']['disable_mlock']
logging.info(
"Asserting new value {} is equal to set value {}"
.format(new_value, value_to_set))
self.assertEqual(
value_to_set,
new_value)
logging.info("Asserting not sealed")
self.assertFalse(lead_client.hvac_client.seal_status['sealed'])
def test_vault_restart(self):
"""Run pause and resume tests.
Restart service and check services are started.
"""
vault_actions = zaza.model.get_actions(
'vault')
if 'restart' not in vault_actions:
raise unittest.SkipTest("The version of charm-vault tested does "
"not have restart action")
logging.info("Testing restart")
zaza.model.run_action_on_leader(
'vault',
'restart',
action_params={})
lead_client = vault_utils.extract_lead_unit_client(self.clients)
self.assertTrue(lead_client.hvac_client.seal_status['sealed'])
class VaultCacheTest(BaseVaultTest):
"""Encapsulate vault tests."""
@test_utils.skipIfNotHA('vault')
def test_vault_caching_unified_view(self):
"""Verify that the vault applicate presents consisent certificates.
On all of the relations to the clients.
"""
try:
application = zaza.model.get_application('keystone')
except KeyError:
self.skipTest("Application 'keystone' not available so skipping.")
# data keys that are 'strs'
key_str_values = ['ca', 'chain', 'client.cert', 'client.key']
for unit in application.units:
command = ['juju', 'show-unit', '--format=json', unit.entity_id]
output = subprocess.check_output(command).decode()
unit_info = json.loads(output)
# verify that unit_info{unit.entity_id}{'relation-info'}[n],
# where List item [n] is a {} where,
# [n]{'endpoint'} == 'certificates' AND
# [n]{'related_units'}{*}{'data'}{...} all match.
#
# first collect the list items that are 'certificates' endpoint.
relation_info_list = [
item
for item in unit_info[unit.entity_id]['relation-info']
if item['endpoint'] == 'certificates']
# collect the data for each of the units.
unit_data_list = [
{key: value['data']
for key, value in item['related-units'].items()}
for item in relation_info_list]
# for each str key, verify that it's the same string on all lists.
for str_key in key_str_values:
values = set((v[str_key]
for unit_data in unit_data_list
for v in unit_data.values()))
self.assertEqual(len(values), 1,
"Not all {} items in data match: {}"
.format(str_key, "\n".join(values)))
# now validate that 'processed_requests' are the same.
# they are json encoded, so need pulling out of the json; first get
# the keys that look like "keystone_0.processed_requests".
data_keys = set((k
for u in unit_data_list
for v in u.values()
for k in v.keys()))
processed_request_keys = [
k for k in data_keys if k.endswith(".processed_requests")]
# now for each processed_request keys, fetch the associated databag
# and json.loads it to get the values; they should match across the
# relations. Using json.loads just in case the ordering of the
# json.dumps is not determined.
for processed_request_key in processed_request_keys:
data_bags = [
(unit, json.loads(v[processed_request_key]))
for u in unit_data_list
for unit, v in u.items()]
# data_bags: [(unit, processed_requests dict)]
self.assertGreater(
len(data_bags), 1,
"Key {} is only in one bag".format(processed_request_key))
first_bag = data_bags[0]
for data_bag in data_bags[1:]:
self.assertEqual(
first_bag[1], data_bag[1],
"key {}: bag for: {} doesn't match bag for: {}"
.format(
processed_request_key, first_bag[0], data_bag[0]))
if __name__ == '__main__':
unittest.main()