zuul/zuul/lib/ansible.py
James E. Blair 2a8b29aa94 Remove built-in ARA support
This has been pinned to a very old version of ARA for some time, and
newer versions of Ansible are no longer compatible with the old version
of ARA.  Since this isn't receiving maintenance keeping it up to date,
remove it.

Note that if there is desire for support for this or other callback
plugins, it would be quite reasonable and relatively straightforward
to add the ability to generically configure additional callback plugins.
This would have the advantage of not requiring tight internal integration
between Zuul and other callback plugins.  Such a change would likely
be welcome.

Change-Id: I733e48127f2b1cf7d2d52153844098163e48bae8
2022-04-13 16:44:34 -07:00

352 lines
14 KiB
Python

# Copyright 2019 BMW Group
#
# 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 concurrent.futures
import configparser
import logging
import os
import shutil
import subprocess
import sys
import zuul.ansible
from pkg_resources import resource_string
from zuul.lib.config import get_default
class ManagedAnsible:
log = logging.getLogger('zuul.managed_ansible')
def __init__(self, config, version, runtime_install_root=None):
self.version = version
requirements = get_default(config, version, 'requirements')
self._requirements = requirements.split(' ')
common_requirements = get_default(config, 'common', 'requirements')
if common_requirements:
self._requirements.extend(common_requirements.split(' '))
self.deprecated = get_default(config, version, 'deprecated', False)
self._ansible_roots = [os.path.join(
sys.exec_prefix, 'lib', 'zuul', 'ansible')]
if runtime_install_root:
self._ansible_roots.append(runtime_install_root)
self.install_root = self._ansible_roots[-1]
def ensure_ansible(self, upgrade=False):
self.log.info('Installing ansible %s, requirements: %s, '
'extra packages: %s',
self.version, self._requirements, self.extra_packages)
self._run_pip(self._requirements + self.extra_packages,
upgrade=upgrade)
def _run_pip(self, requirements, upgrade=False):
cmd = [os.path.join(self.venv_path, 'bin', 'pip'), 'install',
'--no-cache-dir']
if upgrade:
cmd.append('-U')
cmd.extend(requirements)
self.log.debug('Running pip: %s', ' '.join(cmd))
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if p.returncode != 0:
raise Exception('Package installation failed with exit code %s '
'during processing ansible %s:\n'
'stdout:\n%s\n'
'stderr:\n%s' % (p.returncode, self.version,
p.stdout.decode(),
p.stderr.decode()))
self.log.debug('Successfully installed packages %s', requirements)
def ensure_venv(self):
if self.python_path:
self.log.debug(
'Virtual environment %s already existing', self.venv_path)
return
venv_path = os.path.join(self.install_root, self.version)
self.log.info('Creating venv %s', venv_path)
python_executable = sys.executable
if hasattr(sys, 'real_prefix'):
# We're inside a virtual env and the venv module behaves strange
# if we're calling it from there so default to
# <real_prefix>/bin/python3
python_executable = os.path.join(sys.real_prefix, 'bin', 'python3')
# We don't use directly the venv module here because its behavior is
# broken if we're already in a virtual environment.
cmd = [sys.executable, '-m', 'virtualenv',
'-p', python_executable, venv_path]
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if p.returncode != 0:
raise Exception('venv creation failed with exit code %s:\n'
'stdout:\n%s\n'
'stderr:\n%s' % (p.returncode, p.stdout.decode(),
p.stderr.decode()))
@property
def venv_path(self):
for root in reversed(self._ansible_roots):
# Check user configured paths first
venv_path = os.path.join(root, self.version)
if os.path.exists(venv_path):
return venv_path
return None
@property
def python_path(self):
venv_path = self.venv_path
if venv_path:
return os.path.join(self.venv_path, 'bin', 'python')
return None
@property
def extra_packages(self):
mapping = str.maketrans({
'.': None,
'-': '_',
})
env_var = 'ANSIBLE_%s_EXTRA_PACKAGES' % self.version.upper().translate(
mapping)
packages = os.environ.get(env_var)
result = []
if packages:
result.extend(packages.strip().split(' '))
common_packages = os.environ.get('ANSIBLE_EXTRA_PACKAGES')
if common_packages:
result.extend(common_packages.strip().split(' '))
return result
def __repr__(self):
return 'Ansible {a.version}, {a.deprecated}'.format(
a=self)
class AnsibleManager:
log = logging.getLogger('zuul.ansible_manager')
def __init__(self, zuul_ansible_dir=None, default_version=None,
runtime_install_root=None):
self._supported_versions = {}
self.default_version = None
self.zuul_ansible_dir = zuul_ansible_dir
self.runtime_install_root = runtime_install_root
self.load_ansible_config()
# If configured, override the default version
if default_version:
self.requestVersion(default_version)
self.default_version = default_version
def load_ansible_config(self):
c = resource_string(__name__, 'ansible-config.conf').decode()
config = configparser.ConfigParser()
config.read_string(c)
for version in config.sections():
# The common section is no ansible version
if version == 'common':
continue
ansible = ManagedAnsible(
config, version,
runtime_install_root=self.runtime_install_root)
if ansible.version in self._supported_versions:
raise RuntimeError(
'Ansible version %s already defined' % ansible.version)
self._supported_versions[ansible.version] = ansible
default_version = get_default(
config, 'common', 'default_version', None)
if not default_version:
raise RuntimeError('A default ansible version must be specified')
# Validate that this version is known
self._getAnsible(default_version)
self.default_version = default_version
def install(self, upgrade=False):
# virtualenv sets up a shared directory of pip seed packages per
# python version. If we run virtualenv in parallel we can have one
# create the dirs but not be finished filling them with content, a
# second notice the dir is there so it just goes on with what it's
# done, and thus races leaving us with virtualenvs minus pip.
for a in self._supported_versions.values():
a.ensure_venv()
# Note: With higher number of threads pip seems to have some race
# leading to occasional failures during setup of all ansible
# environments. Thus we limit the number of workers to reduce the risk
# of hitting this race.
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = {executor.submit(a.ensure_ansible, upgrade): a
for a in self._supported_versions.values()}
for future in concurrent.futures.as_completed(futures):
future.result()
def _validate_ansible(self, version):
result = True
try:
command = [
self.getAnsibleCommand(version, 'ansible'),
'--version',
]
ret = subprocess.run(command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True)
self.log.info('Ansible version %s information: \n%s',
version, ret.stdout.decode())
except subprocess.CalledProcessError:
result = False
self.log.exception("Ansible version %s not working" % version)
except Exception:
result = False
self.log.exception(
'Ansible version %s not installed' % version)
return result
def _validate_packages(self, version):
result = False
try:
extra_packages = self._getAnsible(version).extra_packages
python_package_check = \
"import pkg_resources; pkg_resources.require({})".format(
repr(extra_packages))
command = [self.getAnsibleCommand(version, 'python'),
'-c', python_package_check]
ret = subprocess.run(command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
# We check manually so that we can log the stdout and stderr
# properly which aren't going to be available if we have
# subprocess.run() check and raise.
if ret.returncode != 0:
self.log.error(
'Ansible version %s installation is missing packages' %
version)
self.log.debug("Ansible package check output: %s", ret.stdout)
else:
result = True
except Exception:
self.log.exception(
'Exception checking Ansible version %s packages' %
version)
return result
def validate(self):
result = True
for version in self._supported_versions:
if not self._validate_ansible(version):
result = False
elif not self._validate_packages(version):
result = False
return result
def _getAnsible(self, version):
if not version:
version = self.default_version
ansible = self._supported_versions.get(version)
if not ansible:
raise Exception('Requested ansible version %s not found' % version)
return ansible
def getAnsibleCommand(self, version, command='ansible-playbook'):
ansible = self._getAnsible(version)
venv_path = ansible.venv_path
if not venv_path:
raise Exception('Requested ansible version \'%s\' is not '
'installed' % version)
return os.path.join(ansible.venv_path, 'bin', command)
def getAnsibleInstallDir(self, version):
ansible = self._getAnsible(version)
venv_path = ansible.venv_path
if not venv_path:
raise Exception('Requested ansible version \'%s\' is not '
'installed' % version)
return venv_path
def getAnsibleDir(self, version):
ansible = self._getAnsible(version)
return os.path.join(self.zuul_ansible_dir, ansible.version)
def getAnsiblePluginDir(self, version):
return os.path.join(self.getAnsibleDir(version), 'zuul', 'ansible')
def requestVersion(self, version):
if version not in self._supported_versions:
raise Exception(
'Requested ansible version \'%s\' is unknown. Supported '
'versions are %s' % (
version, ', '.join(self._supported_versions)))
def getSupportedVersions(self):
versions = []
for version in self._supported_versions:
versions.append((version, version == self.default_version))
return versions
def copyAnsibleFiles(self):
if os.path.exists(self.zuul_ansible_dir):
# Ensure we can delete the files by setting writtable mode
for dirpath, dirnames, filenames in os.walk(self.zuul_ansible_dir):
os.chmod(dirpath, 0o755)
for filename in filenames:
os.chmod(os.path.join(dirpath, filename), 0o600)
shutil.rmtree(self.zuul_ansible_dir)
library_path = os.path.dirname(os.path.abspath(zuul.ansible.__file__))
for ansible in self._supported_versions.values():
ansible_dir = os.path.join(self.zuul_ansible_dir, ansible.version)
plugin_dir = os.path.join(ansible_dir, 'zuul', 'ansible')
source_path = os.path.join(library_path, ansible.version)
os.makedirs(plugin_dir, exist_ok=True)
for fn in os.listdir(source_path):
if fn in ('__pycache__', 'base'):
continue
full_path = os.path.join(source_path, fn)
if os.path.isdir(full_path):
shutil.copytree(full_path, os.path.join(plugin_dir, fn))
else:
shutil.copy(os.path.join(source_path, fn), plugin_dir)
# We're copying zuul.ansible.* into a directory we are going
# to add to pythonpath, so our plugins can "import
# zuul.ansible". But we're not installing all of zuul, so
# create a __init__.py file for the stub "zuul" module.
module_paths = [
os.path.join(ansible_dir, 'zuul'),
os.path.join(ansible_dir, 'zuul', 'ansible'),
]
for fn in module_paths:
with open(os.path.join(fn, '__init__.py'), 'w'):
# Nothing to do here, we just want the file to exist.
pass