Skip to content

Commit

Permalink
exec subcommand (#624)
Browse files Browse the repository at this point in the history
`exec` subcommand

Demo

Add exec action
Add exec subcommand
Add exec_command and exec_shell settings entries
Add support for actions that may be stdout only
Update test data as needed
Update tests to shlex split for first use of space delimited parameter
add flake8 file to set docstring format to sphinx
one black fix in utils, ergh

Fixes: #526

Reviewed-by: Rick Elrod <rick@elrod.me>
Reviewed-by: Bradley A. Thornton <bthornto@redhat.com>
Reviewed-by: Alison Hart <contact@alisonlhart.com>
Reviewed-by: Sviatoslav Sydorenko <webknjaz+github/profile@redhat.com>
Reviewed-by: Jake Jackson  <jljacks93@gmail.com>
Reviewed-by: None <None>
  • Loading branch information
cidrblock authored Dec 14, 2021
1 parent 76bb5cb commit f73f4a0
Show file tree
Hide file tree
Showing 21 changed files with 668 additions and 10 deletions.
9 changes: 9 additions & 0 deletions docs/changelog-fragments.d/526.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Added the ability to run ad-hoc commands directly within an execution
environment using mode `stdout`.

Here is an example invocation:
```bash
ansible-navigator exec --mode stdout
```

-- by {user}`cidrblock`
10 changes: 9 additions & 1 deletion src/ansible_navigator/actions/_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import functools
import importlib
import logging
import re
from collections import namedtuple

Expand All @@ -19,6 +20,8 @@
import importlib_resources as resources # type: ignore[import, no-redef]


logger = logging.getLogger(__name__)

# Basic structure for storing information about one action
ActionT = namedtuple("ActionT", ("name", "cls", "kegex"))

Expand Down Expand Up @@ -95,7 +98,12 @@ def run_interactive(package: str, action: str, *args: Any, **_kwargs: Any) -> An
"""Call the given action's run"""
action_cls = get(package, action)
app, interaction = args
return action_cls(app.args).run(app=app, interaction=interaction)
app_action = action_cls(app.args)
supports_interactive = hasattr(app_action, "run")
if not supports_interactive:
logger.error("Subcommand '%s' does not support mode interactive", action)
run_action = app_action.run if supports_interactive else app_action.no_interactive_mode
return run_action(app=app, interaction=interaction)


def run_interactive_factory(package: str) -> Callable:
Expand Down
116 changes: 116 additions & 0 deletions src/ansible_navigator/actions/exec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Run the :exec subcommand."""
import os
import shlex

from typing import List
from typing import Optional
from typing import Tuple
from typing import Union

from . import _actions as actions
from ..app import App
from ..configuration_subsystem import ApplicationConfiguration
from ..configuration_subsystem.definitions import Constants
from ..runner import Command

GeneratedCommand = Tuple[str, Optional[List[str]]]


def _generate_command(exec_command: str, exec_shell: bool) -> GeneratedCommand:
"""Generate the command and args.
:param exec_command: The command to run
:param exec_shell: Should the command be wrapped in a shell
:returns: The command and any pass through arguments
"""
pass_through_args = None
if exec_shell and exec_command:
command = "/bin/bash"
pass_through_args = ["-c", exec_command]
else:
parts = shlex.split(exec_command)
command = parts[0]
if len(parts) > 1:
pass_through_args = parts[1:]
return (command, pass_through_args)


@actions.register
class Action(App):
"""Run the :exec subcommand."""

# pylint: disable=too-few-public-methods

KEGEX = "^e(?:xec)?$"

def __init__(self, args: ApplicationConfiguration):
"""Initialize the action.
:param args: The current application configuration.
"""
super().__init__(args=args, logger_name=__name__, name="exec")

def run_stdout(self) -> Union[None, int]:
"""Run in mode stdout.
:returns: The return code or None
"""
self._logger.debug("exec requested in stdout mode")
response = self._run_runner()
if response:
_, _, ret_code = response
return ret_code
return None

def _run_runner(self) -> Optional[Tuple]:
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
"""Spin up runner.
:return: The stdout, stderr and return code from runner
"""
if isinstance(self._args.set_environment_variable, dict):
envvars_to_set = self._args.set_environment_variable.copy()
elif isinstance(self._args.set_environment_variable, Constants):
envvars_to_set = {}
else:
log_message = (
"The setting 'set_environment_variable' was neither a dictionary"
" or Constants, please raise an issue. No environment variables will be set."
)
self._logger.error(
"%s The current value was found to be '%s'",
log_message,
self._args.set_environment_variable,
)
envvars_to_set = {}

if self._args.display_color is False:
envvars_to_set["ANSIBLE_NOCOLOR"] = "1"

kwargs = {
"container_engine": self._args.container_engine,
"host_cwd": os.getcwd(),
"execution_environment_image": self._args.execution_environment_image,
"execution_environment": self._args.execution_environment,
"navigator_mode": self._args.mode,
"pass_environment_variable": self._args.pass_environment_variable,
"set_environment_variable": envvars_to_set,
"timeout": self._args.ansible_runner_timeout,
}

if isinstance(self._args.execution_environment_volume_mounts, list):
kwargs["container_volume_mounts"] = self._args.execution_environment_volume_mounts

if isinstance(self._args.container_options, list):
kwargs["container_options"] = self._args.container_options

command, pass_through_args = _generate_command(
exec_command=self._args.exec_command,
exec_shell=self._args.exec_shell,
)
if isinstance(pass_through_args, list):
kwargs["cmdline"] = pass_through_args

runner = Command(executable_cmd=command, **kwargs)
return runner.run()
14 changes: 14 additions & 0 deletions src/ansible_navigator/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from .ui_framework import Interaction
from .ui_framework import ui
from .ui_framework import warning_notification

from .utils import LogMessage
from .utils import ExitMessage
Expand Down Expand Up @@ -83,6 +84,19 @@ def app(self) -> AppPublic:
)
raise AttributeError("app passed without args initialized")

def no_interactive_mode(self, interaction: Interaction, app: AppPublic) -> None:
# pylint: disable=unused-argument
"""Show a warning notification that the user interactive mode is not supported."""
warning = warning_notification(
messages=[
f"The '{self._name}' subcommand is not available while using interactive mode.",
"[HINT] Start an additional instance of ansible-navigator"
+ " in a new terminal with mode 'stdout'.",
f" e.g. 'ansible-navigator {self._name} --mode stdout",
]
)
interaction.ui.show(warning)

@staticmethod
def _copy_args(args: ApplicationConfiguration) -> ApplicationConfiguration:
"""Deepcopy the args.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ class Internals(SimpleNamespace):
" 'ansible-navigator doc --help-doc --mode stdout'"
),
),
SubCommand(
name="exec",
description="Run a command within an execution environment",
epilog=(
"Note: During development, it may become necessary to interact"
" directly with the execution environment to review and confirm"
" its build and behavior. All navigator settings will be applied"
" when starting the execution environment."
),
),
SubCommand(
name="images",
description="Explore execution environment images",
Expand Down Expand Up @@ -246,6 +256,23 @@ class Internals(SimpleNamespace):
short_description="Specify if the editor is console based",
value=EntryValue(default=True),
),
Entry(
name="exec_command",
cli_parameters=CliParameters(short="--excmd"),
settings_file_path_override="exec.command",
short_description="Specify the command to run within the execution environment",
subcommands=["exec"],
value=EntryValue(default="/bin/bash"),
),
Entry(
name="exec_shell",
choices=[True, False],
cli_parameters=CliParameters(short="--exshell"),
settings_file_path_override="exec.shell",
short_description="Specify the exec command should be run in a shell.",
subcommands=["exec"],
value=EntryValue(default=True),
),
Entry(
name="execution_environment",
choices=[True, False],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,16 @@ def execution_environment(self, entry, config) -> PostProcessorReturn:
new_messages, new_exit_messages = check_for_ansible()
messages.extend(new_messages)
exit_messages.extend(new_exit_messages)

if config.app == "exec":
exit_msg = "The 'exec' subcommand requires execution environment support."
exit_messages.append(ExitMessage(message=exit_msg))
hint = (
f"Try again with '{entry.cli_parameters.short} true'"
" to enable the use of an execution environment."
)
exit_messages.append(ExitMessage(message=hint, prefix=ExitPrefix.HINT))

return messages, exit_messages

@staticmethod
Expand Down Expand Up @@ -310,6 +320,17 @@ def container_options(entry: Entry, config: ApplicationConfiguration) -> PostPro
entry.value.current = flatten_list(entry.value.current)
return messages, exit_messages

@_post_processor
def exec_shell(self, entry: Entry, config: ApplicationConfiguration) -> PostProcessorReturn:
# pylint: disable=unused-argument
"""Post process ``exec_shell``.
:param entry: The current settings entry
:param config: The full application configuration
:return: An instance of the standard post process return object
"""
return self._true_or_false(entry, config)

@_post_processor
def help_config(self, entry: Entry, config: ApplicationConfiguration) -> PostProcessorReturn:
# pylint: disable=unused-argument
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ansible-navigator:
exec:
command: echo test_data_from_config
shell: False
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "test[exec echo with ee clear && ansible-navigator exec --excmd 'echo test_data_from_cli' --ee True --ll debug --mode stdout]",
"index": 0,
"comment": "exec echo with ee",
"additional_information": {
"look_fors": [
"bash",
"test_data_from_cli"
],
"look_nots": [
"ERROR"
],
"compared_fixture": false
},
"output": [
"test_data_from_cli",
"(venv) bash-5.1$"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "test[exec echo without ee clear && ansible-navigator exec --excmd 'echo test_data_from_cli' --ee False --ll debug --mode stdout]",
"index": 1,
"comment": "exec echo without ee",
"additional_information": {
"look_fors": [
"bash",
"test_data_from_cli",
"ERROR",
"requires execution environment support"
],
"look_nots": [],
"compared_fixture": false
},
"output": [
"[ERROR]: Command provided: 'exec --excmd 'echo test_data_from_cli' --ee False --ll debug --mode stdout'",
"[ERROR]: The 'exec' subcommand requires execution environment support.",
" [HINT]: Try again with '--ee true' to enable the use of an execution environment.",
"[ERROR]: Configuration failed, using default log file location: /home/user/github/ansible-navigator/ansible-navigator.log. Log level set to debug",
" [HINT]: Review the hints and log file to see what went wrong: /home/user/github/ansible-navigator/ansible-navigator.log",
"(venv) bash-5.1$"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "test[exec echo check path via shell clear && ansible-navigator exec --excmd 'echo $PATH' --ee True --ll debug --mode stdout]",
"index": 2,
"comment": "exec echo check path via shell",
"additional_information": {
"look_fors": [
"/sbin"
],
"look_nots": [],
"compared_fixture": false
},
"output": [
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"(venv) bash-5.1$"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "test[exec echo check no path via shell clear && ansible-navigator exec --excmd 'echo $PATH' --exshell false --ee True --ll debug --mode stdout]",
"index": 3,
"comment": "exec echo check no path via shell",
"additional_information": {
"look_fors": [
"$PATH"
],
"look_nots": [],
"compared_fixture": false
},
"output": [
"$PATH",
"(venv) bash-5.1$"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "test[exec echo with ee clear && ansible-navigator exec --ee True --ll debug --mode stdout]",
"index": 0,
"comment": "exec echo with ee",
"additional_information": {
"look_fors": [
"bash",
"test_data_from_config"
],
"look_nots": [
"ERROR"
],
"compared_fixture": false
},
"output": [
"test_data_from_config",
"(venv) bash-5.1$"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "test[exec echo check no path via shell clear && ansible-navigator exec --excmd '/usr/bin/echo $PATH' --ee True --ll debug --mode stdout]",
"index": 1,
"comment": "exec echo check no path via shell",
"additional_information": {
"look_fors": [],
"look_nots": [
"/sbin"
],
"compared_fixture": false
},
"output": [
"$PATH",
"(venv) bash-5.1$"
]
}
Loading

0 comments on commit f73f4a0

Please sign in to comment.