From b4bf50226874e61835b0d0524be3c47cf870a686 Mon Sep 17 00:00:00 2001 From: Matt Clay Date: Fri, 2 Mar 2018 16:32:38 -0800 Subject: [PATCH] Initial Tower module integration test support. --- .../cloud-config-tower.cfg.template | 18 + .../targets/tower_organization/aliases | 2 + .../targets/tower_organization/tasks/main.yml | 3 + test/integration/targets/tower_team/aliases | 2 + .../targets/tower_team/tasks/main.yml | 4 + test/runner/lib/cloud/tower.py | 311 ++++++++++++++++++ 6 files changed, 340 insertions(+) create mode 100644 test/integration/cloud-config-tower.cfg.template create mode 100644 test/integration/targets/tower_organization/aliases create mode 100644 test/integration/targets/tower_organization/tasks/main.yml create mode 100644 test/integration/targets/tower_team/aliases create mode 100644 test/integration/targets/tower_team/tasks/main.yml create mode 100644 test/runner/lib/cloud/tower.py diff --git a/test/integration/cloud-config-tower.cfg.template b/test/integration/cloud-config-tower.cfg.template new file mode 100644 index 00000000000..ca59c95998b --- /dev/null +++ b/test/integration/cloud-config-tower.cfg.template @@ -0,0 +1,18 @@ +# This is the configuration template for ansible-test Tower integration tests. +# +# You do not need this template if you are: +# +# 1) Running integration tests without using ansible-test. +# 2) Using the automatically provisioned Tower credentials in ansible-test. +# +# If you do not want to use the automatically provisioned temporary Tower credentials, +# fill in the @VAR placeholders below and save this file without the .template extension. +# This will cause ansible-test to use the given configuration instead of temporary credentials. +# +# NOTE: Automatic provisioning of Tower credentials requires an ansible-core-ci API key. + +[general] +version=@VERSION +host=@HOST +username=@USERNAME +password=@PASSWORD diff --git a/test/integration/targets/tower_organization/aliases b/test/integration/targets/tower_organization/aliases new file mode 100644 index 00000000000..42b2b7300b9 --- /dev/null +++ b/test/integration/targets/tower_organization/aliases @@ -0,0 +1,2 @@ +cloud/tower +posix/ci/cloud/group4/tower diff --git a/test/integration/targets/tower_organization/tasks/main.yml b/test/integration/targets/tower_organization/tasks/main.yml new file mode 100644 index 00000000000..ac05ed5fa9b --- /dev/null +++ b/test/integration/targets/tower_organization/tasks/main.yml @@ -0,0 +1,3 @@ +- name: Make sure the default Default organization exists + tower_organization: + name: Default diff --git a/test/integration/targets/tower_team/aliases b/test/integration/targets/tower_team/aliases new file mode 100644 index 00000000000..42b2b7300b9 --- /dev/null +++ b/test/integration/targets/tower_team/aliases @@ -0,0 +1,2 @@ +cloud/tower +posix/ci/cloud/group4/tower diff --git a/test/integration/targets/tower_team/tasks/main.yml b/test/integration/targets/tower_team/tasks/main.yml new file mode 100644 index 00000000000..82ca5d9481a --- /dev/null +++ b/test/integration/targets/tower_team/tasks/main.yml @@ -0,0 +1,4 @@ +- name: Create a Tower team + tower_team: + name: Test Team + organization: Default diff --git a/test/runner/lib/cloud/tower.py b/test/runner/lib/cloud/tower.py new file mode 100644 index 00000000000..964ee0707f1 --- /dev/null +++ b/test/runner/lib/cloud/tower.py @@ -0,0 +1,311 @@ +"""Tower plugin for integration tests.""" +from __future__ import absolute_import, print_function + +import os +import time + +try: + # noinspection PyPep8Naming + import ConfigParser as configparser +except ImportError: + # noinspection PyUnresolvedReferences + import configparser + +from lib.util import ( + display, + ApplicationError, + is_shippable, + find_pip, + run_command, + generate_password, + SubprocessError, +) + +from lib.cloud import ( + CloudProvider, + CloudEnvironment, +) + +from lib.core_ci import ( + AnsibleCoreCI, + InstanceConnection, +) + +from lib.manage_ci import ( + ManagePosixCI, +) + +from lib.http import ( + HttpClient, +) + + +class TowerCloudProvider(CloudProvider): + """Tower cloud provider plugin. Sets up cloud resources before delegation.""" + def __init__(self, args): + """ + :type args: TestConfig + """ + super(TowerCloudProvider, self).__init__(args, config_extension='.cfg') + + self.aci = None + self.version = '' + + def filter(self, targets, exclude): + """Filter out the cloud tests when the necessary config and resources are not available. + :type targets: tuple[TestTarget] + :type exclude: list[str] + """ + if os.path.isfile(self.config_static_path): + return + + aci = get_tower_aci(self.args) + + if os.path.isfile(aci.ci_key): + return + + if is_shippable(): + return + + super(TowerCloudProvider, self).filter(targets, exclude) + + def setup(self): + """Setup the cloud resource before delegation and register a cleanup callback.""" + super(TowerCloudProvider, self).setup() + + if self._use_static_config(): + self._setup_static() + else: + self._setup_dynamic() + + def check_tower_version(self, fallback=None): + """Check the Tower version being tested and determine the correct CLI version to use. + :type fallback: str | None + """ + tower_cli_version_map = { + '3.1.5': '3.1.8', + '3.2.3': '3.2.1', + } + + cli_version = tower_cli_version_map.get(self.version, fallback) + + if not cli_version: + raise ApplicationError('Mapping to ansible-tower-cli version required for Tower version: %s' % self.version) + + self._set_cloud_config('tower_cli_version', cli_version) + + def cleanup(self): + """Clean up the cloud resource and any temporary configuration files after tests complete.""" + # cleanup on success or failure is not yet supported due to how cleanup is called + if self.aci and self.args.remote_terminate == 'always': + self.aci.stop() + + super(TowerCloudProvider, self).cleanup() + + def _setup_static(self): + config = TowerConfig.parse(self.config_static_path) + + self.version = config.version + self.check_tower_version() + + def _setup_dynamic(self): + """Request Tower credentials through the Ansible Core CI service.""" + display.info('Provisioning %s cloud environment.' % self.platform, verbosity=1) + + # temporary solution to allow version selection + self.version = os.environ.get('TOWER_VERSION', '3.2.3') + self.check_tower_version(os.environ.get('TOWER_CLI_VERSION')) + + aci = get_tower_aci(self.args, self.version) + aci.start() + + connection = aci.get() + + self._set_cloud_config('ssh_hostname', connection.hostname) + self._set_cloud_config('ssh_username', connection.username) + self._set_cloud_config('ssh_port', connection.port) + + config = self._read_config_template() + + if not self.args.explain: + self.aci = aci + + values = dict( + VERSION=self.version, + HOST=connection.hostname, + USERNAME='admin', + PASSWORD=generate_password(), + ) + + config = self._populate_config_template(config, values) + + self._write_config(config) + + +class TowerCloudEnvironment(CloudEnvironment): + """Tower cloud environment plugin. Updates integration test environment after delegation.""" + def setup(self): + """Setup which should be done once per environment instead of once per test target.""" + self.setup_cli() + + if self.managed: + self.setup_dynamic() + + self.ping_tower_api() + self.disable_pendo() + + def setup_dynamic(self): + """Dynamic setup which should be done once per environment instead of once per test target.""" + display.info('Waiting for Tower instance to become reachable over SSH') + + ssh_hostname = self._get_cloud_config('ssh_hostname') + ssh_username = self._get_cloud_config('ssh_username') + ssh_port = self._get_cloud_config('ssh_port') + + config = TowerConfig.parse(self.config_path) + + aci = get_tower_aci(self.args) + aci.connection = InstanceConnection(True, ssh_hostname, ssh_port, ssh_username, None) + + mci = ManagePosixCI(aci) + mci.wait() + + display.info('Waiting for Tower to be reconfigured') + + attempts = 60 + + while attempts: + attempts -= 1 + + try: + # Tower is supposed to drop a /etc/tower/reset/.reconfigured file when it is reconfigured. + # However, the playbook sometimes fails, so we'll look for the completion of the playbook in the log instead. + mci.ssh(['grep', '--quiet', '--no-messages', 'PLAY RECAP', '/etc/tower/reset/reset.log']) + break + except SubprocessError: + time.sleep(5) + else: + raise ApplicationError('Timed out waiting for Tower to be reconfigured.') + + display.info('Updating the Tower %s password' % config.username) + + cmd = ['awx-manage', 'update_password', '--username', config.username, '--password', config.password] + mci.ssh(cmd) + + def setup_cli(self): + """Install the correct Tower CLI for the version of Tower being tested.""" + tower_cli_version = self._get_cloud_config('tower_cli_version') + + display.info('Installing Tower CLI version: %s' % tower_cli_version) + + pip = find_pip(version=self.args.python_version) + cmd = [pip, 'install', '--disable-pip-version-check', 'ansible-tower-cli==%s' % tower_cli_version] + + run_command(self.args, cmd) + + def ping_tower_api(self): + """Wait for Tower API to become available.""" + display.info('Waiting for the Tower API to become reachable') + + config = TowerConfig.parse(self.config_path) + + http = HttpClient(self.args, insecure=True) + http.username = config.username + http.password = config.password + + uri = 'https://%s/api/v1/ping/' % config.host + + attempts = 60 + + while attempts: + attempts -= 1 + response = http.get(uri) + + if response.status_code == 200: + return + + time.sleep(5) + + raise ApplicationError('Timed out waiting for Tower API to become reachable.') + + def disable_pendo(self): + """Disable Pendo tracking.""" + display.info('Disable Pendo tracking') + + config = TowerConfig.parse(self.config_path) + + # tower-cli does not recognize TOWER_ environment variables + cmd = ['tower-cli', 'setting', 'modify', 'PENDO_TRACKING_STATE', 'off', + '-h', config.host, '-u', config.username, '-p', config.password] + + run_command(self.args, cmd) + + def configure_environment(self, env, cmd): + """Configuration which should be done once for each test target. + :type env: dict[str, str] + :type cmd: list[str] + """ + config = TowerConfig.parse(self.config_path) + + env.update(config.environment) + + +class TowerConfig(object): + """Tower settings.""" + def __init__(self, values): + self.version = values.get('version') + self.host = values.get('host') + self.username = values.get('username') + self.password = values.get('password') + + if self.password: + display.sensitive.add(self.password) + + @property + def environment(self): + """Tower settings as environment variables. + :rtype: dict[str, str] + """ + env = dict( + TOWER_HOST=self.host, + TOWER_USERNAME=self.username, + TOWER_PASSWORD=self.password, + ) + + return env + + @staticmethod + def parse(path): + """ + :type path: str + :rtype: TowerConfig + """ + parser = configparser.RawConfigParser() + parser.read(path) + + keys = ( + 'version', + 'host', + 'username', + 'password', + ) + + values = dict((k, parser.get('general', k)) for k in keys) + config = TowerConfig(values) + + return config + + +def get_tower_aci(args, version=None): + """ + :type args: EnvironmentConfig + :type version: str | None + :rtype: AnsibleCoreCI + """ + if version: + persist = True + else: + version = '' + persist = False + + return AnsibleCoreCI(args, 'tower', version, persist=persist, stage=args.remote_stage, provider=args.remote_provider)