# Source code for ansible_runner.config.runner

    ############################
    # Licensed to the Apache Software Foundation (ASF) under one
    # or more contributor license agreements.  See the NOTICE file
    # distributed with this work for additional information
    # regarding copyright ownership.  The ASF licenses this file
    # to you 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.
    #

    # pylint: disable=W0201

    import json
    import logging
    import os
    import shlex
    import stat
    import tempfile
    import shutil

    from ansible_runner import output
    from ansible_runner.config._base import BaseConfig, BaseExecutionMode
    from ansible_runner.exceptions import ConfigurationError
    from ansible_runner.output import debug
    from ansible_runner.utils import register_for_cleanup

    logger = logging.getLogger('ansible-runner')

    class ExecutionMode():
        NONE = 0
        ANSIBLE = 1
        ANSIBLE_PLAYBOOK = 2
        RAW = 3

    class RunnerConfig(BaseConfig):
        """
        A ``Runner`` configuration object that's meant to encapsulate the configuration used by the
        :py:mod:`ansible_runner.runner.Runner` object to launch and manage the invocation of ``ansible``
        and ``ansible-playbook``

        Typically this object is initialized for you when using the standard ``run`` interfaces in :py:mod:`ansible_runner.interface`
        but can be used to construct the ``Runner`` configuration to be invoked elsewhere. It can also be overridden to provide different
        functionality to the Runner object.

        :Example:

        >>> rc = RunnerConfig(...)
        >>> r = Runner(config=rc)
        >>> r.run()

        """

        def __init__(self,
                     private_data_dir, playbook=None, inventory=None, roles_path=None, limit=None,
                     module=None, module_args=None, verbosity=None, host_pattern=None, binary=None,
                     extravars=None, suppress_output_file=False, suppress_ansible_output=False, process_isolation_path=None,
                     process_isolation_hide_paths=None, process_isolation_show_paths=None,
                     process_isolation_ro_paths=None, tags=None, skip_tags=None,
                     directory_isolation_base_path=None, forks=None, cmdline=None, omit_event_data=False,
                     only_failed_event_data=False, **kwargs):

            self.runner_mode = "pexpect"

            super().__init__(private_data_dir, **kwargs)

            self.playbook = playbook
            self.inventory = inventory
            self.roles_path = roles_path
            self.limit = limit
            self.module = module
            self.module_args = module_args
            self.host_pattern = host_pattern
            self.binary = binary
            self.extra_vars = extravars
            self.process_isolation_path = process_isolation_path
            self.process_isolation_path_actual = None
            self.process_isolation_hide_paths = process_isolation_hide_paths
            self.process_isolation_show_paths = process_isolation_show_paths
            self.process_isolation_ro_paths = process_isolation_ro_paths
            self.directory_isolation_path = directory_isolation_base_path
            self.verbosity = verbosity
            self.suppress_output_file = suppress_output_file
            self.suppress_ansible_output = suppress_ansible_output
            self.tags = tags
            self.skip_tags = skip_tags
            self.execution_mode = ExecutionMode.NONE
            self.forks = forks
            self.cmdline_args = cmdline

            self.omit_event_data = omit_event_data
            self.only_failed_event_data = only_failed_event_data

        @property
        def sandboxed(self):
            return self.process_isolation and self.process_isolation_executable not in self._CONTAINER_ENGINES

        def prepare(self):
            """
            Performs basic checks and then properly invokes

            - prepare_inventory
            - prepare_env
            - prepare_command

            It's also responsible for wrapping the command with the proper ssh agent invocation
            and setting early ANSIBLE_ environment variables.
            """
            # ansible_path = find_executable('ansible')
            # if ansible_path is None or not os.access(ansible_path, os.X_OK):
            #     raise ConfigurationError("Ansible not found. Make sure that it is installed.")
            if self.private_data_dir is None:
                raise ConfigurationError("Runner Base Directory is not defined")
            if self.module and self.playbook:
                raise ConfigurationError("Only one of playbook and module options are allowed")
            if not os.path.exists(self.artifact_dir):
                os.makedirs(self.artifact_dir, mode=0o700)

            # Since the `sandboxed` property references attributes that may come from `env/settings`,
            # we must call prepare_env() before we can reference it.
            self.prepare_env()

            if self.sandboxed and self.directory_isolation_path is not None:
                self.directory_isolation_path = tempfile.mkdtemp(prefix='runner_di_', dir=self.directory_isolation_path)
                if os.path.exists(self.project_dir):
                    output.debug(f"Copying directory tree from {self.project_dir} to {self.directory_isolation_path} for working directory isolation")
                    shutil.copytree(self.project_dir, self.directory_isolation_path, dirs_exist_ok=True, symlinks=True)

            self.prepare_inventory()
            self.prepare_command()

            if self.execution_mode == ExecutionMode.ANSIBLE_PLAYBOOK and self.playbook is None:
                raise ConfigurationError("Runner playbook required when running ansible-playbook")
            if self.execution_mode == ExecutionMode.ANSIBLE and self.module is None:
                raise ConfigurationError("Runner module required when running ansible")
            if self.execution_mode == ExecutionMode.NONE:
                raise ConfigurationError("No executable for runner to run")

            self.handle_command_wrap()

            debug('env:')
            for k, v in sorted(self.env.items()):
                debug(f' {k}: {v}')
            if hasattr(self, 'command') and isinstance(self.command, list):
                debug(f"command: {' '.join(self.command)}")

        def prepare_inventory(self):
            """
            Prepares the inventory default under ``private_data_dir`` if it's not overridden by the constructor.

            We make sure that if inventory is a path, that it is an absolute path.
            """
            if self.containerized:
                self.inventory = '/runner/inventory'
                return

            if self.inventory is None:
                # At this point we expect self.private_data_dir to be an absolute path
                # since that is expanded in the base class.
                if os.path.exists(os.path.join(self.private_data_dir, "inventory")):
                    self.inventory = os.path.join(self.private_data_dir, "inventory")
            elif isinstance(self.inventory, str) and os.path.exists(self.inventory):
                self.inventory = os.path.abspath(self.inventory)

        def prepare_env(self):
            """
            Manages reading environment metadata files under ``private_data_dir`` and merging/updating
            with existing values so the :py:class:`ansible_runner.runner.Runner` object can read and use them easily
            """

            # setup common env settings
            super().prepare_env()

            self.process_isolation_path = self.settings.get('process_isolation_path', self.process_isolation_path)
            self.process_isolation_hide_paths = self.settings.get('process_isolation_hide_paths', self.process_isolation_hide_paths)
            self.process_isolation_show_paths = self.settings.get('process_isolation_show_paths', self.process_isolation_show_paths)
            self.process_isolation_ro_paths = self.settings.get('process_isolation_ro_paths', self.process_isolation_ro_paths)
            self.directory_isolation_path = self.settings.get('directory_isolation_base_path', self.directory_isolation_path)
            self.directory_isolation_cleanup = bool(self.settings.get('directory_isolation_cleanup', True))

            if 'AD_HOC_COMMAND_ID' in self.env or not os.path.exists(self.project_dir):
                self.cwd = self.private_data_dir
            else:
                if self.directory_isolation_path is not None:
                    self.cwd = self.directory_isolation_path
                else:
                    self.cwd = self.project_dir

            if 'fact_cache' in self.settings:
                if 'fact_cache_type' in self.settings:
                    if self.settings['fact_cache_type'] == 'jsonfile':
                        self.fact_cache = os.path.join(self.artifact_dir, self.settings['fact_cache'])
                else:
                    self.fact_cache = os.path.join(self.artifact_dir, self.settings['fact_cache'])

            if self.roles_path:
                if isinstance(self.roles_path, list):
                    self.env['ANSIBLE_ROLES_PATH'] = ':'.join(self.roles_path)
                else:
                    self.env['ANSIBLE_ROLES_PATH'] = self.roles_path

            self.env["RUNNER_OMIT_EVENTS"] = str(self.omit_event_data)
            self.env["RUNNER_ONLY_FAILED_EVENTS"] = str(self.only_failed_event_data)

        def prepare_command(self):
            try:
                cmdline_args = self.loader.load_file('args', str, encoding=None)
                self.command = shlex.split(cmdline_args)
                self.execution_mode = ExecutionMode.RAW
            except ConfigurationError:
                self.command = self.generate_ansible_command()

        def generate_ansible_command(self):
            """
            Given that the ``RunnerConfig`` preparation methods have been run to gather the inputs this method
            will generate the ``ansible`` or ``ansible-playbook`` command that will be used by the
            :py:class:`ansible_runner.runner.Runner` object to start the process
            """
            if self.binary is not None:
                base_command = self.binary
                self.execution_mode = ExecutionMode.RAW
            elif self.module is not None:
                base_command = 'ansible'
                self.execution_mode = ExecutionMode.ANSIBLE
            else:
                base_command = 'ansible-playbook'
                self.execution_mode = ExecutionMode.ANSIBLE_PLAYBOOK

            exec_list = [base_command]

            try:
                if self.cmdline_args:
                    cmdline_args = self.cmdline_args
                else:
                    cmdline_args = self.loader.load_file('env/cmdline', str, encoding=None)

                args = shlex.split(cmdline_args)
                exec_list.extend(args)
            except ConfigurationError:
                pass

            if self.inventory is None:
                pass
            elif isinstance(self.inventory, list):
                for i in self.inventory:
                    exec_list.append("-i")
                    exec_list.append(i)
            else:
                exec_list.append("-i")
                exec_list.append(self.inventory)

            if self.limit is not None:
                exec_list.append("--limit")
                exec_list.append(self.limit)

            if self.loader.isfile('env/extravars'):
                if self.containerized:
                    extravars_path = '/runner/env/extravars'
                else:
                    extravars_path = self.loader.abspath('env/extravars')
                exec_list.extend(['-e', f'@{extravars_path}'])

            if self.extra_vars:
                if isinstance(self.extra_vars, dict) and self.extra_vars:
                    extra_vars_list = []
                    for k in self.extra_vars:
                        extra_vars_list.append(f"\"{k}\":{json.dumps(self.extra_vars[k])}")

                    exec_list.extend(
                        [
                            '-e',
                            f'{{{",".join(extra_vars_list)}}}'
                        ]
                    )
                elif self.loader.isfile(self.extra_vars):
                    exec_list.extend(['-e', f'@{self.loader.abspath(self.extra_vars)}'])

            if self.verbosity:
                v = 'v' * self.verbosity
                exec_list.append(f'-{v}')

            if self.tags:
                exec_list.extend(['--tags', self.tags])

            if self.skip_tags:
                exec_list.extend(['--skip-tags', self.skip_tags])

            if self.forks:
                exec_list.extend(['--forks', str(self.forks)])

            # Other parameters
            if self.execution_mode == ExecutionMode.ANSIBLE_PLAYBOOK:
                exec_list.append(self.playbook)
            elif self.execution_mode == ExecutionMode.ANSIBLE:
                exec_list.append("-m")
                exec_list.append(self.module)

                if self.module_args is not None:
                    exec_list.append("-a")
                    exec_list.append(self.module_args)

                if self.host_pattern is not None:
                    exec_list.append(self.host_pattern)

            return exec_list

        def build_process_isolation_temp_dir(self):
            '''
            Create a temporary directory for process isolation to use.
            '''
            path = tempfile.mkdtemp(prefix='ansible_runner_pi_', dir=self.process_isolation_path)
            os.chmod(path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)

            register_for_cleanup(path)

            return path

        def wrap_args_for_sandbox(self, args):
            '''
            Wrap existing command line with bwrap to restrict access to:
             - self.process_isolation_path (generally, /tmp) (except for own /tmp files)
            '''
            cwd = os.path.realpath(self.cwd)
            self.process_isolation_path_actual = self.build_process_isolation_temp_dir()
            new_args = [self.process_isolation_executable or 'bwrap']

            new_args.extend([
                '--die-with-parent',
                '--unshare-pid',
                '--dev-bind', '/dev', 'dev',
                '--proc', '/proc',
                '--dir', '/tmp',
                '--ro-bind', '/bin', '/bin',
                '--ro-bind', '/etc', '/etc',
                '--ro-bind', '/usr', '/usr',
                '--ro-bind', '/opt', '/opt',
                '--symlink', 'usr/lib', '/lib',
                '--symlink', 'usr/lib64', '/lib64',
            ])

            for path in sorted(set(self.process_isolation_hide_paths or [])):
                if not os.path.exists(path):
                    logger.debug('hide path not found: %s', path)
                    continue
                path = os.path.realpath(path)
                if os.path.isdir(path):
                    new_path = tempfile.mkdtemp(dir=self.process_isolation_path_actual)
                    os.chmod(new_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
                else:
                    handle, new_path = tempfile.mkstemp(dir=self.process_isolation_path_actual)
                    os.close(handle)
                    os.chmod(new_path, stat.S_IRUSR | stat.S_IWUSR)
                new_args.extend(['--bind', new_path, path])

            if self.private_data_dir:
                show_paths = [self.private_data_dir]
            else:
                show_paths = [cwd]

            for path in sorted(set(self.process_isolation_ro_paths or [])):
                if not os.path.exists(path):
                    logger.debug('read-only path not found: %s', path)
                    continue
                path = os.path.realpath(path)
                new_args.extend(['--ro-bind', path, path])

            show_paths.extend(self.process_isolation_show_paths or [])
            for path in sorted(set(show_paths)):
                if not os.path.exists(path):
                    logger.debug('show path not found: %s', path)
                    continue
                path = os.path.realpath(path)
                new_args.extend(['--bind', path, path])

            if self.execution_mode == ExecutionMode.ANSIBLE_PLAYBOOK:
                # playbook runs should cwd to the SCM checkout dir
                if self.directory_isolation_path is not None:
                    new_args.extend(['--chdir', os.path.realpath(self.directory_isolation_path)])
                else:
                    new_args.extend(['--chdir', os.path.realpath(self.project_dir)])
            elif self.execution_mode == ExecutionMode.ANSIBLE:
                # ad-hoc runs should cwd to the root of the private data dir
                new_args.extend(['--chdir', os.path.realpath(self.private_data_dir)])

            new_args.extend(args)
            return new_args

        def handle_command_wrap(self):
            # wrap args for ssh-agent
            if self.ssh_key_data:
                debug('ssh-agent agrs added')
                self.command = self.wrap_args_with_ssh_agent(self.command, self.ssh_key_path)

            if self.sandboxed:
                debug('sandbox enabled')
                self.command = self.wrap_args_for_sandbox(self.command)
            else:
                debug('sandbox disabled')

            if self.containerized:
                debug('containerization enabled')
                # container volume mount is handled explicitly for run API's
                # using 'container_volume_mounts' arguments
                base_execution_mode = BaseExecutionMode.NONE
                self.command = self.wrap_args_for_containerization(self.command, base_execution_mode, self.cmdline_args)
            else:
                debug('containerization disabled')