Repository: ansible/ansible-runner

Issue:
bwarp can't execute any binaries due to missing /lib symlink
When testing sandbox execution, we were facing an issue where `bwrap` couldn't execute any commands:
```bash
gbwrap: execvp ansible-playbook: No such file or directory
```

In debug mode, we pulled the command out ourselves and tested it with a system binary which also resulted in the same error:
```bash
bwrap --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/lib64 /lib64 --bind /private_data_dir /private_data_dir --chdir /tmp/runner/runner_di_abcdefg id
bwrap: execvp id: no such file or directory
```

My system information:
```bash
$ uname -a
Linux fedora 6.0.11-200.fc36.aarch64 #1 SMP PREEMPT_DYNAMIC Fri Dec 2 20:16:22 UTC 2022 aarch64 GNU/Linux
$ bwrap --version
bubblewrap 0.7.0
```

I was able to get it working by adding `--symlink usr/lib /lib` to the arguments for `bwrap`, though I'm not sure if this has a wider ranging effect on x86_64 systems. It should be fine since most modern Linux systems symlink /lib to /usr/lib as well.

https://github.com/ansible/ansible-runner/blob/0594cea6e415af0d186b611e95e04195317cf816/src/ansible_runner/config/runner.py#L342C12-L342C12

Discussion:

Patch:
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 35f8e29..32dcfcb 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -30,4 +30,4 @@ sphinxcontrib-jquery==4.1
 sphinxcontrib-jsmath==1.0.1
 sphinxcontrib-qthelp==1.0.3
 sphinxcontrib-serializinghtml==1.1.5
-urllib3==1.26.15
+urllib3==1.26.18
diff --git a/src/ansible_runner/config/runner.py b/src/ansible_runner/config/runner.py
index 70379ef..0633a23 100644
--- a/src/ansible_runner/config/runner.py
+++ b/src/ansible_runner/config/runner.py
@@ -135,7 +135,7 @@ class RunnerConfig(BaseConfig):
             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, symlinks=True)
+                shutil.copytree(self.project_dir, self.directory_isolation_path, dirs_exist_ok=True, symlinks=True)
 
         self.prepare_inventory()
         self.prepare_command()
@@ -158,14 +158,20 @@ class RunnerConfig(BaseConfig):
     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):
         """
@@ -339,6 +345,7 @@ class RunnerConfig(BaseConfig):
             '--ro-bind', '/etc', '/etc',
             '--ro-bind', '/usr', '/usr',
             '--ro-bind', '/opt', '/opt',
+            '--symlink', 'usr/lib', '/lib',
             '--symlink', 'usr/lib64', '/lib64',
         ])
 
diff --git a/src/ansible_runner/utils/base64io.py b/src/ansible_runner/utils/base64io.py
index 183be94..0a2422f 100644
--- a/src/ansible_runner/utils/base64io.py
+++ b/src/ansible_runner/utils/base64io.py
@@ -38,8 +38,7 @@ __version__ = "1.0.3"
 _LOGGER = logging.getLogger(LOGGER_NAME)
 
 
-def _to_bytes(data):
-    # type: (AnyStr) -> bytes
+def _to_bytes(data: AnyStr) -> bytes:
     """Convert input data from either string or bytes to bytes.
 
     :param data: Data to convert
@@ -74,8 +73,7 @@ class Base64IO(io.IOBase):
 
     closed = False
 
-    def __init__(self, wrapped):
-        # type: (Base64IO, IO) -> None
+    def __init__(self, wrapped: IO) -> None:
         """Check for required methods on wrapped stream and set up read buffer.
 
         :raises TypeError: if ``wrapped`` does not have attributes needed to determine the stream's state
@@ -91,17 +89,17 @@ class Base64IO(io.IOBase):
         self.__write_buffer = b""
 
     def __enter__(self):
-        # type: () -> Base64IO
         """Return self on enter."""
         return self
 
-    def __exit__(self, exc_type, exc_value, traceback):
-        # type: (Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType]) -> None
+    def __exit__(self,
+                 exc_type: Optional[Type[BaseException]],
+                 exc_value: Optional[BaseException],
+                 traceback: Optional[TracebackType]) -> None:
         """Properly close self on exit."""
         self.close()
 
-    def close(self):
-        # type: () -> None
+    def close(self) -> None:
         """Close this stream, encoding and writing any buffered bytes is present.
 
         .. note::
@@ -113,8 +111,7 @@ class Base64IO(io.IOBase):
             self.__write_buffer = b""
         self.closed = True
 
-    def _passthrough_interactive_check(self, method_name):
-        # type: (str) -> bool
+    def _passthrough_interactive_check(self, method_name: str) -> bool:
         """Attempt to call the specified method on the wrapped stream and return the result.
 
         If the method is not found on the wrapped stream, return False.
@@ -128,8 +125,7 @@ class Base64IO(io.IOBase):
             return False
         return method()
 
-    def writable(self):
-        # type: () -> bool
+    def writable(self) -> bool:
         """Determine if the stream can be written to.
 
         Delegates to wrapped stream when possible.
@@ -139,8 +135,7 @@ class Base64IO(io.IOBase):
         """
         return self._passthrough_interactive_check("writable")
 
-    def readable(self):
-        # type: () -> bool
+    def readable(self) -> bool:
         """Determine if the stream can be read from.
 
         Delegates to wrapped stream when possible.
@@ -150,13 +145,11 @@ class Base64IO(io.IOBase):
         """
         return self._passthrough_interactive_check("readable")
 
-    def flush(self):
-        # type: () -> None
+    def flush(self) -> None:
         """Flush the write buffer of the wrapped stream."""
         return self.__wrapped.flush()
 
-    def write(self, b):
-        # type: (bytes) -> int
+    def write(self, b: bytes) -> int:
         """Base64-encode the bytes and write them to the wrapped stream.
 
         Any bytes that would require padding for the next write call are buffered until the
@@ -191,8 +184,7 @@ class Base64IO(io.IOBase):
         self.__write_buffer = _bytes_to_write[trailing_byte_pos:]
         return self.__wrapped.write(base64.b64encode(_bytes_to_write[:trailing_byte_pos]))
 
-    def _read_additional_data_removing_whitespace(self, data, total_bytes_to_read):
-        # type: (bytes, int) -> bytes
+    def _read_additional_data_removing_whitespace(self, data: bytes, total_bytes_to_read: int) -> bytes:
         """Read additional data from wrapped stream until we reach the desired number of bytes.
 
         .. note::
@@ -224,8 +216,7 @@ class Base64IO(io.IOBase):
             _remaining_bytes_to_read = total_bytes_to_read - _data_buffer.tell()
         return _data_buffer.getvalue()
 
-    def read(self, b=-1):
-        # type: (int) -> bytes
+    def read(self, b=-1) -> bytes:
         """Read bytes from wrapped stream, base64-decoding before return.
 
         .. note::


Test patch:
diff --git a/test/integration/__init__.py b/test/integration/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/integration/conftest.py b/test/integration/conftest.py
index de098cf..559031a 100644
--- a/test/integration/conftest.py
+++ b/test/integration/conftest.py
@@ -1,13 +1,15 @@
 import json
 import os
 import subprocess
-import yaml
 import pathlib
-import pytest
-import pexpect
 import random
+
 from string import ascii_lowercase
 
+import pexpect
+import pytest
+import yaml
+
 from ansible_runner.config.runner import RunnerConfig
 
 here = pathlib.Path(__file__).parent
@@ -15,22 +17,22 @@ here = pathlib.Path(__file__).parent
 
 @pytest.fixture(scope='function')
 def rc(tmp_path):
-    rc = RunnerConfig(str(tmp_path))
-    rc.suppress_ansible_output = True
-    rc.expect_passwords = {
+    conf = RunnerConfig(str(tmp_path))
+    conf.suppress_ansible_output = True
+    conf.expect_passwords = {
         pexpect.TIMEOUT: None,
         pexpect.EOF: None
     }
-    rc.cwd = str(tmp_path)
-    rc.env = {}
-    rc.job_timeout = 10
-    rc.idle_timeout = 0
-    rc.pexpect_timeout = 2.
-    rc.pexpect_use_poll = True
-    return rc
+    conf.cwd = str(tmp_path)
+    conf.env = {}
+    conf.job_timeout = 10
+    conf.idle_timeout = 0
+    conf.pexpect_timeout = 2.
+    conf.pexpect_use_poll = True
+    return conf
 
 
-class CompletedProcessProxy(object):
+class CompletedProcessProxy:
 
     def __init__(self, result):
         self.result = result
@@ -54,7 +56,7 @@ class CompletedProcessProxy(object):
 
 
 @pytest.fixture(scope='function')
-def cli(request):
+def cli():
     def run(args, *a, **kw):
         if not kw.pop('bare', None):
             args = ['ansible-runner'] + args
@@ -73,7 +75,7 @@ def cli(request):
         })
 
         try:
-            ret = CompletedProcessProxy(subprocess.run(' '.join(args), shell=True, *a, **kw))
+            ret = CompletedProcessProxy(subprocess.run(' '.join(args), check=kw.pop('check'), shell=True, *a, **kw))
         except subprocess.CalledProcessError as err:
             pytest.fail(
                 f"Running {err.cmd} resulted in a non-zero return code: {err.returncode} - stdout: {err.stdout}, stderr: {err.stderr}"
@@ -84,7 +86,7 @@ def cli(request):
 
 
 @pytest.fixture
-def container_image(request, cli, tmp_path):
+def container_image(request, cli, tmp_path):  # pylint: disable=W0621
     try:
         containerized = request.getfixturevalue('containerized')
         if not containerized:
@@ -104,7 +106,7 @@ def container_image(request, cli, tmp_path):
         bare=True,
     )
 
-    wheel = next(tmp_path.glob('*.whl'))
+    wheel = next(tmp_path.glob('*.whl'))  # pylint: disable=R1708
 
     runtime = request.getfixturevalue('runtime')
     dockerfile_path = tmp_path / 'Dockerfile'
diff --git a/test/integration/containerized/__init__.py b/test/integration/containerized/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/integration/containerized/test_cli_containerized.py b/test/integration/containerized/test_cli_containerized.py
index 3f770f0..ef709e1 100644
--- a/test/integration/containerized/test_cli_containerized.py
+++ b/test/integration/containerized/test_cli_containerized.py
@@ -3,12 +3,11 @@ import os
 import signal
 import sys
 
+from test.utils.common import iterate_timeout
 from uuid import uuid4
 
 import pytest
 
-from test.utils.common import iterate_timeout
-
 
 @pytest.mark.test_all_runtimes
 def test_module_run(cli, project_fixtures, runtime, container_image):
diff --git a/test/integration/containerized/test_container_management.py b/test/integration/containerized/test_container_management.py
index 2f8212d..c11b4ff 100644
--- a/test/integration/containerized/test_container_management.py
+++ b/test/integration/containerized/test_container_management.py
@@ -14,7 +14,7 @@ from ansible_runner.interface import run
 def is_running(cli, runtime, container_name):
     cmd = [runtime, 'ps', '-aq', '--filter', f'name={container_name}']
     r = cli(cmd, bare=True)
-    output = '{}{}'.format(r.stdout, r.stderr)
+    output = f'{r.stdout}{r.stderr}'
     print(' '.join(cmd))
     print(output)
     return output.strip()
@@ -24,7 +24,7 @@ class CancelStandIn:
     def __init__(self, runtime, cli, container_name, delay=0.2):
         self.runtime = runtime
         self.cli = cli
-        self.delay = 0.2
+        self.delay = delay
         self.container_name = container_name
         self.checked_running = False
         self.start_time = None
@@ -37,7 +37,7 @@ class CancelStandIn:
             return False
         # guard against false passes by checking for running container
         if not self.checked_running:
-            for i in range(5):
+            for _ in range(5):
                 if is_running(self.cli, self.runtime, self.container_name):
                     break
                 time.sleep(0.2)
diff --git a/test/integration/test___main__.py b/test/integration/test___main__.py
index 4df07a7..912d5c5 100644
--- a/test/integration/test___main__.py
+++ b/test/integration/test___main__.py
@@ -16,7 +16,7 @@ def random_string():
 
 
 def random_json(keys=None):
-    data = dict()
+    data = {}
     if keys:
         for key in keys:
             data[key] = random_string()
diff --git a/test/integration/test_config.py b/test/integration/test_config.py
index 7096052..137daa3 100644
--- a/test/integration/test_config.py
+++ b/test/integration/test_config.py
@@ -1,8 +1,8 @@
+import os
+
 from ansible_runner.config._base import BaseConfig
 from ansible_runner.interface import run
 
-import os
-
 
 def test_combine_python_and_file_settings(project_fixtures):
     rc = BaseConfig(private_data_dir=str(project_fixtures / 'job_env'), settings={'job_timeout': 40}, container_image='bar')
@@ -32,7 +32,7 @@ def test_custom_stdout_callback_via_host_environ(project_fixtures, mocker):
     assert '"msg": "Hello world!"' in stdout, stdout
 
 
-def test_custom_stdout_callback_via_envvars(project_fixtures, mocker):
+def test_custom_stdout_callback_via_envvars(project_fixtures):
     res = run(private_data_dir=str(project_fixtures / 'debug'), playbook='debug.yml', envvars={'ANSIBLE_STDOUT_CALLBACK': 'minimal'})
     with res.stdout as f:
         stdout = f.read()
diff --git a/test/integration/test_display_callback.py b/test/integration/test_display_callback.py
index e649a1d..153b8bd 100644
--- a/test/integration/test_display_callback.py
+++ b/test/integration/test_display_callback.py
@@ -4,9 +4,10 @@ import json
 import os
 import yaml
 
+import pytest
+
 from ansible_runner.interface import init_runner
 
-import pytest
 
 HERE = os.path.abspath(os.path.dirname(__file__))
 
@@ -71,9 +72,9 @@ def executor(tmp_path, request):
     {'ANSIBLE_CALLBACK_PLUGINS': ''}],
     ids=['local-callback-plugin', 'no-callback-plugin']
 )
-def test_callback_plugin_receives_events(executor, event, playbook, envvars):
+def test_callback_plugin_receives_events(executor, event, playbook, envvars):  # pylint: disable=W0613,W0621
     executor.run()
-    assert len(list(executor.events))
+    assert list(executor.events)
     assert event in [task['event'] for task in executor.events]
 
 
@@ -156,9 +157,9 @@ def test_callback_plugin_receives_events(executor, event, playbook, envvars):
         ignore_errors: yes
 '''},  # noqa
 ])
-def test_callback_plugin_no_log_filters(executor, playbook):
+def test_callback_plugin_no_log_filters(executor, playbook):  # pylint: disable=W0613,W0621
     executor.run()
-    assert len(list(executor.events))
+    assert list(executor.events)
     assert 'SENSITIVE' not in json.dumps(list(executor.events))
 
 
@@ -175,7 +176,7 @@ def test_callback_plugin_no_log_filters(executor, playbook):
     - uri: url=https://example.org url_username="PUBLIC" url_password="PRIVATE"
 '''},  # noqa
 ])
-def test_callback_plugin_task_args_leak(executor, playbook):
+def test_callback_plugin_task_args_leak(executor, playbook):  # pylint: disable=W0613,W0621
     executor.run()
     events = list(executor.events)
     assert events[0]['event'] == 'playbook_on_start'
@@ -212,7 +213,7 @@ def test_callback_plugin_task_args_leak(executor, playbook):
         },  # noqa
     ],
 )
-def test_resolved_actions(executor, playbook, skipif_pre_ansible212):
+def test_resolved_actions(executor, playbook, skipif_pre_ansible212):  # pylint: disable=W0613,W0621
     executor.run()
     events = list(executor.events)
 
@@ -237,7 +238,7 @@ def test_resolved_actions(executor, playbook, skipif_pre_ansible212):
     - debug: msg="{{ command_register.results|map(attribute='stdout')|list }}"
 '''},  # noqa
 ])
-def test_callback_plugin_censoring_does_not_overwrite(executor, playbook):
+def test_callback_plugin_censoring_does_not_overwrite(executor, playbook):  # pylint: disable=W0613,W0621
     executor.run()
     events = list(executor.events)
     assert events[0]['event'] == 'playbook_on_start'
@@ -246,7 +247,7 @@ def test_callback_plugin_censoring_does_not_overwrite(executor, playbook):
     # task 1
     assert events[2]['event'] == 'playbook_on_task_start'
     # Ordering of task and item events may differ randomly
-    assert set(['runner_on_start', 'runner_item_on_ok', 'runner_on_ok']) == set([data['event'] for data in events[3:6]])
+    assert set(['runner_on_start', 'runner_item_on_ok', 'runner_on_ok']) == {data['event'] for data in events[3:6]}
 
     # task 2 no_log=True
     assert events[6]['event'] == 'playbook_on_task_start'
@@ -264,9 +265,9 @@ def test_callback_plugin_censoring_does_not_overwrite(executor, playbook):
     - shell: echo "Hello, World!"
 '''},  # noqa
 ])
-def test_callback_plugin_strips_task_environ_variables(executor, playbook):
+def test_callback_plugin_strips_task_environ_variables(executor, playbook):  # pylint: disable=W0613,W0621
     executor.run()
-    assert len(list(executor.events))
+    assert list(executor.events)
     for event in list(executor.events):
         assert os.environ['PATH'] not in json.dumps(event)
 
@@ -282,7 +283,7 @@ def test_callback_plugin_strips_task_environ_variables(executor, playbook):
           foo: "bar"
 '''},  # noqa
 ])
-def test_callback_plugin_saves_custom_stats(executor, playbook):
+def test_callback_plugin_saves_custom_stats(executor, playbook):  # pylint: disable=W0613,W0621
     executor.run()
     for event in executor.events:
         event_data = event.get('event_data', {})
@@ -308,9 +309,9 @@ def test_callback_plugin_saves_custom_stats(executor, playbook):
         - my_handler
 '''},  # noqa
 ])
-def test_callback_plugin_records_notify_events(executor, playbook):
+def test_callback_plugin_records_notify_events(executor, playbook):  # pylint: disable=W0613,W0621
     executor.run()
-    assert len(list(executor.events))
+    assert list(executor.events)
     notify_events = [x for x in executor.events if x['event'] == 'playbook_on_notify']
     assert len(notify_events) == 1
     assert notify_events[0]['event_data']['handler'] == 'my_handler'
@@ -332,12 +333,12 @@ def test_callback_plugin_records_notify_events(executor, playbook):
         url_password: "{{ pw }}"
 '''},  # noqa
 ])
-def test_module_level_no_log(executor, playbook):
+def test_module_level_no_log(executor, playbook):  # pylint: disable=W0613,W0621
     # It's possible for `no_log=True` to be defined at the _module_ level,
     # e.g., for the URI module password parameter
     # This test ensures that we properly redact those
     executor.run()
-    assert len(list(executor.events))
+    assert list(executor.events)
     assert 'john-jacob-jingleheimer-schmidt' in json.dumps(list(executor.events))
     assert 'SENSITIVE' not in json.dumps(list(executor.events))
 
@@ -354,15 +355,15 @@ def test_output_when_given_invalid_playbook(tmp_path):
     #
     # But no test validated it.  This does that.
     private_data_dir = str(tmp_path)
-    executor = init_runner(
+    ex = init_runner(
         private_data_dir=private_data_dir,
         inventory='localhost ansible_connection=local ansible_python_interpreter="{{ ansible_playbook_python }}"',
         envvars={"ANSIBLE_DEPRECATION_WARNINGS": "False"},
         playbook=os.path.join(private_data_dir, 'fake_playbook.yml')
     )
 
-    executor.run()
-    with executor.stdout as f:
+    ex.run()
+    with ex.stdout as f:
         stdout = f.read()
     assert "ERROR! the playbook:" in stdout
     assert "could not be found" in stdout
@@ -391,20 +392,20 @@ def test_output_when_given_non_playbook_script(tmp_path):
     with open(os.path.join(private_data_dir, "env", "settings"), 'w') as settings_file:
         settings_file.write("pexpect_timeout: 0.2")
 
-    executor = init_runner(
+    ex = init_runner(
         private_data_dir=private_data_dir,
         inventory='localhost ansible_connection=local ansible_python_interpreter="{{ ansible_playbook_python }}"',
         envvars={"ANSIBLE_DEPRECATION_WARNINGS": "False"}
     )
 
-    executor.run()
+    ex.run()
 
-    with executor.stdout as f:
+    with ex.stdout as f:
         stdout = f.readlines()
     assert stdout[0].strip() == "hi world"
     assert stdout[1].strip() == "goodbye world"
 
-    events = list(executor.events)
+    events = list(ex.events)
 
     assert len(events) == 2
     assert events[0]['event'] == 'verbose'
@@ -425,7 +426,7 @@ def test_output_when_given_non_playbook_script(tmp_path):
         msg: "{{ ('F' * 150) | list }}"
 '''},  # noqa
 ])
-def test_large_stdout_parsing_when_using_json_output(executor, playbook):
+def test_large_stdout_parsing_when_using_json_output(executor, playbook):  # pylint: disable=W0613,W0621
     # When the json flag is used, it is possible to output more data than
     # pexpect's maxread default of 2000 characters.  As a result, if not
     # handled properly, the stdout can end up being corrupted with partial
diff --git a/test/integration/test_events.py b/test/integration/test_events.py
index bf7905e..add859f 100644
--- a/test/integration/test_events.py
+++ b/test/integration/test_events.py
@@ -28,8 +28,7 @@ def test_basic_events(containerized, runtime, tmp_path, container_image, is_run_
         thread.join()  # ensure async run finishes
 
     event_types = [x['event'] for x in r.events if x['event'] != 'verbose']
-    okay_events = [x for x in filter(lambda x: 'event' in x and x['event'] == 'runner_on_ok',
-                                     r.events)]
+    okay_events = list(filter(lambda x: 'event' in x and x['event'] == 'runner_on_ok', r.events))
 
     assert event_types[0] == 'playbook_on_start'
     assert "playbook_on_play_start" in event_types
@@ -61,7 +60,7 @@ def test_basic_serializeable(tmp_path):
     r = run(private_data_dir=str(tmp_path),
             inventory=inv,
             playbook=[{'hosts': 'all', 'gather_facts': False, 'tasks': [{'debug': {'msg': "test"}}]}])
-    events = [x for x in r.events]
+    events = list(r.events)
     json.dumps(events)
 
 
@@ -79,7 +78,7 @@ def test_event_omission(tmp_path):
             continue
         events.append(x)
 
-    assert not any([x['event_data'] for x in events])
+    assert not any(x['event_data'] for x in events)
 
 
 def test_event_omission_except_failed(tmp_path):
@@ -101,12 +100,11 @@ def test_event_omission_except_failed(tmp_path):
     assert len(all_event_datas) == 1
 
 
-def test_runner_on_start(rc, tmp_path):
+def test_runner_on_start(tmp_path):
     r = run(private_data_dir=str(tmp_path),
             inventory='localhost ansible_connection=local ansible_python_interpreter="{{ ansible_playbook_python }}"',
             playbook=[{'hosts': 'all', 'gather_facts': False, 'tasks': [{'debug': {'msg': "test"}}]}])
-    start_events = [x for x in filter(lambda x: 'event' in x and x['event'] == 'runner_on_start',
-                                      r.events)]
+    start_events = list(filter(lambda x: 'event' in x and x['event'] == 'runner_on_start', r.events))
     assert len(start_events) == 1
 
 
diff --git a/test/integration/test_interface.py b/test/integration/test_interface.py
index 0b60916..457ea71 100644
--- a/test/integration/test_interface.py
+++ b/test/integration/test_interface.py
@@ -77,11 +77,11 @@ def get_env_data(res):
         )
         if found and 'res' in event['event_data']:
             return event['event_data']['res']
-    else:
-        print('output:')
-        with res.stdout as f:
-            print(f.read())
-        raise RuntimeError('Count not find look_at_environment task from playbook')
+
+    print('output:')
+    with res.stdout as f:
+        print(f.read())
+    raise RuntimeError('Count not find look_at_environment task from playbook')
 
 
 def test_env_accuracy(request, project_fixtures):
@@ -117,7 +117,7 @@ def test_env_accuracy(request, project_fixtures):
     assert os.path.exists(printenv_example / "env/envvars") == 1
 
 
-def test_no_env_files(request, project_fixtures):
+def test_no_env_files(project_fixtures):
     printenv_example = project_fixtures / 'printenv'
     os.environ['SET_BEFORE_TEST'] = 'MADE_UP_VALUE'
 
@@ -174,7 +174,7 @@ def test_env_accuracy_inside_container(request, project_fixtures, runtime, conta
     # all environment variables, particularly those set by the entrypoint script
     for key, value in expected_env.items():
         assert key in actual_env
-        assert actual_env[key] == value, 'Reported value wrong for {0} env var'.format(key)
+        assert actual_env[key] == value, f'Reported value wrong for {key} env var'
 
     assert env_data['cwd'] == res.config.cwd
 
@@ -230,7 +230,7 @@ def test_run_command(project_fixtures):
 
 
 def test_run_command_injection_error():
-    out, err, rc = run_command(
+    _, err, rc = run_command(
         executable_cmd='whoami',
         cmdline_args=[';hostname'],
         runner_mode='subprocess',
@@ -241,7 +241,7 @@ def test_run_command_injection_error():
 
 @pytest.mark.test_all_runtimes
 def test_run_command_injection_error_within_container(runtime, container_image):
-    out, err, rc = run_command(
+    _, err, rc = run_command(
         executable_cmd='whoami',
         cmdline_args=[';hostname'],
         runner_mode='subprocess',
@@ -278,7 +278,7 @@ def test_run_ansible_command_within_container(project_fixtures, runtime, contain
 def test_run_script_within_container(project_fixtures, runtime, container_image):
     private_data_dir = project_fixtures / 'debug'
     script_path = project_fixtures / 'files'
-    container_volume_mounts = ["{}:{}:Z".format(script_path, script_path)]
+    container_volume_mounts = [f"{script_path}:{script_path}:Z"]
     container_kwargs = {
         'process_isolation_executable': runtime,
         'process_isolation': True,
@@ -437,6 +437,7 @@ def test_run_role(project_fixtures):
     assert 'Hello World!' in stdout
 
 
+# pylint: disable=W0613
 def test_get_role_list(project_fixtures, skipif_pre_ansible211):
     """
     Test get_role_list() running locally, specifying a playbook directory
@@ -450,7 +451,7 @@ def test_get_role_list(project_fixtures, skipif_pre_ansible211):
         }
     }
 
-    resp, err = get_role_list(playbook_dir=pdir)
+    resp, _ = get_role_list(playbook_dir=pdir)
     assert isinstance(resp, dict)
 
     # So that tests can work locally, where multiple roles might be returned,
@@ -478,7 +479,7 @@ def test_get_role_list_within_container(project_fixtures, runtime, skipif_pre_an
         'process_isolation': True,
         'container_image': container_image,
     }
-    resp, err = get_role_list(private_data_dir=pdir, playbook_dir="/runner/project", **container_kwargs)
+    resp, _ = get_role_list(private_data_dir=pdir, playbook_dir="/runner/project", **container_kwargs)
     assert isinstance(resp, dict)
     assert resp == expected
 
@@ -512,7 +513,7 @@ def test_get_role_argspec(project_fixtures, skipif_pre_ansible211):
         }
     }
 
-    resp, err = get_role_argspec('Into_The_Mystic', playbook_dir=use_role_example)
+    resp, _ = get_role_argspec('Into_The_Mystic', playbook_dir=use_role_example)
     assert isinstance(resp, dict)
     assert 'Into_The_Mystic' in resp
     assert resp['Into_The_Mystic']['entry_points'] == expected_epoint
@@ -554,7 +555,55 @@ def test_get_role_argspec_within_container(project_fixtures, runtime, skipif_pre
         'process_isolation': True,
         'container_image': container_image,
     }
-    resp, err = get_role_argspec('Into_The_Mystic', private_data_dir=pdir, playbook_dir="/runner/project", **container_kwargs)
+    resp, _ = get_role_argspec('Into_The_Mystic', private_data_dir=pdir, playbook_dir="/runner/project", **container_kwargs)
     assert isinstance(resp, dict)
     assert 'Into_The_Mystic' in resp
     assert resp['Into_The_Mystic']['entry_points'] == expected_epoint
+
+
+class TestRelativePvtDataDirPaths:
+    """
+    Class to handle test setup/teardown of tests that need to change working
+    directory to test relative paths.
+    """
+
+    def setup_method(self):
+        self._old_workdir = os.getcwd()  # pylint: disable=W0201
+
+    def teardown_method(self):
+        os.chdir(self._old_workdir)
+
+    def test_inventory_as_string(self, project_fixtures):
+        """
+        Test of bug fix for GH issue #1216: https://github.com/ansible/ansible-runner/issues/1216
+
+        A relative private data directory combined with an inventory specified as a string
+        would produce an invalid inventory path being passed along to ansible.
+        """
+        os.chdir(str(project_fixtures))
+
+        inventory = 'hostA ansible_connection=local ansible_python_interpreter="{{ ansible_playbook_python }}"'
+
+        r = run(private_data_dir='debug',
+                inventory=inventory,
+                playbook='debug.yml')
+
+        with r.stdout as output:
+            text = output.read()
+
+        assert r.status == 'successful'
+        assert "No inventory was parsed" not in text
+
+    def test_default_inventory(self, project_fixtures):
+        """
+        Test relative pvt data dir with the default inventory.
+        """
+        os.chdir(str(project_fixtures))
+
+        r = run(private_data_dir='debug', playbook='debug.yml')
+
+        with r.stdout as output:
+            text = output.read()
+
+        assert r.status == 'successful'
+        assert "No inventory was parsed" not in text
diff --git a/test/integration/test_runner.py b/test/integration/test_runner.py
index e9d08dc..553d947 100644
--- a/test/integration/test_runner.py
+++ b/test/integration/test_runner.py
@@ -3,19 +3,19 @@
 import json
 import os
 import re
-import pytest
 import sys
 
-from ansible_runner import Runner
+from test.utils.common import iterate_timeout
 
-from ansible_runner.exceptions import AnsibleRunnerException
+import pytest
 
-from test.utils.common import iterate_timeout
+from ansible_runner import Runner
+from ansible_runner.exceptions import AnsibleRunnerException
 
 
 @pytest.mark.xfail(reason='Test is unstable')
 def test_password_prompt(rc):
-    rc.command = [sys.executable, '-c' 'import time; print(input("Password: "))']
+    rc.command = [sys.executable, '-c', 'import time; print(input("Password: "))']
     rc.expect_passwords[re.compile(r'Password:\s*?$', re.M)] = '1234'
     status, exitcode = Runner(config=rc).run()
     assert status == 'successful'
@@ -52,7 +52,7 @@ def test_run_command_with_unicode(rc):
         assert data.get('command') == ['echo', expected]
         assert 'cwd' in data
         assert isinstance(data.get('env'), dict)
-        assert u"䉪ቒ칸" in data.get('env')
+        assert "䉪ቒ칸" in data.get('env')
 
 
 def test_run_command_finished_callback(rc, mocker):
@@ -84,7 +84,7 @@ def test_run_command_explosive_cancel_callback(rc):
 
 
 def test_run_command_cancel_callback(rc):
-    def cancel(*args):
+    def cancel(*args):  # pylint: disable=W0613
         return True
     rc.command = ['sleep', '1']
     runner = Runner(config=rc, cancel_callback=cancel)
@@ -152,7 +152,7 @@ def test_run_command_events_missing(rc):
     status, exitcode = runner.run()
     assert status == 'successful'
     assert exitcode == 0
-    assert list(runner.events) == []
+    assert not list(runner.events)
 
 
 def test_run_command_stdout_missing(rc):
@@ -183,9 +183,9 @@ def test_run_command_ansible(rc):
     status, exitcode = runner.run()
     assert status == 'successful'
     assert exitcode == 0
-    assert list(runner.events) != []
+    assert list(runner.events)
     assert runner.stats != {}
-    assert list(runner.host_events('localhost')) != [], repr(list(runner.events))
+    assert list(runner.host_events('localhost')), repr(list(runner.events))
     with runner.stdout as f:
         stdout = f.read()
     assert stdout != ""
@@ -255,7 +255,7 @@ def test_set_fact_cache(rc):
     rc.host_pattern = "localhost"
     rc.prepare()
     runner = Runner(config=rc)
-    runner.set_fact_cache('localhost', dict(message='hello there'))
+    runner.set_fact_cache('localhost', {'message': 'hello there'})
     status, exitcode = runner.run()
     assert status == 'successful'
     assert exitcode == 0
@@ -270,10 +270,10 @@ def test_set_extra_vars(rc):
     rc.module = "debug"
     rc.module_args = "var=test_extra_vars"
     rc.host_pattern = "localhost"
-    rc.extra_vars = dict(test_extra_vars='hello there')
+    rc.extra_vars = {'test_extra_vars': 'hello there'}
     rc.prepare()
     runner = Runner(config=rc)
-    status, exitcode = runner.run()
+    runner.run()
     # stdout file can be subject to a race condition
     for _ in iterate_timeout(30.0, 'stdout file to be written with "hello there" in it', interval=0.2):
         with open(os.path.join(rc.artifact_dir, 'stdout')) as f:
diff --git a/test/integration/test_transmit_worker_process.py b/test/integration/test_transmit_worker_process.py
index fc89331..540e5dc 100644
--- a/test/integration/test_transmit_worker_process.py
+++ b/test/integration/test_transmit_worker_process.py
@@ -1,5 +1,6 @@
 import base64
 import io
+import json
 import os
 import socket
 import concurrent.futures
@@ -7,7 +8,6 @@ import time
 import threading
 
 import pytest
-import json
 
 from ansible_runner import run
 from ansible_runner.streaming import Transmitter, Worker, Processor
@@ -16,22 +16,23 @@ import ansible_runner.interface  # AWX import pattern
 
 
 class TestStreamingUsage:
+    # pylint: disable=W0201
 
     @pytest.fixture(autouse=True)
     def reset_self_props(self):
         self.status_data = None
 
-    def status_handler(self, status_data, runner_config=None):
+    def status_handler(self, status_data, runner_config=None):  # pylint: disable=W0613
         self.status_data = status_data
 
     def get_job_kwargs(self, job_type):
         """For this test scenaro, the ansible-runner interface kwargs"""
         if job_type == 'run':
-            job_kwargs = dict(playbook='debug.yml')
+            job_kwargs = {'playbook': 'debug.yml'}
         else:
-            job_kwargs = dict(module='setup', host_pattern='localhost')
+            job_kwargs = {'module': 'setup', 'host_pattern': 'localhost'}
         # also test use of user env vars
-        job_kwargs['envvars'] = dict(MY_ENV_VAR='bogus')
+        job_kwargs['envvars'] = {'MY_ENV_VAR': 'bogus'}
         return job_kwargs
 
     @staticmethod
@@ -115,6 +116,7 @@ class TestStreamingUsage:
         None,  # default disable, test sets envvar for keepalives
     ])
     def test_keepalive_setting(self, tmp_path, project_fixtures, keepalive_setting):
+        # pylint: disable=W0212
         verbosity = None
         output_corruption_test_mode = 0 < (keepalive_setting or 0) < 1
 
@@ -131,8 +133,8 @@ class TestStreamingUsage:
 
         worker_dir = tmp_path / 'for_worker'
         process_dir = tmp_path / 'for_process'
-        for dir in (worker_dir, process_dir):
-            dir.mkdir()
+        for directory in (worker_dir, process_dir):
+            directory.mkdir()
 
         outgoing_buffer = io.BytesIO()
         incoming_buffer = io.BytesIO()
@@ -141,7 +143,7 @@ class TestStreamingUsage:
 
         status, rc = Transmitter(
             _output=outgoing_buffer, private_data_dir=project_fixtures / 'sleep',
-            playbook='sleep.yml', extravars=dict(sleep_interval=2), verbosity=verbosity
+            playbook='sleep.yml', extravars={'sleep_interval': 2}, verbosity=verbosity
         ).run()
         assert rc in (None, 0)
         assert status == 'unstarted'
@@ -348,7 +350,7 @@ def transmit_stream(project_fixtures, tmp_path):
 
 
 @pytest.fixture
-def worker_stream(transmit_stream, tmp_path):
+def worker_stream(transmit_stream, tmp_path):  # pylint: disable=W0621
     ingoing_buffer = tmp_path / 'buffer2'  # basically how some demos work
     ingoing_buffer.touch()
 
@@ -364,7 +366,7 @@ def worker_stream(transmit_stream, tmp_path):
             return ingoing_buffer
 
 
-def test_worker_without_delete_no_dir(tmp_path, cli, transmit_stream):
+def test_worker_without_delete_no_dir(tmp_path, cli, transmit_stream):  # pylint: disable=W0621
     worker_dir = tmp_path / 'for_worker'
 
     with open(transmit_stream, 'rb') as stream:
@@ -375,7 +377,7 @@ def test_worker_without_delete_no_dir(tmp_path, cli, transmit_stream):
     assert worker_dir.joinpath('project', 'debug.yml').exists()
 
 
-def test_worker_without_delete_dir_exists(tmp_path, cli, transmit_stream):
+def test_worker_without_delete_dir_exists(tmp_path, cli, transmit_stream):  # pylint: disable=W0621
     worker_dir = tmp_path / 'for_worker'
     worker_dir.mkdir()
 
@@ -391,7 +393,7 @@ def test_worker_without_delete_dir_exists(tmp_path, cli, transmit_stream):
     assert test_file_path.exists()
 
 
-def test_worker_delete_no_dir(tmp_path, cli, transmit_stream):
+def test_worker_delete_no_dir(tmp_path, cli, transmit_stream):  # pylint: disable=W0621
     """
     Case where non-existing --delete is provided to worker command
     it should always delete everything both before and after the run
@@ -407,7 +409,7 @@ def test_worker_delete_no_dir(tmp_path, cli, transmit_stream):
     assert not worker_dir.joinpath('project', 'debug.yml').exists()
 
 
-def test_worker_delete_dir_exists(tmp_path, cli, transmit_stream):
+def test_worker_delete_dir_exists(tmp_path, cli, transmit_stream):  # pylint: disable=W0621
     """
     Case where non-existing --delete is provided to worker command
     it should always delete everything both before and after the run
@@ -427,7 +429,7 @@ def test_worker_delete_dir_exists(tmp_path, cli, transmit_stream):
     assert not worker_dir.joinpath('project', 'debug.yml').exists()
 
 
-def test_process_with_custom_ident(tmp_path, cli, worker_stream):
+def test_process_with_custom_ident(tmp_path, cli, worker_stream):  # pylint: disable=W0621
     process_dir = tmp_path / 'for_process'
     process_dir.mkdir()
 
@@ -441,7 +443,7 @@ def test_process_with_custom_ident(tmp_path, cli, worker_stream):
     assert (process_dir / 'artifacts' / 'custom_ident' / 'job_events').exists()
 
 
-def test_missing_private_dir_transmit(tmpdir):
+def test_missing_private_dir_transmit():
     outgoing_buffer = io.BytesIO()
 
     # Transmit
@@ -503,7 +505,7 @@ def test_unparsable_really_big_line_processor(tmp_path):
     process_dir.mkdir()
     incoming_buffer = io.BytesIO(bytes(f'not-json-data with extra garbage:{"f"*10000}', encoding='utf-8'))
 
-    def status_receiver(status_data, runner_config):
+    def status_receiver(status_data, runner_config):  # pylint: disable=W0613
         assert status_data['status'] == 'error'
         assert 'Failed to JSON parse a line from worker stream.' in status_data['job_explanation']
         assert 'not-json-data with extra garbage:ffffffffff' in status_data['job_explanation']
diff --git a/test/requirements.txt b/test/requirements.txt
index 8b38987..c227d90 100644
--- a/test/requirements.txt
+++ b/test/requirements.txt
@@ -1,11 +1,11 @@
-mypy==1.3.0
-pylint==2.17.4
+mypy==1.6.0
+pylint==3.0.1
 pytest==7.1.2
 pytest-cov
 pytest-mock
 pytest-timeout
 pytest-xdist==2.5.0
 types-pyyaml
-flake8==4.0.1
-yamllint
+flake8==6.1.0
+yamllint==1.32.0
 cryptography
diff --git a/test/unit/config/test_runner.py b/test/unit/config/test_runner.py
index 397fd9a..ea4ca97 100644
--- a/test/unit/config/test_runner.py
+++ b/test/unit/config/test_runner.py
@@ -239,7 +239,7 @@ def test_prepare_env_directory_isolation_from_settings(mocker, project_fixtures)
     mkdtemp.assert_called_once_with(prefix='runner_di_', dir='/tmp/runner')
 
     # The project files should be copied to the isolation path.
-    copy_tree.assert_called_once_with(rc.project_dir, rc.directory_isolation_path, symlinks=True)
+    copy_tree.assert_called_once_with(rc.project_dir, rc.directory_isolation_path, dirs_exist_ok=True, symlinks=True)
 
 
 def test_prepare_inventory(mocker):
@@ -249,13 +249,16 @@ def test_prepare_inventory(mocker):
     rc = RunnerConfig(private_data_dir='/')
     rc.prepare_inventory()
     assert rc.inventory == '/inventory'
+
     rc.inventory = '/tmp/inventory'
     rc.prepare_inventory()
     assert rc.inventory == '/tmp/inventory'
+
+    path_exists.return_value = False
     rc.inventory = 'localhost,anotherhost,'
     rc.prepare_inventory()
     assert rc.inventory == 'localhost,anotherhost,'
-    path_exists.return_value = False
+
     rc.inventory = None
     rc.prepare_inventory()
     assert rc.inventory is None
@@ -544,6 +547,7 @@ def test_bwrap_process_isolation_defaults(mocker):
         '--ro-bind', '/etc', '/etc',
         '--ro-bind', '/usr', '/usr',
         '--ro-bind', '/opt', '/opt',
+        '--symlink', 'usr/lib', '/lib',
         '--symlink', 'usr/lib64', '/lib64',
         '--bind', '/', '/',
         '--chdir', '/project',
@@ -598,6 +602,7 @@ def test_bwrap_process_isolation_and_directory_isolation(mocker, patch_private_d
         '--ro-bind', '/etc', '/etc',
         '--ro-bind', '/usr', '/usr',
         '--ro-bind', '/opt', '/opt',
+        '--symlink', 'usr/lib', '/lib',
         '--symlink', 'usr/lib64', '/lib64',
         '--bind', '/', '/',
         '--chdir', os.path.realpath(rc.directory_isolation_path),
@@ -636,6 +641,7 @@ def test_process_isolation_settings(mocker, tmp_path):
         '--ro-bind', '/etc', '/etc',
         '--ro-bind', '/usr', '/usr',
         '--ro-bind', '/opt', '/opt',
+        '--symlink', 'usr/lib', '/lib',
         '--symlink', 'usr/lib64', '/lib64',
     ]
     index = len(expected)