Files
tobiko/tobiko/openstack/heat/_stack.py
Slawek Kaplonski 26b6909ceb Introduce new BaseResourceFixture class
This new class is base for both: HeatStackFixture and ResourceFixture
classes. It contains some common pieces of code like e.g. ensuring
that quota is set as required by the fixture.

This is done because fixture StatelessSecurityGroup, which don't use
HeatStackFixture needs to set quota for security groups in Neutron but
ensuring neutron (and nova) quotas are set correctly were done only in
the HeatStackFixture class so far.

Change-Id: Id3d3207f098853469bef87020fc017bec5aaba93
2024-03-19 14:43:41 +01:00

716 lines
25 KiB
Python

# Copyright 2019 Red Hat
#
# 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 __future__ import absolute_import
from collections import abc
import random
import time
import typing
from heatclient.v1 import stacks
from heatclient import exc
from oslo_log import log
import tobiko
from tobiko import config
from tobiko.openstack.heat import _client
from tobiko.openstack.heat import _template
from tobiko.openstack import keystone
from tobiko.openstack.base import _fixture as base_fixture
LOG = log.getLogger(__name__)
# Status
INIT_IN_PROGRESS = 'INIT_IN_PROGRESS'
INIT_COMPLETE = 'INIT_COMPLETE'
INIT_FAILED = 'INIT_FAILED'
CREATE_IN_PROGRESS = 'CREATE_IN_PROGRESS'
CREATE_COMPLETE = 'CREATE_COMPLETE'
CREATE_FAILED = 'CREATE_FAILED'
DELETE_IN_PROGRESS = 'DELETE_IN_PROGRESS'
DELETE_COMPLETE = 'DELETE_COMPLETE'
DELETE_FAILED = 'DELETE_FAILED'
TEMPLATE_FILE_SUFFIX = '.yaml'
def heat_stack_parameters(obj,
stack: 'HeatStackFixture' = None) \
-> 'HeatStackParametersFixture':
if isinstance(obj, HeatStackParametersFixture):
parameters = obj
elif obj is None or isinstance(obj, abc.Mapping):
parameters = HeatStackParametersFixture(stack, obj)
else:
parameters = tobiko.get_fixture(obj)
tobiko.check_valid_type(parameters, HeatStackParametersFixture)
if stack is not None and parameters.stack is None:
parameters.stack = stack
tobiko.check_valid_type(parameters.stack, type(None), HeatStackFixture)
return parameters
STACK_CLASSES = stacks.Stack,
StackType = typing.Union[stacks.Stack]
StackIdType = typing.Union[str, stacks.Stack, 'HeatStackFixture']
def get_stack_id(stack: StackIdType) -> str:
if isinstance(stack, str):
return stack
elif isinstance(stack, HeatStackFixture):
return stack.stack_id
elif isinstance(stack, STACK_CLASSES):
return stack.id
else:
raise TypeError(f'{stack} is not a valid Heat stack ID')
def get_stack(stack: StackIdType,
resolve_outputs=False,
client: _client.HeatClientType = None) \
-> StackType:
if isinstance(stack, HeatStackFixture):
return stack.get_stack(resolve_outputs=resolve_outputs)
else:
stack_id = get_stack_id(stack)
return _client.heat_client(client).stacks.get(
stack_id, resolve_outputs=resolve_outputs)
def list_stacks(client: _client.HeatClientType = None,
**kwargs) -> tobiko.Selection[StackType]:
client = _client.heat_client(client)
return tobiko.select(client.stacks.list(**kwargs))
def find_stack(client: _client.HeatClientType = None,
unique=False,
**kwargs) -> StackIdType:
_stacks = list_stacks(client=client, **kwargs)
if unique:
return _stacks.unique
else:
return _stacks.first
@keystone.skip_unless_has_keystone_credentials()
class HeatStackFixture(base_fixture.BaseResourceFixture):
"""Manages Heat stacks."""
client: _client.HeatClientType = None
retry_count: int = 3
retry_timeout: float = 1200.
min_retry_interval: float = 0.1
max_retry_interval: float = 5.
wait_interval: tobiko.Seconds = 5
wait_timeout: tobiko.Seconds = 600.
template: _template.HeatTemplateFixture
stack: typing.Optional[StackType] = None
stack_name: typing.Optional[str] = None
parameters: typing.Optional['HeatStackParametersFixture'] = None
output_needs_stack_complete: bool = True
def __init__(
self,
stack_name: str = None,
template: _template.HeatTemplateFixture = None,
parameters=None,
wait_interval: tobiko.Seconds = None,
client: _client.HeatClientType = None):
super(HeatStackFixture, self).__init__()
if stack_name is not None:
self.stack_name = stack_name
if template is not None:
self.template = _template.heat_template(template)
if parameters is None:
parameters = self.parameters
self.parameters = heat_stack_parameters(obj=parameters,
stack=self)
if client is not None:
self.client = client
if wait_interval is not None:
self.wait_interval = wait_interval
def setup_fixture(self):
super().setup_fixture()
self.setup_stack_name()
self.setup_template()
self.setup_parameters()
self.setup_stack()
def setup_template(self):
tobiko.setup_fixture(self.template)
def setup_parameters(self):
self.get_stack_parameters()
def setup_stack_name(self) -> str:
stack_name = self.stack_name
if stack_name is None:
self.stack_name = stack_name = self.fixture_name
return stack_name
def setup_client(self) -> _client.HeatClient:
client = self.client
if not isinstance(client, _client.HeatClient):
self.client = client = _client.heat_client(self.client)
return client
@property
def session(self):
return self.setup_client().http_client.session
def setup_stack(self) -> stacks.Stack:
stack = self.create_stack()
tobiko.addme_to_shared_resource(__name__, stack.stack_name)
return stack
def get_stack_parameters(self):
return tobiko.reset_fixture(self.parameters).values
def create_stack(self, retry: tobiko.Retry = None) -> stacks.Stack:
if config.get_bool_env('TOBIKO_PREVENT_CREATE'):
stack = self.validate_created_stack()
else:
for attempt in tobiko.retry(retry,
count=self.retry_count,
timeout=self.retry_timeout,
interval=0.):
try:
stack = self.try_create_stack()
break
except base_fixture.InvalidFixtureError:
LOG.exception(f"Error creating stack '{self.stack_name}'",
exc_info=1)
if attempt.is_last:
raise
# the stack shelf counter does not need to be decreased
# here, because it was not increased yet
self.delete_stack()
# It uses a random time sleep to make conflicting
# concurrent creations less probable to occur
sleep_time = random_sleep_time(
min_time=self.min_retry_interval,
max_time=self.max_retry_interval)
LOG.debug(f"Failed creating stack '{self.stack_name}' "
f"(attempt {attempt.number} of "
f"{attempt.count}). It will retry after "
f"{sleep_time} seconds", exc_info=1)
time.sleep(sleep_time)
else:
raise RuntimeError('Retry loop broken')
return stack
#: valid status expected to be the stack after exiting from create_stack
# method
expected_creted_status = {CREATE_IN_PROGRESS, CREATE_COMPLETE}
def validate_created_stack(self):
return self.wait_for_stack_status(
expected_status=self.expected_creted_status,
check=True)
def try_create_stack(self) -> stacks.Stack:
stack = self.wait_for_stack_status(
expected_status={CREATE_COMPLETE, CREATE_FAILED,
CREATE_IN_PROGRESS, DELETE_COMPLETE,
DELETE_FAILED})
if stack is not None:
stack_status = stack.stack_status
if stack_status in {CREATE_IN_PROGRESS, CREATE_COMPLETE}:
LOG.debug(f"Stack already created (name='{self.stack_name}', "
f"id='{stack.id}').")
return self.validate_created_stack()
if stack_status.endswith('_FAILED'):
LOG.error(f"Stack '{self.stack_name}' (id='{stack.id}') "
f"found in '{stack_status}' status (reason="
f"'{stack.stack_status_reason}'). Deleting it...")
# the stack shelf counter does not need to be decreased here,
# because it was not increased yet
self.delete_stack(stack_id=stack.id)
self.wait_until_stack_deleted()
# Cleanup cached objects
assert self.stack is None
self._outputs = self._resources = None
# Re-compile template parameters
parameters = self.get_stack_parameters()
# Ensure quota limits are OK just in time before start creating
# a new stack
self.ensure_quota_limits()
self.prepare_external_resources()
LOG.debug('Begin creating stack %r...', self.stack_name)
try:
stack_id: str = self.setup_client().stacks.create(
stack_name=self.stack_name,
template=self.template.template_yaml,
parameters=parameters)['stack']['id']
except exc.HTTPConflict:
LOG.debug(f"Stack '{self.stack_name}' already created")
return self.validate_created_stack()
LOG.debug(f"New stack being created: name='{self.stack_name}', "
f"id='{stack_id}'.")
try:
stack = self.validate_created_stack()
except base_fixture.InvalidFixtureError as ex:
LOG.debug(f'Deleting invalid stack (name={self.stack_name}, "'
f'"id={stack_id}): {ex}')
# the stack shelf counter does not need to be decreased here,
# because it was not increased yet
self.delete_stack(stack_id=stack_id)
raise
if stack_id != stack.id:
LOG.debug(f'Deleting duplicate stack (name={self.stack_name}, "'
f'"id={stack_id})')
# the stack shelf counter does not need to be decreased here,
# because it was not increased yet
self.delete_stack(stack_id=stack_id)
del stack_id
LOG.debug('Stack successfully created "'
f"(name={self.stack_name}, id={stack.id}).")
return stack
def prepare_external_resources(self):
pass
_resources = None
@tobiko.fixture_property
def resources(self):
resources = self._resources
if not self._resources:
self._resources = resources = HeatStackResourceFixture(self)
return resources
def cleanup_fixture(self):
n_tests_using_stack = len(tobiko.removeme_from_shared_resource(
__name__, self.stack_name))
if n_tests_using_stack == 0:
self.setup_client()
self.cleanup_stack()
else:
LOG.info('Stack %r not deleted because %d tests are using it',
self.stack_name, n_tests_using_stack)
def cleanup_stack(self):
self.delete_stack()
self.wait_until_stack_deleted()
def delete_stack(self, stack_id=None):
"""Deletes stack."""
self.stack = self._outputs = self._resources = None
if not stack_id:
stack_id = self.stack_id
try:
self.client.stacks.delete(stack_id)
except exc.NotFound:
LOG.debug('Stack already deleted: %r (id=%r)', self.stack_name,
stack_id)
else:
LOG.debug('Deleting stack %r (id=%r)...', self.stack_name,
stack_id)
@property
def stack_id(self) -> str:
stack = self.stack
if stack is None:
return self.setup_stack_name()
else:
return stack.id
def get_stack(self, resolve_outputs=False) \
-> typing.Optional[stacks.Stack]:
"""Returns stack ID."""
client = self.setup_client()
try:
self.stack = stack = client.stacks.get(
self.stack_name, resolve_outputs=resolve_outputs)
except exc.HTTPNotFound:
LOG.debug(f"Stack '{self.stack_name}' not found")
self.stack = stack = None
finally:
self._outputs = self._resources = None
return stack
def wait_for_create_complete(self,
cached=True,
check=True,
timeout: tobiko.Seconds = None,
interval: tobiko.Seconds = None) \
-> typing.Optional[stacks.Stack]:
return self.wait_for_stack_status(expected_status={CREATE_COMPLETE},
cached=cached,
check=check,
timeout=timeout,
interval=interval)
def wait_for_delete_complete(self,
check=True,
cached=True,
timeout: tobiko.Seconds = None,
interval: tobiko.Seconds = None) \
-> typing.Optional[stacks.Stack]:
return self.wait_for_stack_status(expected_status={DELETE_COMPLETE},
cached=cached,
check=check,
timeout=timeout,
interval=interval)
def wait_until_stack_deleted(self,
check=True,
cached=True,
timeout: tobiko.Seconds = None,
interval: tobiko.Seconds = None):
# check stack has been completely deleted
for attempt in tobiko.retry(timeout=timeout,
interval=interval,
default_timeout=self.wait_timeout,
default_interval=self.wait_interval):
# Ensure to refresh stack status
stack = self.wait_for_delete_complete(check=check,
cached=cached,
timeout=attempt.time_left,
interval=attempt.interval)
if stack is None:
LOG.debug(f"Stack {self.stack_name} disappeared")
break
if stack.stack_status != DELETE_COMPLETE:
raise HeatStackDeletionFailed(
name=self.stack_name,
observed=stack.stack_status,
expected={DELETE_COMPLETE},
status_reason=stack.stack_status_reason)
if attempt.is_last:
tobiko.fail(f"Stack {self.stack_name} in status "
f"{DELETE_COMPLETE}, but still present")
cached = False
LOG.debug("Waiting for deleted stack to disappear: '%s'",
self.stack_name)
else:
raise RuntimeError("Retry look broken itself")
def wait_for_stack_status(
self,
expected_status: typing.Container[str],
check=True,
cached=True,
timeout: tobiko.Seconds = None,
interval: tobiko.Seconds = None) \
-> typing.Optional[stacks.Stack]:
"""Waits for the stack to reach the given status."""
for attempt in tobiko.retry(
timeout=timeout,
interval=interval,
default_timeout=self.wait_timeout,
default_interval=self.wait_interval):
if cached:
cached = False
stack = self.stack or self.get_stack()
else:
stack = self.get_stack()
stack_status = getattr(stack, 'stack_status', DELETE_COMPLETE)
if stack_status in expected_status:
LOG.debug(f"Stack '{self.stack_name}' reached expected "
f"status: '{stack_status}'")
break
if not stack_status.endswith('_IN_PROGRESS'):
LOG.warning(f"Stack '{self.stack_name}' reached unexpected "
f"status: '{stack_status}'")
break
if attempt.is_last:
LOG.warning(f"Timed out waiting for stack '{self.stack_name}' "
f"status to change from '{stack_status}' to "
f"'{expected_status}'.")
break
LOG.debug(f"Waiting for stack '{self.stack_name}' status to "
f"change from '{stack_status}' to "
f"'{expected_status}'...")
else:
raise RuntimeError('Retry loop broken')
if stack is not None:
self._log_stack_status(stack)
if check:
if stack is None:
if DELETE_COMPLETE not in expected_status:
raise HeatStackNotFound(name=self.stack_name)
else:
check_stack_status(stack, expected_status)
return stack
_outputs = None
def _log_stack_status(self, stack):
if stack is None:
LOG.debug(f"Stack '{self.stack_name}' doesn't exist")
else:
stack_status_reason = stack.stack_status_reason
if stack_status_reason:
LOG.info(f"Stack '{self.stack_name}' status is "
f"'{stack.stack_status}'. Reason:\n"
f"\t{stack_status_reason}")
else:
LOG.debug(f"Stack '{self.stack_name}' status is "
f"'{stack.stack_status}'.")
def get_stack_outputs(self):
outputs = self._outputs
if not outputs:
self._outputs = outputs = HeatStackOutputsFixture(self)
return outputs
outputs = tobiko.fixture_property(get_stack_outputs)
def __getattr__(self, name):
try:
return self.get_stack_outputs().get_value(name)
except HeatStackOutputKeyError:
pass
message = "Object {!r} has no attribute {!r}".format(self, name)
raise AttributeError(message)
class HeatStackKeyError(tobiko.TobikoException):
message = "key {key!r} not found in stack {name!r}"
class HeatStackResourceKeyError(HeatStackKeyError):
message = "resource key {key!r} not found in stack {name!r}"
class HeatStackParameterKeyError(HeatStackKeyError):
message = "parameter key {key!r} not found in stack {name!r}"
class HeatStackOutputKeyError(HeatStackKeyError):
message = "output key {key!r} not found in stack {name!r}"
class HeatStackNamespaceFixture(tobiko.SharedFixture):
key_error = HeatStackKeyError
_keys = None
_values = None
def __init__(self, stack):
super(HeatStackNamespaceFixture, self).__init__()
if stack and not isinstance(stack, HeatStackFixture):
message = "Object {!r} is not an HeatStackFixture".format(stack)
raise TypeError(message)
self.stack = stack
def setup_fixture(self):
self.setup_keys()
self.setup_values()
def setup_keys(self):
keys = self._keys
if keys is None:
self._keys = keys = self.get_keys()
self.addCleanup(self.cleanup_keys)
return keys
keys = tobiko.fixture_property(setup_keys)
def get_keys(self):
raise NotImplementedError
def cleanup_keys(self):
del self._keys
def setup_values(self):
values = self._values
if values is None:
self._values = values = self.get_values()
self.addCleanup(self.cleanup_values)
return values
values = tobiko.fixture_property(setup_values)
def get_values(self):
raise NotImplementedError
def cleanup_values(self):
del self._values
def get_value(self, key):
# Match template outputs definition before getting value
if key in self.keys:
try:
return self.values[key]
except KeyError:
LOG.error('Key %r not found in stack %r', key,
self.stack.stack_name)
else:
LOG.error('Key %r not found in template for stack %r', key,
self.stack.stack_name)
raise self.key_error(name=self.stack.stack_name, key=key)
def set_value(self, key, value):
# Match template outputs definition before setting value
if key in self.keys:
self.values[key] = value
else:
LOG.error('Key %r not found in template for stack %r', key,
self.stack.stack_name)
raise self.key_error(name=self.stack.stack_name, key=key)
def __getattr__(self, name):
try:
return self.get_value(name)
except self.key_error:
pass
message = "Object {!r} has no attribute {!r}".format(self, name)
raise AttributeError(message)
class HeatStackParametersFixture(HeatStackNamespaceFixture):
key_error = HeatStackParameterKeyError
def __init__(self, stack, parameters=None):
super(HeatStackParametersFixture, self).__init__(stack)
self.parameters = parameters and dict(parameters) or {}
def get_keys(self):
template = tobiko.setup_fixture(self.stack.template)
return frozenset(template.parameters or [])
def get_values(self):
values = dict(self.parameters)
missing_keys = sorted(self.keys - set(values))
for key in missing_keys:
value = getattr(self.stack, key, None)
if value is not None:
values[key] = value
return values
class HeatStackOutputsFixture(HeatStackNamespaceFixture):
key_error = HeatStackOutputKeyError
def get_keys(self):
template = tobiko.setup_fixture(self.stack.template)
return frozenset(template.outputs or [])
def get_values(self):
if self.stack.output_needs_stack_complete:
self.stack.wait_for_create_complete()
outputs = self.stack.get_stack(resolve_outputs=True).outputs
return {o['output_key']: o['output_value']
for o in outputs}
def check_stack_status(stack: stacks.Stack,
expected_status: typing.Container[str]):
stack_status = stack.stack_status
if stack_status in expected_status:
return stack_status
if stack_status == CREATE_FAILED and (
CREATE_IN_PROGRESS in expected_status or
CREATE_COMPLETE in expected_status):
raise HeatStackCreationFailed(
name=stack.stack_name,
observed=stack_status,
expected=expected_status,
status_reason=stack.stack_status_reason)
if stack_status == DELETE_FAILED and (
DELETE_IN_PROGRESS in expected_status or
DELETE_COMPLETE in expected_status):
raise HeatStackDeletionFailed(
name=stack.stack_name,
observed=stack_status,
expected=expected_status,
status_reason=stack.stack_status_reason)
raise InvalidHeatStackStatus(
name=stack.stack_name,
observed=stack_status,
expected=expected_status,
status_reason=stack.stack_status_reason)
class HeatStackError(tobiko.TobikoException):
message = "error creating stack '{name}'"
class HeatStackNotFound(HeatStackError):
message = "stack {name!r} not found"
class InvalidHeatStackStatus(base_fixture.InvalidFixtureError):
message = ("stack {name!r} status {observed!r} not in {expected!r}\n"
"{status_reason!s}")
class HeatStackCreationFailed(InvalidHeatStackStatus):
pass
class HeatStackDeletionFailed(InvalidHeatStackStatus):
pass
class HeatStackResourceFixture(HeatStackNamespaceFixture):
key_error = HeatStackResourceKeyError
def get_keys(self):
template = tobiko.setup_fixture(self.stack.template)
return frozenset(template.resources or [])
def get_values(self):
# Setting output_needs_stack_complete to False may be necessary
# in some case, such as the faults tests
# that covers RHBZ#2124877
# Some VMs may be in ERROR state for this testcase
# but that is ok, that is not the aim of this test
if self.stack.output_needs_stack_complete:
self.stack.wait_for_create_complete()
client = self.stack.client
resources = client.resources.list(self.stack.stack_id)
return {r.resource_name: r for r in resources}
@property
def fixture_name(self):
return self.stack_name + '.resources'
def random_sleep_time(min_time, max_time):
assert min_time <= min_time
return (max_time - min_time) * random.random() + min_time