diff --git a/docs/CONFIG.md b/docs/CONFIG.md index d4072fd..6e0035b 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -10,7 +10,8 @@ - - [Optional fields](#optional-fields) - - - [before](#before-liststring) - - - [after](#after-liststring) -- - - [docker_container](#docker_container-string) +- - - [cleanup](#cleanup-liststring) +- - - [default_docker_container](#default_docker_container-string) - - - [docker_run_options](#docker_run_options-string) - [Containers](#containers) - - [Containers, based on images from repo](#containers-based-on-images-from-repo), @@ -48,9 +49,10 @@ name = "test" description = "runs unit tests" before = ["echo This is just an example", "echo You should configure your tests here"] execute = "pytest" -after =["echo done > /dev/null"] +after = ["echo tests completed"] +cleanup = ["rm -f .coverage"] examples = ["tests --cov -vv", "tests --last-failed"] -docker_container = "alpine" +default_docker_container = "alpine" docker_run_options = "-it -v .:/app -w /app" ``` @@ -105,18 +107,28 @@ then resulting command, which will be executed by `runo` will be: `pytest --cov Before executing the main part, you may want to perform some setup (activate `venv` in case of Python project). In this section you can specify list of commands/scripts, which should be executed before -the main part. Example: +the main part. These commands are executed inside the container +(or natively if no container is used). Example: - `["source /tmp/.venv/bin/activate", "echo Starting tests"]` #### `after` (`list[string]`) -Similar to `before`, but executed after the main part. Example: -- `["rm -f /tmp/unneeded_files*"]` +Similar to `before`, but executed after the main part. These commands +are executed inside the container (or natively if no container is used), +which makes them useful for post-execution actions that need access +to the container environment. Example: +- `["echo tests completed", "echo generating report"]` + -Please note that these commands are executed on host machine (not inside container). +#### `cleanup` (`list[string]`) + +Cleanup commands to run on the host machine after the container exits +(or after native execution completes). Useful for removing temporary files +or performing other host-side cleanup. Example: +- `["rm -f /tmp/unneeded_files*"]` -#### `docker_container` (`string`) +#### `default_docker_container` (`string`) Name of the container (defined in the same config), which should be used by the command by default. It can be overwritten via CLI at moment of run diff --git a/runo b/runo index e96700e..cb6bd9e 100755 --- a/runo +++ b/runo @@ -30,7 +30,7 @@ from typing import ( Union, ) -__version__ = "26.01.29-716a0e17" +__version__ = "26.02.03-1ec55b48" # runo uses .toml format of config files, but parsers for this format # might be not available in old python versions (only in 3.11 tomllib @@ -83,20 +83,24 @@ name = "test" description = "runs unit tests" # OPTIONALLY, you can specify actions, which you want to perform before the main command. # For python projects, you may want to activate venv here. +# These commands are executed inside the container (or natively if no container is used). before = ["echo This is just an example", "echo You should configure your tests here"] # Actual command to run (can be single command, or script). # You can pass additional options to this executable via console (see 'examples' section) execute = "echo ALL TESTS PASSED" -# OPTIONALLY, you can specify actions, which you want to perform after the main command -# to do cleanup -after = ["echo done > /dev/null"] +# OPTIONALLY, you can specify actions to perform after the main command, but still inside +# the container. Useful for generating coverage reports or other post-test actions. +after = ["echo generating coverage report"] +# OPTIONALLY, you can specify cleanup actions to perform on the host machine after +# the container exits. Useful for removing temporary files. +cleanup = ["echo done > /dev/null"] # OPTIONALLY, you can specify examples of command usage. # if missing, ./runo will auto generate single example. examples = ["tests --cov -vv", "tests --last-failed"] -## OPTIONALLY you can specify container, in which command should be executed by default. +## OPTIONALLY you can specify default container, in which command should be executed. ## Container must be defined in the same file. -## It can be overwritten, or set from CLI as well. -# docker_container = "alpine" +## It can be overwritten, or set from CLI as well with '-c'/'--container' option. +# default_docker_container = "alpine" ## OPTIONALLY, you can specify 'docker run' options, which should be used at command execution. ## See official docker documentation: https://docs.docker.com/reference/cli/docker/container/run/ # docker_run_options = "-it -v .:/app -w /app" @@ -105,24 +109,24 @@ examples = ["tests --cov -vv", "tests --last-failed"] name = "build" description = "builds the project" execute = "echo Build is running" -after = ["echo done"] -#docker_container = "alpine" +cleanup = ["echo done"] +#default_docker_container = "alpine" #docker_run_options = "-it -v .:/app -w /app" [[commands]] name = "shell" description = "debug container by running shell in interactive mode (keep container running)" execute = "/bin/sh" -docker_container = "alpine" +default_docker_container = "alpine" docker_run_options = "-it -v .:/app -w /app" [[commands]] name = "pre-commit" description = "quick checks/fixes of code formatting (ruff/mypy)" execute = "echo Ruff is formatting the code" -after = ["echo Formatting completed"] +cleanup = ["echo Formatting completed"] #execute = "scripts/pre_commit.sh" -#docker_container = "alpine" +#default_docker_container = "alpine" #docker_run_options = "-v .:/app -w /app" [[commands]] @@ -130,7 +134,7 @@ name = "update-deps" description = "updates dependencies, used in project, to the latest versions" execute = "echo Often it is pain, but if you will script it and put here, it will be super easy" #execute = "scripts/update-requirements.sh" -#docker_container = "alpine" +#default_docker_container = "alpine" #docker_run_options = "-v .:/app -w /app" ####################################################### @@ -627,7 +631,27 @@ def _generate_command_to_run(command_cfg: dict, command_options: List[str]) -> s execute = " ".join([command_cfg["execute"]] + command_options) - return _generate_bin_sh_cmd(before + [execute]) + after = command_cfg.get("after", []) + + return _generate_bin_sh_cmd(before + [execute] + after) + + +def _get_default_container_from_config(command_cfg: dict) -> Optional[str]: + """ + Get the default container name from command config. + Supports both 'default_docker_container' (preferred) and 'docker_container' (deprecated). + If 'docker_container' is used, logs a deprecation warning. + """ + if "default_docker_container" in command_cfg: + return command_cfg["default_docker_container"] + if "docker_container" in command_cfg: + _logger.error( + "DEPRECATION WARNING: 'docker_container' section in config is deprecated " + "and will be removed after 2027-01-01. " + "Please rename it to 'default_docker_container'." + ) + return command_cfg["docker_container"] + return None def _containers_to_use( @@ -643,9 +667,8 @@ def _containers_to_use( This function returns resulting list of containers, which should be used to handle the given command run. """ - containers_from_command_config = ( - [command_cfg["docker_container"]] if "docker_container" in command_cfg else [] - ) + default_container = _get_default_container_from_config(command_cfg) + containers_from_command_config = [default_container] if default_container else [] containers_to_use = containers_requested_via_cli or containers_from_command_config if len(containers_to_use) == 1 and containers_to_use[0] == "*": @@ -656,9 +679,9 @@ def _containers_to_use( def _generate_cleanup_cmd(command_cfg: dict) -> Optional[str]: - after = command_cfg.get("after") - if after: - return _generate_bin_sh_cmd(after) + cleanup = command_cfg.get("cleanup") + if cleanup: + return _generate_bin_sh_cmd(cleanup) return None @@ -791,6 +814,12 @@ class Validator: if present_conflicting: field_errors.append(f"conflicting fields found: {present_conflicting}") + requires_one_of = self._schema[field].get("requires_one_of_fields", set()) + if requires_one_of and not requires_one_of.intersection(set(data.keys())): + field_errors.append( + f"requires one of the following fields to be present: {sorted(requires_one_of)}" + ) + if field_errors: errors[field].extend(field_errors) @@ -823,16 +852,25 @@ _command_validator = Validator( "before": dict(value_type=list, required=False), "execute": dict(value_type=str, required=True), "after": dict(value_type=list, required=False), + "cleanup": dict(value_type=list, required=False), "examples": dict(value_type=list, required=False), # Docker section + # DEPRECATED: 'docker_container' will be removed on 2027-01-01. + # Use 'default_docker_container' instead. "docker_container": dict( value_type=str, required=False, + excludes_fields={"default_docker_container"}, + ), + "default_docker_container": dict( + value_type=str, + required=False, + excludes_fields={"docker_container"}, ), "docker_run_options": dict( value_type=str, required=False, - requires_fields={"docker_container"}, + requires_one_of_fields={"docker_container", "default_docker_container"}, ), } ) diff --git a/runo.toml b/runo.toml index 48d288c..3ba687f 100644 --- a/runo.toml +++ b/runo.toml @@ -10,30 +10,30 @@ before = ["source /tmp/runo_venv/.venv/bin/activate", "ln -s runo runo.py"] execute = "pytest" # clean all files, generated by test (__pycache__/.coverage), # except of htmlcov and pytest_cache, which are useful for us -after = ["rm -f runo.py", "rm -rf .coverage", "rm -rf tests/unit/__pycache__", "rm -rf __pycache__"] +cleanup = ["rm -f runo.py", "rm -rf .coverage", "rm -rf tests/unit/__pycache__", "rm -rf __pycache__"] examples = ["test --cov -vv", "test --last-failed"] -docker_container = "python39" +default_docker_container = "python39" docker_run_options = "-it -v .:/app -w /app" [[commands]] name = "shell" description = "debug container by running shell in interactive mode (keep container running)" execute = "/bin/sh" -docker_container = "python39" +default_docker_container = "python39" docker_run_options = "-it -v .:/app -w /app" [[commands]] name = "pre-commit" description = "quick checks/fixes of code formatting (ruff/mypy)." execute = "scripts/pre_commit.sh" -docker_container = "python39" +default_docker_container = "python39" docker_run_options = "-v .:/app -w /app" [[commands]] name = "update-deps" description = "updates dependencies, used in project, to the latest versions" execute = "scripts/update_deps.sh" -docker_container = "python39" +default_docker_container = "python39" docker_run_options = "-v .:/app -w /app" [[commands]] @@ -42,7 +42,7 @@ description = "runs 'mypy' static type checker" before = ["source /tmp/runo_venv/.venv/bin/activate"] execute = "mypy" examples = ["mypy runo tests"] -docker_container = "python39" +default_docker_container = "python39" docker_run_options = "-v .:/app -w /app" [[commands]] @@ -51,7 +51,7 @@ description = "runs 'ruff' (checker/formatter)" before = ["source /tmp/runo_venv/.venv/bin/activate"] execute = "ruff" examples = ["ruff check runo tests", "ruff format runo tests --check"] -docker_container = "python39" +default_docker_container = "python39" docker_run_options = "-v .:/app -w /app" [[docker_containers]] diff --git a/tests/e2e/runo.toml b/tests/e2e/runo.toml index c5b86e1..6f70c69 100644 --- a/tests/e2e/runo.toml +++ b/tests/e2e/runo.toml @@ -15,8 +15,23 @@ name = "example_native_multi" description = "example of command, executing 2 linux commands natively on your OS" before = ["echo BE", "echo FOR"] execute = "echo PASSED" -after = ["echo AFTER"] -# e2e_tests = ["example_native_multi|BE\nFOR\nPASSED\nAFTER\n|0"] +after = ["echo AFTER_IN_CMD"] +cleanup = ["echo CLEANUP"] +# e2e_tests = ["example_native_multi|BE\nFOR\nPASSED\nAFTER_IN_CMD\nCLEANUP\n|0"] + +[[commands]] +name = "example_native_after_only" +description = "example of command with only 'after' (runs in same shell as execute)" +execute = "echo MAIN" +after = ["echo POST_EXECUTE"] +# e2e_tests = ["example_native_after_only|MAIN\nPOST_EXECUTE\n|0"] + +[[commands]] +name = "example_native_cleanup_only" +description = "example of command with only 'cleanup' (runs on host after main command)" +execute = "echo MAIN" +cleanup = ["echo HOST_CLEANUP"] +# e2e_tests = ["example_native_cleanup_only|MAIN\nHOST_CLEANUP\n|0"] [[commands]] name = "example_native_script" @@ -29,7 +44,7 @@ execute = "dev/say.sh" [[commands]] name = "example_docker_file_single" description = "example of command, executing 1 linux command in docker container, defined with docker file" -docker_container = "container_defined_by_docker_file" +default_docker_container = "container_defined_by_docker_file" docker_run_options = "-it -v .:/app -w /app --user 1000:1000" execute = "grep 'PRETTY_NAME' /etc/os-release" # e2e_tests = ["example_docker_file_single|PRETTY_NAME=\"Alpine Linux v3.20\"\r\n|0"] @@ -37,26 +52,46 @@ execute = "grep 'PRETTY_NAME' /etc/os-release" [[commands]] name = "example_docker_file_multi" description = "example of command, executing 2 linux commands in docker container, defined with docker file" -docker_container = "container_defined_by_docker_file" +default_docker_container = "container_defined_by_docker_file" docker_run_options = "-it -v .:/app -w /app --user 1000:1000" before = ["echo Your OS:"] execute = "grep 'PRETTY_NAME' /etc/os-release" # e2e_tests = ["example_docker_file_multi|Your OS:\r\nPRETTY_NAME=\"Alpine Linux v3.20\"\r\n|0"] +[[commands]] +name = "example_docker_file_after" +description = "example of 'after' running inside docker container (after execute). Shows RUNO_CONTAINER_NAME is set in container but not on host." +default_docker_container = "container_defined_by_docker_file" +docker_run_options = "-it -v .:/app -w /app --user 1000:1000" +before = ["echo CONTAINER_NAME_IN_BEFORE: $RUNO_CONTAINER_NAME"] +execute = "echo CONTAINER_NAME_IN_EXECUTE: $RUNO_CONTAINER_NAME" +after = ["echo CONTAINER_NAME_IN_AFTER: $RUNO_CONTAINER_NAME"] +cleanup = ["echo CLEANUP_RUNO_CONTAINER_NAME_IS_EMPTY: ${RUNO_CONTAINER_NAME:-true}"] +# e2e_tests = ["example_docker_file_after|CONTAINER_NAME_IN_BEFORE: container_defined_by_docker_file\r\nCONTAINER_NAME_IN_EXECUTE: container_defined_by_docker_file\r\nCONTAINER_NAME_IN_AFTER: container_defined_by_docker_file\r\nCLEANUP_RUNO_CONTAINER_NAME_IS_EMPTY: true\n|0"] + [[commands]] name = "example_docker_file_script" description = "example of command, executing script in docker container, defined with docker file" -docker_container = "container_defined_by_docker_file" +default_docker_container = "container_defined_by_docker_file" docker_run_options = "-it -v .:/app -w /app --user 1000:1000" examples = ["example_docker_file_script say_hello"] before = ["echo Your OS:", "grep 'PRETTY_NAME' /etc/os-release"] execute = "dev/say.sh" # e2e_tests = ["example_docker_file_script hello_from_docker|Your OS:\r\nPRETTY_NAME=\"Alpine Linux v3.20\"\r\nyou asked to print 'hello_from_docker'\r\n|0"] +# Test backward compatibility with deprecated 'docker_container' field +[[commands]] +name = "example_docker_file_deprecated" +description = "example using deprecated docker_container field (backward compatibility test)" +docker_container = "container_defined_by_docker_file" +docker_run_options = "-it -v .:/app -w /app --user 1000:1000" +execute = "grep 'PRETTY_NAME' /etc/os-release" +# e2e_tests = ["example_docker_file_deprecated|PRETTY_NAME=\"Alpine Linux v3.20\"\r\n|0"] + [[commands]] name = "example_docker_compose_simple" description = "example of command, exicuting simple command from docker compose service" -docker_container = "container_defined_by_docker_compose" +default_docker_container = "container_defined_by_docker_compose" docker_run_options = "--quiet --build -i" execute = "echo PASSED" # e2e_tests = ["example_docker_compose_simple|PASSED\n|0"] @@ -64,7 +99,7 @@ execute = "echo PASSED" [[commands]] name = "example_docker_compose_test_client" description = "example of command, which depends on docker compose service" -docker_container = "container_defined_by_docker_compose" +default_docker_container = "container_defined_by_docker_compose" docker_run_options = "--quiet --build -i" execute = "./dev/client.py" # e2e_tests = ["example_docker_compose_test_client|Server is alive\n|0"] diff --git a/tests/unit/test_runo.py b/tests/unit/test_runo.py index ee45498..c54a26c 100644 --- a/tests/unit/test_runo.py +++ b/tests/unit/test_runo.py @@ -210,20 +210,24 @@ class TestInit: description = "runs unit tests" # OPTIONALLY, you can specify actions, which you want to perform before the main command. # For python projects, you may want to activate venv here. +# These commands are executed inside the container (or natively if no container is used). before = ["echo This is just an example", "echo You should configure your tests here"] # Actual command to run (can be single command, or script). # You can pass additional options to this executable via console (see 'examples' section) execute = "echo ALL TESTS PASSED" -# OPTIONALLY, you can specify actions, which you want to perform after the main command -# to do cleanup -after = ["echo done > /dev/null"] +# OPTIONALLY, you can specify actions to perform after the main command, but still inside +# the container. Useful for generating coverage reports or other post-test actions. +after = ["echo generating coverage report"] +# OPTIONALLY, you can specify cleanup actions to perform on the host machine after +# the container exits. Useful for removing temporary files. +cleanup = ["echo done > /dev/null"] # OPTIONALLY, you can specify examples of command usage. # if missing, ./runo will auto generate single example. examples = ["tests --cov -vv", "tests --last-failed"] -## OPTIONALLY you can specify container, in which command should be executed by default. +## OPTIONALLY you can specify default container, in which command should be executed. ## Container must be defined in the same file. -## It can be overwritten, or set from CLI as well. -# docker_container = "alpine" +## It can be overwritten, or set from CLI as well with '-c'/'--container' option. +# default_docker_container = "alpine" ## OPTIONALLY, you can specify 'docker run' options, which should be used at command execution. ## See official docker documentation: https://docs.docker.com/reference/cli/docker/container/run/ # docker_run_options = "-it -v .:/app -w /app" @@ -232,24 +236,24 @@ class TestInit: name = "build" description = "builds the project" execute = "echo Build is running" -after = ["echo done"] -#docker_container = "alpine" +cleanup = ["echo done"] +#default_docker_container = "alpine" #docker_run_options = "-it -v .:/app -w /app" [[commands]] name = "shell" description = "debug container by running shell in interactive mode (keep container running)" execute = "/bin/sh" -docker_container = "alpine" +default_docker_container = "alpine" docker_run_options = "-it -v .:/app -w /app" [[commands]] name = "pre-commit" description = "quick checks/fixes of code formatting (ruff/mypy)" execute = "echo Ruff is formatting the code" -after = ["echo Formatting completed"] +cleanup = ["echo Formatting completed"] #execute = "scripts/pre_commit.sh" -#docker_container = "alpine" +#default_docker_container = "alpine" #docker_run_options = "-v .:/app -w /app" [[commands]] @@ -257,7 +261,7 @@ class TestInit: description = "updates dependencies, used in project, to the latest versions" execute = "echo Often it is pain, but if you will script it and put here, it will be super easy" #execute = "scripts/update-requirements.sh" -#docker_container = "alpine" +#default_docker_container = "alpine" #docker_run_options = "-v .:/app -w /app" ####################################################### @@ -714,11 +718,12 @@ class BaseCommandsTest: COMMON_COMMANDS_TEMPLATES = [ # Simple command {"execute": "echo PASSED"}, - # before/after + # before/after (in-container) and cleanup (on-host) { "before": ["echo BEFORE", "echo TEST"], "execute": "echo PASSED", - "after": ["echo done"], + "after": ["echo AFTER_IN_CONTAINER"], + "cleanup": ["echo done"], }, ] @@ -748,13 +753,15 @@ def _generate_command_to_run(command_cfg: dict, command_options: List[str]) -> s execute = " ".join([command_cfg["execute"]] + command_options) - return f"/bin/sh -c '{' && '.join(before + [execute])}'" + after = command_cfg.get("after", []) + + return f"/bin/sh -c '{' && '.join(before + [execute] + after)}'" @staticmethod def _generate_configured_cleanup(command_cfg: dict): - after = command_cfg.get("after") - if after: - return f"/bin/sh -c '{' && '.join(after)}'" + cleanup = command_cfg.get("cleanup") + if cleanup: + return f"/bin/sh -c '{' && '.join(cleanup)}'" return "" @staticmethod @@ -930,7 +937,7 @@ class TestLocallyBuiltContainerCommands(BaseCommandsTest): BASE_COMMAND_CFG: dict = { "name": "test_cmd", "description": "-", - "docker_container": "test_docker_file", + "default_docker_container": "test_docker_file", } @pytest.fixture( @@ -1055,7 +1062,7 @@ def test_build_failed( { "name": "test_cmd", "description": "-", - "docker_container": "test_docker_file", + "default_docker_container": "test_docker_file", "execute": "echo OK", } ], @@ -1083,7 +1090,7 @@ class TestContainerFromImageCommands(BaseCommandsTest): BASE_COMMAND_CFG: dict = { "name": "test_cmd", "description": "-", - "docker_container": "test_image_from_repo", + "default_docker_container": "test_image_from_repo", } @pytest.fixture( @@ -1135,7 +1142,7 @@ class TestDockerComposeServiceCommands(BaseCommandsTest): BASE_COMMAND_CFG: dict = { "name": "test_cmd", "description": "-", - "docker_container": "test_docker_compose", + "default_docker_container": "test_docker_compose", } @pytest.fixture( @@ -1272,7 +1279,7 @@ class TestContainersSelection: "name": "command_with_default_container", "description": "-", "execute": "echo OK", - "docker_container": "container1", + "default_docker_container": "container1", }, ], "docker_containers": [ @@ -1442,7 +1449,7 @@ def test_no_any_containers_available(self, config_path, monkeypatch, capfd): "name": "test_cmd", "description": "-", "execute": "echo OK", - "docker_container": "no_such_container", + "default_docker_container": "no_such_container", }, ], } @@ -1468,7 +1475,7 @@ def test_container_configuration_is_wrong(self, config_path, monkeypatch, capfd) "name": "test_cmd", "description": "-", "execute": "echo OK", - "docker_container": "bad_container", + "default_docker_container": "bad_container", }, ], "docker_containers": [ @@ -1566,7 +1573,7 @@ def generate_config_content(containers_to_test: dict, docker_run_options: str): "name": "test_cmd", "description": "-", "execute": "echo OK", - "docker_container": containers_to_test["name"], + "default_docker_container": containers_to_test["name"], "docker_run_options": docker_run_options, }, ], @@ -1804,3 +1811,342 @@ def test_exception_reraised_with_debug(self, patched_run, capfd, monkeypatch, co std_out, std_err = capfd.readouterr() # Error is still logged before re-raising assert "error happened: simulated error for debug" in std_err + + +class TestAfterAndCleanupBehavior: + """ + Test that 'after' commands run inside the container (part of the main command) + and 'cleanup' commands run on the host (separate subprocess call). + """ + + @patch("runo.subprocess.run") + def test_after_runs_inside_container(self, patched_run, capfd, monkeypatch, config_path): + """ + 'after' commands should be part of the /bin/sh -c command that runs in container, + executed after the main 'execute' command. + """ + patched_run.return_value.returncode = 0 + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "before": ["echo BEFORE"], + "execute": "echo EXECUTE", + "after": ["echo AFTER"], + "default_docker_container": "test_container", + "docker_run_options": "-v .:/app", + } + ], + "docker_containers": [ + { + "name": "test_container", + "docker_image": "alpine:latest", + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( + toml.dumps(config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + assert std_err == "" + # The 'after' command should be part of the single /bin/sh -c command + expected_cmd = "/bin/sh -c 'echo BEFORE && echo EXECUTE && echo AFTER'" + assert expected_cmd in std_out + + @patch("runo.subprocess.run") + def test_cleanup_runs_on_host(self, patched_run, capfd, monkeypatch, config_path): + """ + 'cleanup' commands should run as a separate subprocess call on the host, + after the container command completes. + """ + patched_run.return_value.returncode = 0 + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "execute": "echo EXECUTE", + "cleanup": ["echo CLEANUP"], + "default_docker_container": "test_container", + "docker_run_options": "-v .:/app", + } + ], + "docker_containers": [ + { + "name": "test_container", + "docker_image": "alpine:latest", + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( + toml.dumps(config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + assert std_err == "" + # The cleanup command should be a separate call, not part of the container command + assert "/bin/sh -c 'echo CLEANUP'" in std_out + # And it should NOT be part of the main docker command + assert "echo EXECUTE && echo CLEANUP" not in std_out + + @patch("runo.subprocess.run") + def test_after_and_cleanup_together(self, patched_run, capfd, monkeypatch, config_path): + """ + Test that both 'after' (in-container) and 'cleanup' (on-host) work together correctly. + """ + patched_run.return_value.returncode = 0 + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "before": ["echo BEFORE"], + "execute": "echo EXECUTE", + "after": ["echo AFTER_IN_CONTAINER"], + "cleanup": ["echo CLEANUP_ON_HOST"], + "default_docker_container": "test_container", + "docker_run_options": "-v .:/app", + } + ], + "docker_containers": [ + { + "name": "test_container", + "docker_image": "alpine:latest", + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( + toml.dumps(config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + assert std_err == "" + # 'after' should be in the container command + expected_container_cmd = ( + "/bin/sh -c 'echo BEFORE && echo EXECUTE && echo AFTER_IN_CONTAINER'" + ) + assert expected_container_cmd in std_out + # 'cleanup' should be a separate host command + assert "/bin/sh -c 'echo CLEANUP_ON_HOST'" in std_out + + @patch("runo.subprocess.run") + def test_after_runs_natively_without_container( + self, patched_run, capfd, monkeypatch, config_path + ): + """ + When no container is used, 'after' should still be part of the main command. + """ + patched_run.return_value.returncode = 0 + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "before": ["echo BEFORE"], + "execute": "echo EXECUTE", + "after": ["echo AFTER"], + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( + toml.dumps(config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + assert std_err == "" + expected_cmd = "/bin/sh -c 'echo BEFORE && echo EXECUTE && echo AFTER'" + assert expected_cmd in std_out + + +class TestDefaultDockerContainer: + """ + Test the 'default_docker_container' field which replaces the deprecated 'docker_container'. + """ + + @patch("runo.subprocess.run") + def test_default_docker_container_works(self, patched_run, capfd, monkeypatch, config_path): + """ + 'default_docker_container' should work the same as 'docker_container'. + """ + patched_run.return_value.returncode = 0 + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "execute": "echo TEST", + "default_docker_container": "test_container", + "docker_run_options": "-v .:/app", + } + ], + "docker_containers": [ + { + "name": "test_container", + "docker_image": "alpine:latest", + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( + toml.dumps(config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + # No deprecation warning should be shown + assert "DEPRECATION WARNING" not in std_err + # Container should be used + assert "docker run" in std_out + assert "alpine:latest" in std_out + + @patch("runo.subprocess.run") + def test_deprecated_docker_container_shows_warning( + self, patched_run, capfd, monkeypatch, config_path + ): + """ + 'docker_container' should still work but show a deprecation warning. + """ + patched_run.return_value.returncode = 0 + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "execute": "echo TEST", + "docker_container": "test_container", + "docker_run_options": "-v .:/app", + } + ], + "docker_containers": [ + { + "name": "test_container", + "docker_image": "alpine:latest", + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( + toml.dumps(config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + # Deprecation warning should be shown with exact text + expected_warning = ( + "DEPRECATION WARNING: 'docker_container' section in config is deprecated " + "and will be removed after 2027-01-01. " + "Please rename it to 'default_docker_container'.\n" + ) + assert std_err == expected_warning + # Container should still be used + assert "docker run" in std_out + assert "alpine:latest" in std_out + + def test_both_fields_conflict(self, capfd, monkeypatch, config_path): + """ + Using both 'docker_container' and 'default_docker_container' should fail validation. + """ + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "execute": "echo TEST", + "docker_container": "container1", + "default_docker_container": "container2", + } + ], + "docker_containers": [ + {"name": "container1", "docker_image": "alpine:latest"}, + {"name": "container2", "docker_image": "alpine:latest"}, + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit) as exc_info, _config_file( + toml.dumps(config_content), config_path + ): + main() + + assert exc_info.value.code == os.EX_CONFIG + std_out, std_err = capfd.readouterr() + assert "conflicting fields found" in std_err + + @patch("runo.subprocess.run") + def test_docker_run_options_works_with_default_docker_container( + self, patched_run, capfd, monkeypatch, config_path + ): + """ + 'docker_run_options' should work correctly with 'default_docker_container'. + """ + patched_run.return_value.returncode = 0 + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "execute": "echo TEST", + "default_docker_container": "test_container", + "docker_run_options": "-v .:/app -w /app", + } + ], + "docker_containers": [ + { + "name": "test_container", + "docker_image": "alpine:latest", + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "-d", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit, match=_OK_EXIT_CODE_REGEX), _config_file( + toml.dumps(config_content), config_path + ): + main() + + std_out, std_err = capfd.readouterr() + assert std_err == "" + assert "-v .:/app" in std_out + assert "-w /app" in std_out + + def test_docker_run_options_requires_container_field(self, capfd, monkeypatch, config_path): + """ + 'docker_run_options' should fail if neither 'docker_container' nor + 'default_docker_container' is present. + """ + config_content = { + "commands": [ + { + "name": "test_cmd", + "description": "test command", + "execute": "echo TEST", + "docker_run_options": "-v .:/app", + } + ], + } + monkeypatch.setattr(sys, "argv", ["runo", "--config", str(config_path), "test_cmd"]) + + with pytest.raises(SystemExit) as exc_info, _config_file( + toml.dumps(config_content), config_path + ): + main() + + assert exc_info.value.code == os.EX_CONFIG + std_out, std_err = capfd.readouterr() + assert "requires one of the following fields" in std_err