diff --git a/.isort.cfg b/.isort.cfg index 7fa1b117d09b99cf7ce2a459b8f071e9af81dd89..5bc2f237603d937d42e4db424ed6e105ca2d0c45 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -8,4 +8,4 @@ line_length = 88 default_section=FIRSTPARTY known_first_party = arkindex,arkindex_common -known_third_party =PIL,apistar,pytest,requests,setuptools,tenacity,yaml +known_third_party =PIL,apistar,gnupg,pytest,requests,setuptools,tenacity,yaml diff --git a/arkindex_worker/worker.py b/arkindex_worker/worker.py index 664de9465a97f1157dd44b41626d63b8b540961e..be46a870e6f6d22ba89fd60fea79dce978ade54c 100644 --- a/arkindex_worker/worker.py +++ b/arkindex_worker/worker.py @@ -6,7 +6,9 @@ import os import sys import uuid from enum import Enum +from pathlib import Path +import gnupg import yaml from apistar.exceptions import ErrorResponse @@ -87,16 +89,68 @@ class BaseWorker(object): f"Loaded worker {worker_version['worker']['name']} revision {worker_version['revision']['hash'][0:7]} from API" ) self.config = worker_version["configuration"]["configuration"] + required_secrets = worker_version["configuration"].get("secrets", []) elif self.args.config: # Load config from YAML file self.config = yaml.safe_load(self.args.config) + required_secrets = self.config.get("secrets", []) logger.info( f"Running with local configuration from {self.args.config.name}" ) else: self.config = {} + required_secrets = [] logger.warning("Running without any extra configuration") + # Load all required secrets + self.secrets = {name: self.load_secret(name) for name in required_secrets} + + def load_secret(self, name): + """Load all secrets described in the worker configuration""" + secret = None + + # Load from the backend + try: + resp = self.api_client.request("RetrieveSecret", name=name) + secret = resp["content"] + logging.info(f"Loaded API secret {name}") + except ErrorResponse as e: + logger.warning(f"Secret {name} not available: {e.content}") + + # Load from local developer storage + base_dir = Path(os.environ.get("XDG_CONFIG_HOME") or "~/.config").expanduser() + path = base_dir / "arkindex" / "secrets" / name + if path.exists(): + logging.debug(f"Loading local secret from {path}") + + try: + gpg = gnupg.GPG() + decrypted = gpg.decrypt_file(open(path, "rb")) + assert ( + decrypted.ok + ), f"GPG error: {decrypted.status} - {decrypted.stderr}" + secret = decrypted.data.decode("utf-8") + logging.info(f"Loaded local secret {name}") + except Exception as e: + logger.error(f"Local secret {name} is not available as {path}: {e}") + + if secret is None: + raise Exception(f"Secret {name} is not available on the API nor locally") + + # Parse secret payload, according to its extension + _, ext = os.path.splitext(os.path.basename(name)) + try: + ext = ext.lower() + if ext == ".json": + return json.loads(secret) + elif ext in (".yaml", ".yml"): + return yaml.safe_load(secret) + except Exception as e: + logger.error(f"Failed to parse secret {name}: {e}") + + # By default give raw secret payload + return secret + def add_arguments(self): """Override this method to add argparse argument to this worker""" diff --git a/requirements.txt b/requirements.txt index 827a074e475ff0195689aa3e001f0c2cb55a67cb..5ae91b593903359e9d9cd01b9063fab17b7cf10b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ arkindex-client==1.0.2 Pillow==7.2.0 +python-gnupg==0.4.6 tenacity==6.2.0 diff --git a/tests/conftest.py b/tests/conftest.py index 672f51c36a9061ebb1432762c103cb38a82670fd..b293eee996d13cfe1398a47627e8c080c224aa51 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,6 +54,7 @@ def mock_worker_version_api(responses): "configuration": { "docker": {"image": "python:3"}, "configuration": {"someKey": "someValue"}, + "secrets": [], }, "revision": { "hash": "deadbeef1234", diff --git a/tests/test_base_worker.py b/tests/test_base_worker.py index bb329321a9aaed5eab224c987bc301172edaed20..dc39b4c0fdb6cc1db7d264bed5ecb820291215f3 100644 --- a/tests/test_base_worker.py +++ b/tests/test_base_worker.py @@ -4,6 +4,10 @@ import os import sys from pathlib import Path +import gnupg +import pytest + +from arkindex.mock import MockApiClient from arkindex_worker import logger from arkindex_worker.worker import BaseWorker @@ -101,3 +105,107 @@ def test_cli_arg_verbose_given(mocker, mock_worker_version_api): assert worker.config == {"someKey": "someValue"} # from API logger.setLevel(logging.NOTSET) + + +def test_load_missing_secret(): + worker = BaseWorker() + worker.api_client = MockApiClient() + + with pytest.raises( + Exception, match="Secret missing/secret is not available on the API nor locally" + ): + worker.load_secret("missing/secret") + + +def test_load_remote_secret(): + worker = BaseWorker() + worker.api_client = MockApiClient() + worker.api_client.add_response( + "RetrieveSecret", + name="testRemote", + response={"content": "this is a secret value !"}, + ) + + assert worker.load_secret("testRemote") == "this is a secret value !" + + # The one mocked call has been used + assert len(worker.api_client.history) == 1 + assert len(worker.api_client.responses) == 0 + + +def test_load_json_secret(): + worker = BaseWorker() + worker.api_client = MockApiClient() + worker.api_client.add_response( + "RetrieveSecret", + name="path/to/file.json", + response={"content": '{"key": "value", "number": 42}'}, + ) + + assert worker.load_secret("path/to/file.json") == { + "key": "value", + "number": 42, + } + + # The one mocked call has been used + assert len(worker.api_client.history) == 1 + assert len(worker.api_client.responses) == 0 + + +def test_load_yaml_secret(): + worker = BaseWorker() + worker.api_client = MockApiClient() + worker.api_client.add_response( + "RetrieveSecret", + name="path/to/file.yaml", + response={ + "content": """--- +somekey: value +aList: + - A + - B + - C +struct: + level: + X +""" + }, + ) + + assert worker.load_secret("path/to/file.yaml") == { + "aList": ["A", "B", "C"], + "somekey": "value", + "struct": {"level": "X"}, + } + + # The one mocked call has been used + assert len(worker.api_client.history) == 1 + assert len(worker.api_client.responses) == 0 + + +def test_load_local_secret(monkeypatch, tmpdir): + # Setup arkindex config dir in a temp directory + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmpdir)) + + # Write a dummy secret + secrets_dir = tmpdir / "arkindex" / "secrets" + os.makedirs(secrets_dir) + secret = secrets_dir / "testLocal" + secret.write_text("this is a local secret value", encoding="utf-8") + + # Mock GPG decryption + class GpgDecrypt(object): + def __init__(self, fd): + self.ok = True + self.data = fd.read() + + monkeypatch.setattr(gnupg.GPG, "decrypt_file", lambda gpg, f: GpgDecrypt(f)) + + worker = BaseWorker() + worker.api_client = MockApiClient() + + assert worker.load_secret("testLocal") == "this is a local secret value" + + # The remote api is checked first + assert len(worker.api_client.history) == 1 + assert worker.api_client.history[0].operation == "RetrieveSecret"