diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 6c8b07e..4f6dbee 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -12,17 +12,17 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4.9.1 with: python-version: '3.9' - name: Install poetry - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a #v1.4.1 with: version: 2.1.2 virtualenvs-create: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 141122a..0140028 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,23 +23,23 @@ jobs: # TODO: the list might be added for future test uses python-version: ["3.9"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: persist-credentials: false - name: Install Task - uses: arduino/setup-task@v2 + uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611 # v2.0.0 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4.9.1 with: python-version: ${{ matrix.python-version }} - name: Install poetry - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a #v1.4.1 with: version: 2.1.2 virtualenvs-create: true @@ -47,7 +47,7 @@ jobs: - name: Load cached venv id: cached-poetry-dependencies - uses: actions/cache@v3 + uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f # v3.4.3 with: path: .venv key: venv-sndk-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }} @@ -61,7 +61,7 @@ jobs: - name: Upload coverage reports to Codecov if: github.ref == 'refs/heads/main' - uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d + uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d #v5.5.1 with: token: ${{ secrets.CODECOV_TOKEN }} slug: iomarmochtar/sandock diff --git a/.gitignore b/.gitignore index 4b7ad6c..12a5bfb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist htmlcov .mypy_cache coverage.xml +.DS_STORE \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bd2dc6f..a9e3377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## [0.6.0] + +### Features and Enhancements + +- [cli] introduce `--sandock-arg-recreate-img` to rebuild container image + + ## [0.5.0] ### Features and Enhancements diff --git a/pyproject.toml b/pyproject.toml index 53788f7..a57fb2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sandock" -version = "0.5.0" +version = "0.6.0" description = "CLI tool for sandbox execution using container approach" authors = [{ name = "Imam Omar Mochtar", email = "iomarmochtar@gmail.com" }] license = { text = "MIT" } @@ -46,5 +46,5 @@ show_column_numbers = true [tool.flake8] max-line-length = 88 -extend-ignore = ["E501"] +extend-ignore = ["E501", "E252"] exclude = ["build", "dist", "__pycache__"] \ No newline at end of file diff --git a/sandock/cli.py b/sandock/cli.py index 0ffd25c..d611c8e 100644 --- a/sandock/cli.py +++ b/sandock/cli.py @@ -348,6 +348,13 @@ def overrides_args(self) -> ArgumentParser: help="publish container ports", ) + oparser.add_argument( + self.override_arg(name="recreate-img"), + action="store_true", + default=False, + help="recreate the used container image", + ) + oparser.add_argument( self.override_arg(name="help"), action="store_true", diff --git a/sandock/sandbox.py b/sandock/sandbox.py index cc19b48..649fbb2 100644 --- a/sandock/sandbox.py +++ b/sandock/sandbox.py @@ -3,7 +3,7 @@ import tempfile import re from datetime import datetime -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, Callable, Tuple from pathlib import Path from .config import MainConfig from .config.program import Program @@ -39,9 +39,17 @@ def __init__( if program.persist.enable and "name" in overrides: raise SandboxExecution("name of persist program cannot be overrided") + hooks: List[Tuple[Callable[...], Any]] = [] # type: ignore[misc] # apply program's attribute overrides for k, v in overrides.items(): if not hasattr(program, k): + # it might be an internal/hook method + method = f"hook_{k}" + if hasattr(self, method): + log.debug(f"hook detected for method {method}") + hooks.append((getattr(self, method), v)) + continue + log.warning(f"program doesn't has property {k}") continue @@ -60,6 +68,21 @@ def __init__( ) self.container_name = self.generate_container_name() + # run hooks if any + for method_hook, arg in hooks: + method_hook(arg) + + def hook_recreate_img(self, create: bool=False) -> None: + """ + register for pre-exec cmd to delete image run the related container + """ + if not create: + return + + log.debug("[hook] registring for image deletion: {self.program.image}") + self.program.pre_exec_cmds.insert(0, " ".join([ + self.docker_bin, "image", "rm", self.program.image + ])) @property def docker_bin(self) -> str: diff --git a/tests/test_cli.py b/tests/test_cli.py index f90e123..a6e805f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -190,6 +190,7 @@ def test_main(self, sandbox_exec_mock: MagicMock) -> None: dict( hostname="change_host", allow_home_dir=False, + recreate_img=False, ports=["8080:8080", "8081:8081"]), ) remote.do.assert_called_once() @@ -204,11 +205,15 @@ def test_overrides_properties_kv(self) -> None: args=Namespace(program="pydev"), ) as o: result = o.override_properties( - args=["--sandbox-arg-env=DEBUG=true", "--sandbox-arg-env=APP_ENV=dev"] + args=[ + "--sandbox-arg-env=DEBUG=true", + "--sandbox-arg-env=APP_ENV=dev", + "--sandbox-arg-recreate-img", + ] ) expected_env = dict(DEBUG="true", APP_ENV="dev") - ov_props = dict(allow_home_dir=False, env=expected_env) + ov_props = dict(allow_home_dir=False, recreate_img=True, env=expected_env) self.assertDictEqual(result, ov_props) with self.obj( diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index dbcc4be..053aa2b 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -688,7 +688,7 @@ def test_do_docker_run(self) -> None: ), ): - o = self.obj(cfg=cfg) + o = self.obj(cfg=cfg, overrides=dict(recreate_img=False)) o.do(args=["--version"]) self.assertEqual(o.ensure_custom_image.call_count, 1) @@ -706,6 +706,58 @@ def test_do_docker_run(self) -> None: "docker run bla bla --version", ) + def test_do_hook_recreate_img(self) -> None: + """ + run recreate image as the first execute on pre-exec + """ + shell_side_effects = [ + dict(returncode=0), # pre cmd from deleting image + dict(returncode=0), # pre cmd 1 + dict(returncode=0), # pre cmd 2 + dict(returncode=0), # docker run + ] + with mock_shell_exec(side_effects=shell_side_effects) as rs: + cfg = dummy_main_cfg( + program_kwargs=dict( + name="pydev", + image="custom_python3", + pre_exec_cmds=["whoami", "cd /tmp"], + volumes=["namevol:/mnt:ro", "cache_${VOL_DIR}:/cache"], + ) + ) + + # mocks methods and properties + with mock.patch.multiple( + SandboxExec, + current_dir="/path/to/repo", + ensure_custom_image=mock.MagicMock(), + ensure_network=mock.MagicMock(), + ensure_volume=mock.MagicMock(), + exec_container_cmd=mock.MagicMock(), + run_container_cmd=mock.MagicMock( + return_value=["docker", "run", "bla", "bla"] + ), + ): + + o = self.obj(cfg=cfg, overrides=dict(recreate_img=True)) + o.do(args=["--version"]) + + self.assertEqual(o.ensure_custom_image.call_count, 1) + self.assertEqual(o.ensure_network.call_count, 1) + self.assertEqual(o.ensure_volume.call_count, 2) + self.assertEqual(o.exec_container_cmd.call_count, 0) + + # only for the shell command under "do" methods + rs.assert_called() + self.assertEqual(rs.call_count, 4) + self.assertEqual(rs.call_args_list[0].args[0], "docker image rm custom_python3") + self.assertEqual(rs.call_args_list[1].args[0], "whoami") + self.assertEqual(rs.call_args_list[2].args[0], "cd /tmp") + self.assertEqual( + rs.call_args_list[3].args[0], + "docker run bla bla --version", + ) + def test_do_docker_exec(self) -> None: side_effects = [ @@ -754,6 +806,5 @@ def test_do_docker_exec(self) -> None: "docker exec bla bla --version", ) - if __name__ == "__main__": unittest.main()