Skip to content

Commit

Permalink
ansible-test - Create injector scripts at runtime. (ansible#75761)
Browse files Browse the repository at this point in the history
* ansible-test - Create injector scripts at runtime.
* Set bootstrap.sh shebang at runtime.
* Remove shebang and execute bit from importer.
* Update shebang sanity test.
* Preserve line numbers.
  • Loading branch information
mattclay authored Sep 22, 2021
1 parent 440cf15 commit 5cb54e8
Show file tree
Hide file tree
Showing 26 changed files with 120 additions and 53 deletions.
1 change: 0 additions & 1 deletion MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ recursive-include test/integration *
recursive-include test/lib/ansible_test/config *.yml *.template
recursive-include test/lib/ansible_test/_data *.cfg *.ini *.ps1 *.txt *.yml coveragerc
recursive-include test/lib/ansible_test/_util *.cfg *.json *.ps1 *.psd1 *.py *.sh *.txt *.yml
recursive-include test/lib/ansible_test/_util/target/injector ansible ansible-config ansible-connection ansible-console ansible-doc ansible-galaxy ansible-inventory ansible-playbook ansible-pull ansible-test ansible-vault pytest
recursive-include test/lib/ansible_test/_util/controller/sanity/validate-modules validate-modules
recursive-include test/sanity *.json *.py *.txt
recursive-include test/support *.py *.ps1 *.psm1 *.cs
Expand Down
2 changes: 2 additions & 0 deletions changelogs/fragments/ansible-test-injector.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
minor_changes:
- ansible-test - The "injector" scripts are now generated at runtime to avoid issues with symlinks and shebangs.
4 changes: 2 additions & 2 deletions test/lib/ansible_test/_internal/ansible_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
ANSIBLE_TEST_DATA_ROOT,
ANSIBLE_BIN_PATH,
ANSIBLE_SOURCE_ROOT,
ANSIBLE_TEST_TARGET_ROOT,
ANSIBLE_TEST_TOOLS_ROOT,
get_ansible_version,
)
Expand All @@ -30,6 +29,7 @@
run_command,
ResultType,
intercept_python,
get_injector_path,
)

from .config import (
Expand Down Expand Up @@ -117,7 +117,7 @@ def ansible_environment(args, color=True, ansible_config=None):
# ansible-connection only requires the injector for code coverage
# the correct python interpreter is already selected using the sys.executable used to invoke ansible
ansible.update(dict(
ANSIBLE_CONNECTION_PATH=os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'injector', 'ansible-connection'),
ANSIBLE_CONNECTION_PATH=os.path.join(get_injector_path(), 'ansible-connection'),
))

if isinstance(args, PosixIntegrationConfig):
Expand Down
4 changes: 4 additions & 0 deletions test/lib/ansible_test/_internal/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from .util_common import (
ShellScriptTemplate,
set_shebang,
)

from .core_ci import (
Expand Down Expand Up @@ -48,7 +49,10 @@ def get_variables(self): # type: () -> t.Dict[str, str]
def get_script(self): # type: () -> str
"""Return a shell script to bootstrap the specified host."""
path = os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'setup', 'bootstrap.sh')

content = read_text_file(path)
content = set_shebang(content, '/bin/sh')

template = ShellScriptTemplate(content)

variables = self.get_variables()
Expand Down
13 changes: 4 additions & 9 deletions test/lib/ansible_test/_internal/commands/sanity/bin_symlinks.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
SanityTargets,
)

from ...constants import (
__file__ as symlink_map_full_path,
)

from ...test import (
TestResult,
)
Expand All @@ -26,12 +30,10 @@

from ...payload import (
ANSIBLE_BIN_SYMLINK_MAP,
__file__ as symlink_map_full_path,
)

from ...util import (
ANSIBLE_BIN_PATH,
ANSIBLE_TEST_TARGET_ROOT,
)


Expand All @@ -54,9 +56,6 @@ def test(self, args, targets): # type: (SanityConfig, SanityTargets) -> TestRes
bin_names = os.listdir(bin_root)
bin_paths = sorted(os.path.join(bin_root, path) for path in bin_names)

injector_root = os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'injector')
injector_names = os.listdir(injector_root)

errors = [] # type: t.List[t.Tuple[str, str]]

symlink_map_path = os.path.relpath(symlink_map_full_path, data_context().content.root)
Expand Down Expand Up @@ -95,10 +94,6 @@ def test(self, args, targets): # type: (SanityConfig, SanityTargets) -> TestRes
bin_path = os.path.join(bin_root, bin_name)
errors.append((bin_path, 'missing symlink to "%s" defined in ANSIBLE_BIN_SYMLINK_MAP in file "%s"' % (dest, symlink_map_path)))

if bin_name not in injector_names:
injector_path = os.path.join(injector_root, bin_name)
errors.append((injector_path, 'missing symlink to "python.py"'))

messages = [SanityMessage(message=message, path=os.path.relpath(path, data_context().content.root), confidence=100) for path, message in errors]

if errors:
Expand Down
20 changes: 4 additions & 16 deletions test/lib/ansible_test/_internal/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
import time
import typing as t

from .constants import (
ANSIBLE_BIN_SYMLINK_MAP,
)

from .config import (
IntegrationConfig,
ShellConfig,
Expand All @@ -33,22 +37,6 @@
tarfile.pwd = None
tarfile.grp = None

# this bin symlink map must exactly match the contents of the bin directory
# it is necessary for payload creation to reconstruct the bin directory when running ansible-test from an installed version of ansible
ANSIBLE_BIN_SYMLINK_MAP = {
'ansible': '../lib/ansible/cli/scripts/ansible_cli_stub.py',
'ansible-config': 'ansible',
'ansible-connection': '../lib/ansible/cli/scripts/ansible_connection_cli_stub.py',
'ansible-console': 'ansible',
'ansible-doc': 'ansible',
'ansible-galaxy': 'ansible',
'ansible-inventory': 'ansible',
'ansible-playbook': 'ansible',
'ansible-pull': 'ansible',
'ansible-test': '../test/lib/ansible_test/_util/target/cli/ansible_test_cli_stub.py',
'ansible-vault': 'ansible',
}


def create_payload(args, dst_path): # type: (CommonConfig, str) -> None
"""Create a payload for delegation."""
Expand Down
75 changes: 74 additions & 1 deletion test/lib/ansible_test/_internal/util_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,21 @@
import textwrap
import typing as t

from .constants import (
ANSIBLE_BIN_SYMLINK_MAP,
)

from .encoding import (
to_bytes,
)

from .util import (
cache,
display,
remove_tree,
MODE_DIRECTORY,
MODE_FILE_EXECUTE,
MODE_FILE,
PYTHON_PATHS,
raw_command,
ANSIBLE_TEST_DATA_ROOT,
Expand All @@ -32,6 +38,7 @@

from .io import (
make_dirs,
read_text_file,
write_text_file,
write_json_file,
)
Expand Down Expand Up @@ -226,6 +233,72 @@ def write_text_test_results(category, name, content): # type: (ResultType, str,
write_text_file(path, content, create_directories=True)


@cache
def get_injector_path(): # type: () -> str
"""Return the path to a directory which contains a `python.py` executable and associated injector scripts."""
injector_path = tempfile.mkdtemp(prefix='ansible-test-', suffix='-injector', dir='/tmp')

display.info(f'Initializing "{injector_path}" as the temporary injector directory.', verbosity=1)

injector_names = sorted(list(ANSIBLE_BIN_SYMLINK_MAP) + [
'importer.py',
'pytest',
])

scripts = (
('python.py', '/usr/bin/env python', MODE_FILE_EXECUTE),
('virtualenv.sh', '/usr/bin/env bash', MODE_FILE),
)

source_path = os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'injector')

for name in injector_names:
os.symlink('python.py', os.path.join(injector_path, name))

for name, shebang, mode in scripts:
src = os.path.join(source_path, name)
dst = os.path.join(injector_path, name)

script = read_text_file(src)
script = set_shebang(script, shebang)

write_text_file(dst, script)
os.chmod(dst, mode)

os.chmod(injector_path, MODE_DIRECTORY)

def cleanup_injector():
"""Remove the temporary injector directory."""
remove_tree(injector_path)

atexit.register(cleanup_injector)

return injector_path


def set_shebang(script, executable): # type: (str, str) -> str
"""Return the given script with the specified executable used for the shebang."""
prefix = '#!'
shebang = prefix + executable

overwrite = (
prefix,
'# auto-shebang',
'# shellcheck shell=',
)

lines = script.splitlines()

if any(lines[0].startswith(value) for value in overwrite):
lines[0] = shebang
else:
lines.insert(0, shebang)

script = '\n'.join(lines)

return script


def get_python_path(interpreter): # type: (str) -> str
"""Return the path to a directory which contains a `python` executable that runs the specified interpreter."""
python_path = PYTHON_PATHS.get(interpreter)
Expand Down Expand Up @@ -318,7 +391,7 @@ def intercept_python(
"""
env = env.copy()
cmd = list(cmd)
inject_path = os.path.join(ANSIBLE_TEST_TARGET_ROOT, 'injector')
inject_path = get_injector_path()

# make sure scripts (including injector.py) find the correct Python interpreter
if isinstance(python, VirtualPythonConfig):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ def main():
is_module = True
elif re.search('^test/support/[^/]+/collections/ansible_collections/[^/]+/[^/]+/plugins/modules/', path):
is_module = True
elif path.startswith('test/lib/ansible_test/_util/target/'):
pass
elif path == 'test/lib/ansible_test/_util/target/cli/ansible_test_cli_stub.py':
pass # ansible-test entry point must be executable and have a shebang
elif path.startswith('lib/') or path.startswith('test/lib/'):
if executable:
print('%s:%d:%d: should not be executable' % (path, 0, 0))
Expand Down
17 changes: 17 additions & 0 deletions test/lib/ansible_test/_util/target/common/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,20 @@
'default',
'unconfined',
]

# This bin symlink map must exactly match the contents of the bin directory.
# It is necessary for payload creation to reconstruct the bin directory when running ansible-test from an installed version of ansible.
# It is also used to construct the injector directory at runtime.
ANSIBLE_BIN_SYMLINK_MAP = {
'ansible': '../lib/ansible/cli/scripts/ansible_cli_stub.py',
'ansible-config': 'ansible',
'ansible-connection': '../lib/ansible/cli/scripts/ansible_connection_cli_stub.py',
'ansible-console': 'ansible',
'ansible-doc': 'ansible',
'ansible-galaxy': 'ansible',
'ansible-inventory': 'ansible',
'ansible-playbook': 'ansible',
'ansible-pull': 'ansible',
'ansible-test': '../test/lib/ansible_test/_util/target/cli/ansible_test_cli_stub.py',
'ansible-vault': 'ansible',
}
1 change: 0 additions & 1 deletion test/lib/ansible_test/_util/target/injector/ansible

This file was deleted.

1 change: 0 additions & 1 deletion test/lib/ansible_test/_util/target/injector/ansible-config

This file was deleted.

This file was deleted.

This file was deleted.

1 change: 0 additions & 1 deletion test/lib/ansible_test/_util/target/injector/ansible-doc

This file was deleted.

1 change: 0 additions & 1 deletion test/lib/ansible_test/_util/target/injector/ansible-galaxy

This file was deleted.

This file was deleted.

This file was deleted.

1 change: 0 additions & 1 deletion test/lib/ansible_test/_util/target/injector/ansible-pull

This file was deleted.

1 change: 0 additions & 1 deletion test/lib/ansible_test/_util/target/injector/ansible-test

This file was deleted.

1 change: 0 additions & 1 deletion test/lib/ansible_test/_util/target/injector/ansible-vault

This file was deleted.

1 change: 0 additions & 1 deletion test/lib/ansible_test/_util/target/injector/importer.py

This file was deleted.

1 change: 0 additions & 1 deletion test/lib/ansible_test/_util/target/injector/pytest

This file was deleted.

15 changes: 9 additions & 6 deletions test/lib/ansible_test/_util/target/injector/python.py
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env python
# auto-shebang
"""Provides an entry point for python scripts and python modules on the controller with the current python interpreter and optional code coverage collection."""
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
Expand Down Expand Up @@ -46,21 +46,24 @@ def main():
sys.exit('ERROR: Use `python -c` instead of `python.py -c` to avoid errors when code coverage is collected.')
elif name == 'pytest':
args += ['-m', 'pytest']
elif name == 'importer.py':
args += [find_program(name, False)]
else:
args += [find_executable(name)]
args += [find_program(name, True)]

args += sys.argv[1:]

os.execv(args[0], args)


def find_executable(name):
def find_program(name, executable): # type: (str, bool) -> str
"""
:type name: str
:rtype: str
Find and return the full path to the named program, optionally requiring it to be executable.
Raises an exception if the program is not found.
"""
path = os.environ.get('PATH', os.path.defpath)
seen = set([os.path.abspath(__file__)])
mode = os.F_OK | os.X_OK if executable else os.F_OK

for base in path.split(os.path.pathsep):
candidate = os.path.abspath(os.path.join(base, name))
Expand All @@ -70,7 +73,7 @@ def find_executable(name):

seen.add(candidate)

if os.path.exists(candidate) and os.access(candidate, os.F_OK | os.X_OK):
if os.path.exists(candidate) and os.access(candidate, mode):
return candidate

raise Exception('Executable "%s" not found in path: %s' % (name, path))
Expand Down
2 changes: 1 addition & 1 deletion test/lib/ansible_test/_util/target/injector/virtualenv.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env bash
# shellcheck shell=bash
# Create and activate a fresh virtual environment with `source virtualenv.sh`.

rm -rf "${OUTPUT_DIR}/venv"
Expand Down
1 change: 0 additions & 1 deletion test/lib/ansible_test/_util/target/sanity/import/importer.py
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#!/usr/bin/env python
"""Import the given python module(s) and report error(s) encountered."""
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
Expand Down
2 changes: 1 addition & 1 deletion test/lib/ansible_test/_util/target/setup/bootstrap.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/bin/sh
# shellcheck shell=sh

set -eu

Expand Down

0 comments on commit 5cb54e8

Please sign in to comment.