diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cb822a0..ee8f1b9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,12 +12,12 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: '3.13' - name: Install Dependencies shell: bash diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9bcdfe9..52f78cb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,8 +22,8 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.7', '3.12'] # the oldest and newest support versions - nomad-version: ['1.2.16', '1.3.16', '1.4.14', '1.5.17', '1.6.10', '1.7.7'] + python-version: ['3.8', '3.13'] # the oldest and newest support versions + nomad-version: ['1.4.14', '1.5.17', '1.6.10', '1.7.7', '1.8.4', '1.9.5'] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} @@ -41,7 +41,9 @@ jobs: curl -L -o /tmp/nomad_${NOMAD_VERSION}_linux_amd64.zip https://releases.hashicorp.com/nomad/${NOMAD_VERSION}/nomad_${NOMAD_VERSION}_linux_amd64.zip echo "unzip nomad" - unzip -d /usr/local/bin/ /tmp/nomad_${NOMAD_VERSION}_linux_amd64.zip + unzip -o -d /usr/local/bin/ /tmp/nomad_${NOMAD_VERSION}_linux_amd64.zip + chmod +x /usr/local/bin/nomad + /usr/local/bin/nomad version - name: Install Dependencies shell: bash run: | @@ -60,4 +62,4 @@ jobs: run: | ./run_tests.sh - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 \ No newline at end of file + uses: codecov/codecov-action@v5 diff --git a/README.md b/README.md index 817439c..05f7815 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,9 @@ n = nomad.Nomad(host="172.16.100.10", secure=True, timeout=5, verify=True, cert= # For HTTPS Nomad instances with cert file and key n = nomad.Nomad(host="https://172.16.100.10", secure=True, timeout=5, verify=True, cert=("/path/to/certfile", "/path/to/key")) # See http://docs.python-requests.org/en/master/user/advanced/#ssl-cert-verification +# For HTTPS Nomad instance with cert file and key and CA file +n = nomad.Nomad(host="https://172.16.100.10", secure=True, timeout=5, verify="/path/to/cacert", cert=("/path/to/certfile", "/path/to/key")) + # For HTTPS Nomad instances with namespace and acl token n = nomad.Nomad(host="172.16.100.10", secure=True, timeout=5, verify=False, namespace='Namespace-example',token='3f4a0fcd-7c42-773c-25db-2d31ba0c05fe') diff --git a/docs/api/allocations.md b/docs/api/allocations.md index 4f525af..1e6ac67 100644 --- a/docs/api/allocations.md +++ b/docs/api/allocations.md @@ -18,3 +18,26 @@ allocations = my_nomad.allocations.get_allocations() for allocation in allocations: print (allocation) ``` + +### Signal allocation + +This endpoint sends a signal to an allocation or task. + +https://developer.hashicorp.com/nomad/api-docs/allocations#signal-allocation + +Example: + +``` +import signal +import nomad + +my_nomad = nomad.Nomad(host='192.168.33.10') + +alloc_id = nomad_setup.allocations.get_allocations()[0]["ID"] + +# Send signal to an allocation +my_nomad.client.allocation.signal_allocation(alloc_id, signal.SIGUSR1.name) + +# Send signal to a task in allocation +my_nomad.client.allocation.signal_allocation(alloc_id, signal.SIGUSR1.name, task="my_task") +``` diff --git a/nomad/__init__.py b/nomad/__init__.py index 406d2db..43437c5 100644 --- a/nomad/__init__.py +++ b/nomad/__init__.py @@ -1,7 +1,7 @@ """Nomad Python library""" import os -from typing import Optional +from typing import Optional, Union import requests @@ -25,7 +25,7 @@ def __init__( # pylint: disable=too-many-arguments timeout: int = 5, region: Optional[str] = None, version: str = "v1", - verify: bool = False, + verify: Union[bool, str] = False, cert: tuple = (), session: requests.Session = None, ): @@ -39,8 +39,8 @@ def __init__( # pylint: disable=too-many-arguments - user_agent (defaults None), custom user agent for requests to Nomad. - secure (defaults False), define if the protocol is secured or not (https or http) - version (defaults v1), version of the api of nomad. - - verify (defaults False), verify the certificate when tls/ssl is enabled - at nomad. + - verify (defaults False), verify SSL certificates for HTTPS requests. Can be a boolean to enable/disable + verification, or a string path to a CA certificate file. - cert (defaults empty), cert, or key and cert file to validate the certificate configured at nomad. - region (defaults None), version of the region to use. It will be used then diff --git a/nomad/api/base.py b/nomad/api/base.py index 209899b..ef340fe 100644 --- a/nomad/api/base.py +++ b/nomad/api/base.py @@ -1,6 +1,6 @@ """Requester""" -from typing import Optional +from typing import Optional, Union import requests @@ -24,7 +24,7 @@ def __init__( # pylint: disable=too-many-arguments token: Optional[str] = None, timeout: int = 5, version: str = "v1", - verify: bool = False, + verify: Union[bool, str] = False, cert: tuple = (), region: Optional[str] = None, session: requests.Session = None, diff --git a/nomad/api/client.py b/nomad/api/client.py index ebf5991..9f4374c 100644 --- a/nomad/api/client.py +++ b/nomad/api/client.py @@ -304,6 +304,22 @@ def restart_allocation(self, id_): """ return self.request(id_, "restart", method="post").json() + def signal_allocation(self, id_, signal, task=None): + """Send a signal to an allocation or task. + https://www.nomadproject.io/api-docs/allocations#signal-allocation + arguments: + - id_ + - signal (str) + optional_arguments: + - task: (str) Optional, if omitted, the signal will be sent to all tasks in the allocation. + returns: dict + raises: + - nomad.api.exceptions.BaseNomadException + - nomad.api.exceptions.URLNotFoundNomadException + """ + payload = {"Signal": signal, "Task": task} + return self.request(id_, "signal", json=payload, method="post").json() + class gc_allocation(Requester): """ diff --git a/nomad/api/job.py b/nomad/api/job.py index b8beba8..5ba2502 100644 --- a/nomad/api/job.py +++ b/nomad/api/job.py @@ -245,7 +245,14 @@ def periodic_job(self, id_): """ return self.request(id_, "periodic", "force", method="post").json() - def dispatch_job(self, id_, payload=None, meta=None): + def dispatch_job( + self, + id_, + payload=None, + meta=None, + id_prefix_template=None, + idempotency_token=None, + ): # pylint: disable=too-many-arguments """Dispatches a new instance of a parameterized job. https://www.nomadproject.io/docs/http/job.html @@ -254,12 +261,19 @@ def dispatch_job(self, id_, payload=None, meta=None): - id_ - payload - meta + - id_prefix_template + - idempotency_token returns: dict raises: - nomad.api.exceptions.BaseNomadException - nomad.api.exceptions.URLNotFoundNomadException """ - dispatch_json = {"Meta": meta, "Payload": payload} + dispatch_json = { + "Meta": meta, + "Payload": payload, + "idempotency_token": idempotency_token, + "IdPrefixTemplate": id_prefix_template, + } return self.request(id_, "dispatch", json=dispatch_json, method="post").json() def revert_job(self, id_, version, enforce_prior_version=None): diff --git a/requirements.txt b/requirements.txt index 2c24336..6e42168 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -requests==2.31.0 +requests==2.32.2 diff --git a/setup.py b/setup.py index 9c97309..c44d07b 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setuptools.setup( name='python-nomad', - version='2.0.1', + version='2.1.0', install_requires=['requests'], packages=['nomad', 'nomad.api'], url='http://github.com/jrxfive/python-nomad', @@ -26,6 +26,7 @@ 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', ], keywords='nomad hashicorp client', ) diff --git a/tests/test_client.py b/tests/test_client.py index 84bd076..5330869 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,6 @@ import pytest import json +import signal import time import os @@ -8,6 +9,21 @@ from flaky import flaky +def get_running_allocation(nomad_setup): + max_iterations = 6 + for _ in range(max_iterations): + try: + return next( + alloc + for alloc in nomad_setup.allocations.get_allocations() + if alloc["ClientStatus"] == "running" + ) + except StopIteration: + # No alloc running + time.sleep(5) + raise ValueError("No allocations running") + + # integration tests requires nomad Vagrant VM or Binary running def test_register_job(nomad_setup): @@ -17,7 +33,6 @@ def test_register_job(nomad_setup): assert "example" in nomad_setup.job max_iterations = 6 - while nomad_setup.job["example"]["Status"] != "running": time.sleep(5) if max_iterations == 0: @@ -70,11 +85,37 @@ def test_read_allocation_stats(nomad_setup): f = nomad_setup.client.allocation.read_allocation_stats(a) +@pytest.mark.skipif( + tuple(int(i) for i in os.environ.get("NOMAD_VERSION").split(".")) < (0, 9, 1), reason="Not supported in version" +) +def test_signal_allocation(nomad_setup): + alloc_id = get_running_allocation(nomad_setup)["ID"] + nomad_setup.client.allocation.signal_allocation(alloc_id, signal.SIGUSR1.name) + + +@pytest.mark.skipif( + tuple(int(i) for i in os.environ.get("NOMAD_VERSION").split(".")) < (0, 9, 1), reason="Not supported in version" +) +def test_signal_allocation_task(nomad_setup): + allocation = get_running_allocation(nomad_setup) + alloc_id = allocation["ID"] + task = list(allocation["TaskStates"].keys())[0] + nomad_setup.client.allocation.signal_allocation(alloc_id, signal.SIGUSR1.name, task) + + +@pytest.mark.skipif( + tuple(int(i) for i in os.environ.get("NOMAD_VERSION").split(".")) < (0, 9, 1), reason="Not supported in version" +) +def test_signal_allocation_invalid_signal(nomad_setup): + alloc_id = get_running_allocation(nomad_setup)["ID"] + with pytest.raises(nomad.api.exceptions.BaseNomadException, match="invalid signal"): + nomad_setup.client.allocation.signal_allocation(alloc_id, "INVALID-SIGNAL") + + @pytest.mark.skipif( tuple(int(i) for i in os.environ.get("NOMAD_VERSION").split(".")) < (0, 8, 1), reason="Not supported in version" ) def test_gc_all_allocations(nomad_setup): - node_id = nomad_setup.nodes.get_nodes()[0]["ID"] nomad_setup.client.gc_all_allocations.garbage_collect(node_id) nomad_setup.client.gc_all_allocations.garbage_collect() diff --git a/tests/test_job.py b/tests/test_job.py index 486a101..ae1eb36 100644 --- a/tests/test_job.py +++ b/tests/test_job.py @@ -125,56 +125,57 @@ def test_delete_job(nomad_setup): @flaky(max_runs=5, min_passes=1) -@pytest.mark.skipif( - tuple(int(i) for i in os.environ.get("NOMAD_VERSION").split(".")) < (0, 5, 3), reason="Nomad dispatch not supported" -) def test_dispatch_job(nomad_setup): with open("example_batch_parameterized.json") as fh: job = json.loads(fh.read()) nomad_setup.job.register_job("example-batch", job) try: - nomad_setup.job.dispatch_job("example-batch", meta={"time": "500"}) + nomad_setup.job.dispatch_job("example-batch", meta={"time": "500"}, id_prefix_template="run1") except (exceptions.URLNotFoundNomadException, exceptions.BaseNomadException) as e: print(e.nomad_resp.text) raise e assert "example-batch" in nomad_setup.job -@pytest.mark.skipif( - tuple(int(i) for i in os.environ.get("NOMAD_VERSION").split(".")) < (0, 5, 3), reason="Nomad dispatch not supported" -) +@flaky(max_runs=5, min_passes=1) +def test_dispatch_job_idempotency(nomad_setup): + with open("example_batch_parameterized.json") as fh: + job = json.loads(fh.read()) + nomad_setup.job.register_job("example-batch-idempotent", job) + + # First dispatch should succeed + try: + nomad_setup.job.dispatch_job("example-batch-idempotent", meta={"time": "500"}, id_prefix_template="run1", idempotency_token="737ae8cd-f237-43a5-8fad-0e6a3f94ad55") + except (exceptions.URLNotFoundNomadException, exceptions.BaseNomadException) as e: + print(e.nomad_resp.text) + raise e + assert "example-batch-idempotent" in nomad_setup.job + + # Second dispatch with the same idempotency token should fail + with pytest.raises(exceptions.BaseNomadException): + nomad_setup.job.dispatch_job("example-batch-idempotent", meta={"time": "500"}, id_prefix_template="run2", idempotency_token="737ae8cd-f237-43a5-8fad-0e6a3f94ad55") + + def test_summary_job(nomad_setup): j = nomad_setup.job["example"] assert "JobID" in nomad_setup.job.get_summary(j["ID"]) -@pytest.mark.skipif( - tuple(int(i) for i in os.environ.get("NOMAD_VERSION").split(".")) < (0, 4, 0), reason="Not supported in version" -) def test_plan_job(nomad_setup): with open("example.json") as fh: job = json.loads(fh.read()) assert "Index" in nomad_setup.job.plan_job(nomad_setup.job["example"]["ID"], job) -@pytest.mark.skipif( - tuple(int(i) for i in os.environ.get("NOMAD_VERSION").split(".")) < (0, 6, 0), reason="Not supported in version" -) def test_versions_job(nomad_setup): assert "Versions" in nomad_setup.job.get_versions("example") -@pytest.mark.skipif( - tuple(int(i) for i in os.environ.get("NOMAD_VERSION").split(".")) < (0, 6, 0), reason="Not supported in version" -) def test_versions_job_missing(nomad_setup): with pytest.raises(nomad.api.exceptions.URLNotFoundNomadException): assert "Versions" in nomad_setup.job.get_versions("example1") -@pytest.mark.skipif( - tuple(int(i) for i in os.environ.get("NOMAD_VERSION").split(".")) < (0, 6, 0), reason="Not supported in version" -) def test_get_job_deployments(nomad_setup): assert "JobID" in nomad_setup.job.get_deployments("example")[0] assert isinstance(nomad_setup.job.get_deployments("example"), list) @@ -182,36 +183,24 @@ def test_get_job_deployments(nomad_setup): assert "example" == nomad_setup.job.get_deployments("example")[0]["JobID"] -@pytest.mark.skipif( - tuple(int(i) for i in os.environ.get("NOMAD_VERSION").split(".")) < (0, 6, 0), reason="Not supported in version" -) def test_get_job_deployment(nomad_setup): assert "JobID" in nomad_setup.job.get_deployment("example") assert isinstance(nomad_setup.job.get_deployment("example"), dict) assert "example" == nomad_setup.job.get_deployment("example")["JobID"] -@pytest.mark.skipif( - tuple(int(i) for i in os.environ.get("NOMAD_VERSION").split(".")) < (0, 6, 0), reason="Not supported in version" -) def test_get_summary(nomad_setup): assert "JobID" in nomad_setup.job.get_summary("example") assert isinstance(nomad_setup.job.get_summary("example"), dict) assert "example" == nomad_setup.job.get_summary("example")["JobID"] -@pytest.mark.skipif( - tuple(int(i) for i in os.environ.get("NOMAD_VERSION").split(".")) < (0, 6, 0), reason="Not supported in version" -) def test_revert_job(nomad_setup): current_job_version = nomad_setup.job.get_deployment("example")["JobVersion"] prior_job_version = current_job_version - 1 nomad_setup.job.revert_job("example", prior_job_version, current_job_version) -@pytest.mark.skipif( - tuple(int(i) for i in os.environ.get("NOMAD_VERSION").split(".")) < (0, 6, 0), reason="Not supported in version" -) def test_stable_job(nomad_setup): current_job_version = nomad_setup.job.get_deployment("example")["JobVersion"] nomad_setup.job.stable_job("example", current_job_version, True)