diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d65fd77bc0..2fa6fd3029 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,7 +207,7 @@ jobs: ansible-galaxy collection install -r molecule/requirements.yml sudo rm -f $(which kustomize) make kustomize - KUSTOMIZE_PATH=$(readlink -f bin/kustomize) molecule test -s kind + KUSTOMIZE_PATH=$(readlink -f bin/kustomize) molecule -v test -s kind env: AWX_TEST_IMAGE: awx AWX_TEST_VERSION: ci diff --git a/awx/conf/fields.py b/awx/conf/fields.py index eb5c962641..2ab3a9e8d9 100644 --- a/awx/conf/fields.py +++ b/awx/conf/fields.py @@ -13,6 +13,9 @@ from django.utils.translation import ugettext_lazy as _ from rest_framework.fields import BooleanField, CharField, ChoiceField, DictField, DateTimeField, EmailField, IntegerField, ListField, NullBooleanField # noqa from rest_framework.serializers import PrimaryKeyRelatedField # noqa +# AWX +from awx.main.constants import CONTAINER_VOLUMES_MOUNT_TYPES, MAX_ISOLATED_PATH_COLON_DELIMITER + logger = logging.getLogger('awx.conf.fields') # Use DRF fields to convert/validate settings: @@ -109,6 +112,49 @@ class StringListPathField(StringListField): self.fail('type_error', input_type=type(paths)) +class StringListIsolatedPathField(StringListField): + # Valid formats + # '/etc/pki/ca-trust' + # '/etc/pki/ca-trust:/etc/pki/ca-trust' + # '/etc/pki/ca-trust:/etc/pki/ca-trust:O' + + default_error_messages = { + 'type_error': _('Expected list of strings but got {input_type} instead.'), + 'path_error': _('{path} is not a valid path choice. You must provide an absolute path.'), + 'mount_error': _('{scontext} is not a valid mount option. Allowed types are {mount_types}'), + 'syntax_error': _('Invalid syntax. A string HOST-DIR[:CONTAINER-DIR[:OPTIONS]] is expected but got {path}.'), + } + + def to_internal_value(self, paths): + + if isinstance(paths, (list, tuple)): + for p in paths: + if not isinstance(p, str): + self.fail('type_error', input_type=type(p)) + if not p.startswith('/'): + self.fail('path_error', path=p) + + if p.count(':'): + if p.count(':') > MAX_ISOLATED_PATH_COLON_DELIMITER: + self.fail('syntax_error', path=p) + try: + src, dest, scontext = p.split(':') + except ValueError: + scontext = 'z' + src, dest = p.split(':') + finally: + for sp in [src, dest]: + if not len(sp): + self.fail('syntax_error', path=sp) + if not sp.startswith('/'): + self.fail('path_error', path=sp) + if scontext not in CONTAINER_VOLUMES_MOUNT_TYPES: + self.fail('mount_error', scontext=scontext, mount_types=CONTAINER_VOLUMES_MOUNT_TYPES) + return super(StringListIsolatedPathField, self).to_internal_value(sorted(paths)) + else: + self.fail('type_error', input_type=type(paths)) + + class URLField(CharField): # these lines set up a custom regex that allow numbers in the # top-level domain diff --git a/awx/main/conf.py b/awx/main/conf.py index 2fa477380f..94f448e3bc 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -259,10 +259,14 @@ register( register( 'AWX_ISOLATION_SHOW_PATHS', - field_class=fields.StringListField, + field_class=fields.StringListIsolatedPathField, required=False, label=_('Paths to expose to isolated jobs'), - help_text=_('List of paths that would otherwise be hidden to expose to isolated jobs. Enter one path per line.'), + help_text=_( + 'List of paths that would otherwise be hidden to expose to isolated jobs. Enter one path per line. ' + 'Volumes will be mounted from the execution node to the container. ' + 'The supported format is HOST-DIR[:CONTAINER-DIR[:OPTIONS]]. ' + ), category=_('Jobs'), category_slug='jobs', ) diff --git a/awx/main/constants.py b/awx/main/constants.py index 13addde4a5..36209c3334 100644 --- a/awx/main/constants.py +++ b/awx/main/constants.py @@ -85,3 +85,10 @@ RECEPTOR_PENDING = 'ansible-runner-???' # Naming pattern for AWX jobs in /tmp folder, like /tmp/awx_42_xiwm # also update awxkit.api.pages.unified_jobs if changed JOB_FOLDER_PREFIX = 'awx_%s_' + +# :z option tells Podman that two containers share the volume content with r/w +# :O option tells Podman to mount the directory from the host as a temporary storage using the overlay file system. +# see podman-run manpage for further details +# /HOST-DIR:/CONTAINER-DIR:OPTIONS +CONTAINER_VOLUMES_MOUNT_TYPES = ['z', 'O'] +MAX_ISOLATED_PATH_COLON_DELIMITER = 2 diff --git a/awx/main/tasks/jobs.py b/awx/main/tasks/jobs.py index 51aa89408d..be78472631 100644 --- a/awx/main/tasks/jobs.py +++ b/awx/main/tasks/jobs.py @@ -37,7 +37,13 @@ from gitdb.exc import BadName as BadGitName from awx.main.constants import ACTIVE_STATES from awx.main.dispatch.publish import task from awx.main.dispatch import get_local_queuename -from awx.main.constants import PRIVILEGE_ESCALATION_METHODS, STANDARD_INVENTORY_UPDATE_ENV, MINIMAL_EVENTS, JOB_FOLDER_PREFIX +from awx.main.constants import ( + PRIVILEGE_ESCALATION_METHODS, + STANDARD_INVENTORY_UPDATE_ENV, + MINIMAL_EVENTS, + JOB_FOLDER_PREFIX, + MAX_ISOLATED_PATH_COLON_DELIMITER, +) from awx.main.redact import UriCleaner from awx.main.models import ( Instance, @@ -147,6 +153,9 @@ class BaseTask(object): return os.path.abspath(os.path.join(os.path.dirname(__file__), *args)) def build_execution_environment_params(self, instance, private_data_dir): + """ + Return params structure to be executed by the container runtime + """ if settings.IS_K8S: return {} @@ -158,6 +167,9 @@ class BaseTask(object): "container_options": ['--user=root'], } + if settings.DEFAULT_CONTAINER_RUN_OPTIONS: + params['container_options'].extend(settings.DEFAULT_CONTAINER_RUN_OPTIONS) + if instance.execution_environment.credential: cred = instance.execution_environment.credential if all([cred.has_input(field_name) for field_name in ('host', 'username', 'password')]): @@ -176,9 +188,17 @@ class BaseTask(object): if settings.AWX_ISOLATION_SHOW_PATHS: params['container_volume_mounts'] = [] for this_path in settings.AWX_ISOLATION_SHOW_PATHS: - # Using z allows the dir to mounted by multiple containers + # Verify if a mount path and SELinux context has been passed + # Using z allows the dir to be mounted by multiple containers # Uppercase Z restricts access (in weird ways) to 1 container at a time - params['container_volume_mounts'].append(f'{this_path}:{this_path}:z') + if this_path.count(':') == MAX_ISOLATED_PATH_COLON_DELIMITER: + src, dest, scontext = this_path.split(':') + params['container_volume_mounts'].append(f'{src}:{dest}:{scontext}') + elif this_path.count(':') == MAX_ISOLATED_PATH_COLON_DELIMITER - 1: + src, dest = this_path.split(':') + params['container_volume_mounts'].append(f'{src}:{dest}:z') + else: + params['container_volume_mounts'].append(f'{this_path}:{this_path}:z') return params def build_private_data(self, instance, private_data_dir): diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 5fd36649aa..3fd721c1af 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -989,3 +989,8 @@ DEFAULT_EXECUTION_QUEUE_NAME = 'default' DEFAULT_EXECUTION_QUEUE_POD_SPEC_OVERRIDE = '' # Name of the default controlplane queue DEFAULT_CONTROL_PLANE_QUEUE_NAME = 'controlplane' + +# Extend container runtime attributes. +# For example, to disable SELinux in containers for podman +# DEFAULT_CONTAINER_RUN_OPTIONS = ['--security-opt', 'label=disable'] +DEFAULT_CONTAINER_RUN_OPTIONS = []