From dd0b2fdd6bb1177aa594317d4094a0c82c6ad3f6 Mon Sep 17 00:00:00 2001 From: Abhijeet Saroha <108522472+abhijeetSaroha@users.noreply.github.com> Date: Tue, 5 Nov 2024 02:38:00 +0530 Subject: [PATCH] feat(ssh): support SSH-based command execution (#124) --- .editorconfig | 30 +++++++ .github/workflows/main.yaml | 16 ++++ .makim.yaml | 21 ++++- poetry.lock | 152 +++++++++++++++++++++++++++++++++++- pyproject.toml | 4 +- src/makim/core.py | 121 +++++++++++++++++++++++++++- src/makim/logs.py | 4 + src/makim/schema.json | 34 ++++++++ tests/smoke/.env-ssh | 4 + tests/smoke/.makim-ssh.yaml | 44 +++++++++++ 10 files changed, 422 insertions(+), 8 deletions(-) create mode 100644 .editorconfig create mode 100644 tests/smoke/.env-ssh create mode 100644 tests/smoke/.makim-ssh.yaml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e3f10a6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,30 @@ +# http://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,rst,ini}] +indent_style = space +indent_size = 4 + +[*.{html,css,scss,json,yml,xml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[default.conf] +indent_style = space +indent_size = 2 + +["Makefile"] +indent_style = tab + +[*.{diff,patch}] +trim_trailing_whitespace = false diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index d2bead9..8ca9bd4 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -113,6 +113,22 @@ jobs: - name: Run unit tests run: makim tests.unittest + - name: Run smoke test for interactive args + run: makim smoke-tests.interactive-args + + - name: Run smoke test for hooks (pre and post) + run: makim smoke-tests.run-hooks + + - name: Run smoke test for matrix combinations + run: makim smoke-tests.matrix + + - name: Run smoke test for shell app + run: makim smoke-tests.shell-app + + - name: Run smoke test for ssh remote execution + if: ${{ matrix.os != 'macos' }} + run: makim smoke-tests.ssh-remote-execution + - name: Semantic Release PR Title Check uses: osl-incubator/semantic-release-pr-title-check@v1.4.1 if: success() || failure() diff --git a/.makim.yaml b/.makim.yaml index cfcfcd9..68e43ad 100644 --- a/.makim.yaml +++ b/.makim.yaml @@ -86,7 +86,8 @@ groups: - task: smoke-tests.interactive-args - task: smoke-tests.run-hooks - task: smoke-tests.matrix - - task: makim smoke-tests.shell-app + - task: smoke-tests.shell-app + - task: smoke-tests.ssh-remote-execution ci: help: Run all tasks used on CI @@ -413,6 +414,22 @@ groups: makim $VERBOSE_FLAG --file $MAKIM_FILE test.browser makim $VERBOSE_FLAG --file $MAKIM_FILE test.browser --headless + ssh-remote-execution: + help: Test makim with remote execution + args: + verbose-mode: + help: Run the all the tests in verbose mode + type: bool + action: store_true + env: + MAKIM_FILE: tests/smoke/.makim-ssh.yaml + backend: bash + run: | + export VERBOSE_FLAG='${{ "--verbose" if args.verbose_mode else "" }}' + makim $VERBOSE_FLAG --file $MAKIM_FILE --help + makim $VERBOSE_FLAG --file $MAKIM_FILE --version + makim $VERBOSE_FLAG --file $MAKIM_FILE remote_test.echo_test + error: help: This group helps tests failure tasks tasks: @@ -458,4 +475,4 @@ groups: run: | # it requires the password manually ssh-keygen -R "[localhost]:2222" || true - ssh -o StrictHostKeyChecking=no testuser@localhost -p 2222 + ssh -o StrictHostKeyChecking=no testuser@localhost -p 2222 'pwd' diff --git a/poetry.lock b/poetry.lock index bf221a6..15f892d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -223,6 +223,46 @@ test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", toml = ["tomli (>=1.1.0)"] yaml = ["PyYAML"] +[[package]] +name = "bcrypt" +version = "4.2.0" +description = "Modern password hashing for your software and your servers" +optional = false +python-versions = ">=3.7" +files = [ + {file = "bcrypt-4.2.0-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:096a15d26ed6ce37a14c1ac1e48119660f21b24cba457f160a4b830f3fe6b5cb"}, + {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c02d944ca89d9b1922ceb8a46460dd17df1ba37ab66feac4870f6862a1533c00"}, + {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d84cf6d877918620b687b8fd1bf7781d11e8a0998f576c7aa939776b512b98d"}, + {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1bb429fedbe0249465cdd85a58e8376f31bb315e484f16e68ca4c786dcc04291"}, + {file = "bcrypt-4.2.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:655ea221910bcac76ea08aaa76df427ef8625f92e55a8ee44fbf7753dbabb328"}, + {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:1ee38e858bf5d0287c39b7a1fc59eec64bbf880c7d504d3a06a96c16e14058e7"}, + {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:0da52759f7f30e83f1e30a888d9163a81353ef224d82dc58eb5bb52efcabc399"}, + {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3698393a1b1f1fd5714524193849d0c6d524d33523acca37cd28f02899285060"}, + {file = "bcrypt-4.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:762a2c5fb35f89606a9fde5e51392dad0cd1ab7ae64149a8b935fe8d79dd5ed7"}, + {file = "bcrypt-4.2.0-cp37-abi3-win32.whl", hash = "sha256:5a1e8aa9b28ae28020a3ac4b053117fb51c57a010b9f969603ed885f23841458"}, + {file = "bcrypt-4.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:8f6ede91359e5df88d1f5c1ef47428a4420136f3ce97763e31b86dd8280fbdf5"}, + {file = "bcrypt-4.2.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:c52aac18ea1f4a4f65963ea4f9530c306b56ccd0c6f8c8da0c06976e34a6e841"}, + {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bbbfb2734f0e4f37c5136130405332640a1e46e6b23e000eeff2ba8d005da68"}, + {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3413bd60460f76097ee2e0a493ccebe4a7601918219c02f503984f0a7ee0aebe"}, + {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8d7bb9c42801035e61c109c345a28ed7e84426ae4865511eb82e913df18f58c2"}, + {file = "bcrypt-4.2.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3d3a6d28cb2305b43feac298774b997e372e56c7c7afd90a12b3dc49b189151c"}, + {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:9c1c4ad86351339c5f320ca372dfba6cb6beb25e8efc659bedd918d921956bae"}, + {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:27fe0f57bb5573104b5a6de5e4153c60814c711b29364c10a75a54bb6d7ff48d"}, + {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8ac68872c82f1add6a20bd489870c71b00ebacd2e9134a8aa3f98a0052ab4b0e"}, + {file = "bcrypt-4.2.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cb2a8ec2bc07d3553ccebf0746bbf3d19426d1c6d1adbd4fa48925f66af7b9e8"}, + {file = "bcrypt-4.2.0-cp39-abi3-win32.whl", hash = "sha256:77800b7147c9dc905db1cba26abe31e504d8247ac73580b4aa179f98e6608f34"}, + {file = "bcrypt-4.2.0-cp39-abi3-win_amd64.whl", hash = "sha256:61ed14326ee023917ecd093ee6ef422a72f3aec6f07e21ea5f10622b735538a9"}, + {file = "bcrypt-4.2.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:39e1d30c7233cfc54f5c3f2c825156fe044efdd3e0b9d309512cc514a263ec2a"}, + {file = "bcrypt-4.2.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f4f4acf526fcd1c34e7ce851147deedd4e26e6402369304220250598b26448db"}, + {file = "bcrypt-4.2.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1ff39b78a52cf03fdf902635e4c81e544714861ba3f0efc56558979dd4f09170"}, + {file = "bcrypt-4.2.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:373db9abe198e8e2c70d12b479464e0d5092cc122b20ec504097b5f2297ed184"}, + {file = "bcrypt-4.2.0.tar.gz", hash = "sha256:cf69eaf5185fd58f268f805b505ce31f9b9fc2d64b376642164e9244540c1221"}, +] + +[package.extras] +tests = ["pytest (>=3.2.1,!=3.3.0)"] +typecheck = ["mypy"] + [[package]] name = "beautifulsoup4" version = "4.12.3" @@ -616,6 +656,55 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "cryptography" +version = "43.0.3" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6"}, + {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd"}, + {file = "cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73"}, + {file = "cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2"}, + {file = "cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd"}, + {file = "cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7"}, + {file = "cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16"}, + {file = "cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73"}, + {file = "cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995"}, + {file = "cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d03b5621a135bffecad2c73e9f4deb1a0f977b9a8ffe6f8e002bf6c9d07b918c"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a2a431ee15799d6db9fe80c82b055bae5a752bef645bba795e8e52687c69efe3"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:281c945d0e28c92ca5e5930664c1cefd85efe80e5c0d2bc58dd63383fda29f83"}, + {file = "cryptography-43.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f18c716be16bc1fea8e95def49edf46b82fccaa88587a45f8dc0ff6ab5d8e0a7"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4a02ded6cd4f0a5562a8887df8b3bd14e822a90f97ac5e544c162899bc467664"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53a583b6637ab4c4e3591a15bc9db855b8d9dee9a669b550f311480acab6eb08"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1ec0bcf7e17c0c5669d881b1cd38c4972fade441b27bda1051665faaa89bdcaa"}, + {file = "cryptography-43.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2ce6fae5bdad59577b44e4dfed356944fbf1d925269114c28be377692643b4ff"}, + {file = "cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "debugpy" version = "1.8.1" @@ -2096,6 +2185,27 @@ files = [ {file = "pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e"}, ] +[[package]] +name = "paramiko" +version = "3.5.0" +description = "SSH2 protocol library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "paramiko-3.5.0-py3-none-any.whl", hash = "sha256:1fedf06b085359051cd7d0d270cebe19e755a8a921cc2ddbfa647fb0cd7d68f9"}, + {file = "paramiko-3.5.0.tar.gz", hash = "sha256:ad11e540da4f55cedda52931f1a3f812a8238a7af7f62a60de538cd80bb28124"}, +] + +[package.dependencies] +bcrypt = ">=3.2" +cryptography = ">=3.3" +pynacl = ">=1.5" + +[package.extras] +all = ["gssapi (>=1.4.1)", "invoke (>=2.0)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] +gssapi = ["gssapi (>=1.4.1)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"] +invoke = ["invoke (>=2.0)"] + [[package]] name = "parso" version = "0.8.4" @@ -2341,6 +2451,32 @@ pyyaml = "*" [package.extras] extra = ["pygments (>=2.12)"] +[[package]] +name = "pynacl" +version = "1.5.0" +description = "Python binding to the Networking and Cryptography (NaCl) library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"}, + {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"}, + {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"}, + {file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"}, + {file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"}, + {file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"}, +] + +[package.dependencies] +cffi = ">=1.4.1" + +[package.extras] +docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] +tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] + [[package]] name = "pytest" version = "8.2.0" @@ -3304,6 +3440,20 @@ rich = ">=10.11.0" shellingham = ">=1.3.0" typing-extensions = ">=3.7.4.3" +[[package]] +name = "types-paramiko" +version = "3.5.0.20240928" +description = "Typing stubs for paramiko" +optional = false +python-versions = ">=3.8" +files = [ + {file = "types-paramiko-3.5.0.20240928.tar.gz", hash = "sha256:79dd9b2ee510b76a3b60d8ac1f3f348c45fcecf01347ca79e14db726bbfc442d"}, + {file = "types_paramiko-3.5.0.20240928-py3-none-any.whl", hash = "sha256:cda0aff4905fe8efe4b5448331a80e943d42a796bd4beb77a3eed3485bc96a85"}, +] + +[package.dependencies] +cryptography = ">=37.0.0" + [[package]] name = "types-python-dateutil" version = "2.9.0.20240316" @@ -3546,4 +3696,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.9,<4" -content-hash = "7886a42d84b90c157a08d8566c9afbb0afa3962b40c8fd838397d56682688b2e" +content-hash = "0fbec82975dc0c59328c69a1cbbee16adfd2fcbed57e4e3b282b07c6e4ace666" diff --git a/pyproject.toml b/pyproject.toml index 36d36d1..ec89859 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ python-levenshtein = ">=0.23.0" rich = ">=10.11.0" shellingham = ">=1.5.4" jsonschema = ">=4" +paramiko = "^3.5.0" [tool.poetry.group.dev.dependencies] containers-sugar = ">=1.11.1" @@ -60,6 +61,7 @@ jupyterlab = ">=4.1.5" nox = ">=2024.3.2" nbconvert = ">=7.16.3" pymdown-extensions = ">=10.7.1" +types-paramiko = "^3.5.0.20240928" [build-system] requires = ["poetry-core>=1.0.0", "poetry>=1.5.1"] @@ -118,7 +120,7 @@ quote-style = "single" [tool.bandit] exclude_dirs = ["tests"] targets = "src/makim/" -skips = ["B102", "B701"] +skips = ["B102", "B701", "B507", "B601"] [tool.vulture] exclude = ["tests"] diff --git a/src/makim/core.py b/src/makim/core.py index 4c83180..f5ae971 100644 --- a/src/makim/core.py +++ b/src/makim/core.py @@ -20,9 +20,10 @@ from copy import deepcopy from itertools import product from pathlib import Path -from typing import Any, Dict, List, Optional, Union, cast +from typing import Any, Dict, List, Optional, TypedDict, Union, cast import dotenv +import paramiko import sh import yaml # type: ignore @@ -60,6 +61,19 @@ ) +class HostConfig(TypedDict): + """ + Type definition for SSH host configuration containing. + + Includes username, host, port, and optional password. + """ + + username: str + host: str + port: int + password: Optional[str] + + def strip_recursively(data: Any) -> Any: """Strip strings in list and dictionaries.""" if isinstance(data, str): @@ -117,6 +131,7 @@ class Makim: group_data: dict[str, Any] = {} task_name: str = '' task_data: dict[str, Any] = {} + ssh_config: dict[str, Any] = {} def __init__(self) -> None: """Prepare the Makim class with the default configuration.""" @@ -172,6 +187,84 @@ def _call_shell_app(self, cmd: str) -> None: ) os.close(fd) + def _call_shell_remote( + self, cmd: str, host_config: dict[str, Any] + ) -> None: + try: + # Render the host configuration values + env, variables = self._load_scoped_data('task') + rendered_config = self._render_host_config(host_config, env) + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + ssh.connect( + username=rendered_config['username'], + password=rendered_config.get('password'), + hostname=rendered_config['host'], + port=rendered_config['port'], + ) + + stdin, stdout, stderr = ssh.exec_command( + cmd, environment=os.environ + ) + + if self.verbose: + MakimLogs.print_info(cmd) + + MakimLogs.print_info(stdout.read().decode('utf-8')) + + error = stderr.read().decode('utf-8') + if error: + MakimLogs.raise_error(error, MakimError.SSH_EXECUTION_ERROR) + + ssh.close() + except paramiko.AuthenticationException: + MakimLogs.raise_error( + f"Authentication failed for host {host_config['host']}", + MakimError.SSH_AUTHENTICATION_FAILED, + ) + except paramiko.SSHException as ssh_exception: + MakimLogs.raise_error( + f'SSH error: {ssh_exception!s}', + MakimError.SSH_CONNECTION_ERROR, + ) + except Exception as e: + MakimLogs.raise_error( + f'Unexpected error during remote execution: {e!s}', + MakimError.SSH_EXECUTION_ERROR, + ) + + def _render_host_config( + self, host_config: dict[str, Any], env: dict[str, str] + ) -> HostConfig: + """Render the host configuration values using Jinja2 templates.""" + rendered: dict[str, Optional[str]] = {} + + for key in ('user', 'host', 'password', 'port'): + value = host_config.get(key, '') + str_value = str(value) if value is not None else '' + + if isinstance(value, str): + str_value = TEMPLATE.from_string(str_value).render(env=env) + + if key == 'port': + rendered[key] = str_value if str_value.isdigit() else '22' + elif key == 'password': + rendered[key] = str_value if str_value else None + else: + rendered[key] = str_value + + return cast( + HostConfig, + { + 'username': rendered['user'], + 'host': rendered['host'], + 'port': int(rendered['port']) if rendered['port'] else 22, + 'password': rendered['password'], + }, + ) + def _check_makim_file(self, file_path: str = '') -> bool: return Path(file_path or self.file).exists() @@ -289,6 +382,8 @@ def _load_config_data(self) -> None: content_io = io.StringIO(content) self.global_data = yaml.safe_load(content_io) + self.ssh_config = self.global_data.get('hosts', {}) + self._validate_config() def _resolve_working_directory(self, scope: str) -> Optional[Path]: @@ -631,6 +726,7 @@ def _run_hooks(self, args: dict[str, Any], hook_type: str) -> None: def _run_command(self, args: dict[str, Any]) -> None: cmd = self.task_data.get('run', '').strip() + remote_host = self.task_data.get('remote') if not isinstance(self.group_data.get('vars', {}), dict): MakimLogs.raise_error( @@ -682,8 +778,7 @@ def _run_command(self, args: dict[str, Any]) -> None: width, _ = get_terminal_size() - # Run command for each matrix combination - for matrix_vars in matrix_combinations or [{}]: + def process_matrix_combination(matrix_vars: dict[str, Any]) -> None: # Update environment variables for k, v in env.items(): os.environ[k] = v @@ -714,7 +809,25 @@ def _run_command(self, args: dict[str, Any]) -> None: MakimLogs.print_info('=' * width) if not self.dry_run and current_cmd: - self._call_shell_app(current_cmd) + if remote_host: + host_config = self.ssh_config.get(remote_host) + if not host_config: + MakimLogs.raise_error( + f""" + Remote host '{remote_host}' configuration + not found. + """, + MakimError.REMOTE_HOST_NOT_FOUND, + ) + self._call_shell_remote( + current_cmd, cast(dict[str, Any], host_config) + ) + else: + self._call_shell_app(current_cmd) + + # Run command for each matrix combination + for matrix_vars in matrix_combinations or [{}]: + process_matrix_combination(matrix_vars) # move back the environment variable to the previous values os.environ.clear() diff --git a/src/makim/logs.py b/src/makim/logs.py index f804fcd..fb025ec 100644 --- a/src/makim/logs.py +++ b/src/makim/logs.py @@ -24,6 +24,10 @@ class MakimError(Enum): YAML_PARSING_ERROR = 12 JSON_SCHEMA_DECODING_ERROR = 13 CONFIG_VALIDATION_UNEXPECTED_ERROR = 14 + SSH_AUTHENTICATION_FAILED = 15 + SSH_CONNECTION_ERROR = 16 + SSH_EXECUTION_ERROR = 17 + REMOTE_HOST_NOT_FOUND = 18 class MakimLogs: diff --git a/src/makim/schema.json b/src/makim/schema.json index 1b3cb4b..ba3bafb 100644 --- a/src/makim/schema.json +++ b/src/makim/schema.json @@ -30,6 +30,36 @@ "type": "string", "description": "Change the directory to a specific path." }, + "hosts": { + "type": "object", + "description": "Configuration for remote hosts.", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "type": "object", + "required": ["host", "user"], + "properties": { + "host": { + "type": "string", + "description": "Hostname or IP address of the remote host." + }, + "user": { + "type": "string", + "description": "Username for SSH connection." + }, + "password": { + "type": "string", + "description": "Password for SSH connection (optional)." + }, + "port": { + "type": "string", + "description": "SSH port number (default is 22)." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, "groups": { "type": "object", "description": "Task groups defined by the user.", @@ -76,6 +106,10 @@ "type": "object", "required": [], "properties": { + "remote": { + "type": "string", + "description": "Name of the remote host to execute the task on." + }, "help": { "type": "string", "description": "Description of the task." diff --git a/tests/smoke/.env-ssh b/tests/smoke/.env-ssh new file mode 100644 index 0000000..de98f18 --- /dev/null +++ b/tests/smoke/.env-ssh @@ -0,0 +1,4 @@ +SSH_HOST=localhost +SSH_PORT=2222 +SSH_USER=testuser +SSH_PASSWORD=testpassword diff --git a/tests/smoke/.makim-ssh.yaml b/tests/smoke/.makim-ssh.yaml new file mode 100644 index 0000000..5546b5e --- /dev/null +++ b/tests/smoke/.makim-ssh.yaml @@ -0,0 +1,44 @@ +env-file: .env-ssh +hosts: + test_container: + host: ${{ env.SSH_HOST }} + port: ${{ env.SSH_PORT }} + user: ${{ env.SSH_USER }} + password: ${{ env.SSH_PASSWORD }} +backend: bash +groups: + docker: + help: Tasks with docker + tasks: + build: + help: Build the dockerfile for ssh tests + dir: containers + run: docker build -t ssh-test . + + start: + help: Start a service from the dockerfile for ssh tests + dir: containers + hooks: + pre-run: + - task: docker.build + run: docker run -d -p 2222:22 --rm --name ssh-test ssh-test + + stop: + help: Start a service from the dockerfile for ssh tests + dir: containers + env: + DOCKER_BUILDKIT: "0" + run: docker stop ssh-test + + remote_test: + tasks: + echo_test: + hooks: + pre-run: + - task: docker.start + post-run: + - task: docker.stop + remote: test_container + run: | + hostname + pwd