diff --git a/ghga_connector/__init__.py b/ghga_connector/__init__.py index dccb38a2..6e5f6a20 100644 --- a/ghga_connector/__init__.py +++ b/ghga_connector/__init__.py @@ -17,4 +17,4 @@ CLI - Client to perform up- and download operations to and from a local ghga instance """ -__version__ = "0.2.1" +__version__ = "0.2.2" diff --git a/ghga_connector/cli.py b/ghga_connector/cli.py index 9d056f1a..d33e1928 100644 --- a/ghga_connector/cli.py +++ b/ghga_connector/cli.py @@ -59,16 +59,23 @@ def upload( # noqa C901 *, file_id: str = typer.Option(..., help="The id if the file to upload"), file_path: Path = typer.Option(..., help="The path to the file to upload"), + pubkey_path: Path = typer.Argument( + "./key.pub", + help="The path to a public key from the key pair that was used to encrypt the " + + "crypt4gh envelope. Defaults to the file key.pub in the current folder.", + ), ): """ Command to upload a file """ core.RequestsSession.configure(config.max_retries) + core.upload( api_url=config.upload_api, file_id=file_id, file_path=file_path, message_display=CLIMessageDisplay(), + pubkey_path=pubkey_path, ) diff --git a/ghga_connector/core/api_calls.py b/ghga_connector/core/api_calls.py index e1fa0344..c6e6e722 100644 --- a/ghga_connector/core/api_calls.py +++ b/ghga_connector/core/api_calls.py @@ -18,11 +18,14 @@ Contains calls to the GHGA storage API """ +import base64 import json from enum import Enum +from pathlib import Path from time import sleep from typing import Dict, Iterator, Tuple, Union +import crypt4gh.keys import requests from requests.structures import CaseInsensitiveDict @@ -51,7 +54,12 @@ class UploadStatus(str, Enum): UPLOADED = "uploaded" -def initiate_multipart_upload(*, api_url: str, file_id: str) -> Tuple[str, int]: +def initiate_multipart_upload( + *, + api_url: str, + file_id: str, + pubkey_path: Path, +) -> Tuple[str, int]: """ Perform a RESTful API call to initiate a multipart upload Returns an upload id and a part size @@ -60,7 +68,9 @@ def initiate_multipart_upload(*, api_url: str, file_id: str) -> Tuple[str, int]: # build url and headers url = f"{api_url}/uploads" headers = {"Accept": "application/json", "Content-Type": "application/json"} - post_data = {"file_id": file_id} + public_key = base64.b64encode(crypt4gh.keys.get_public_key(pubkey_path)).decode() + + post_data = {"file_id": file_id, "public_key": public_key} serialized_data = json.dumps(post_data) # Make function call to get upload url @@ -344,12 +354,18 @@ def download_api_call( return download_url, file_size, NO_RETRY_TIME -def start_multipart_upload(*, api_url: str, file_id: str) -> Tuple[str, int]: +def start_multipart_upload( + *, api_url: str, file_id: str, pubkey_path: Path +) -> Tuple[str, int]: """Try to initiate a multipart upload. If it fails, try to cancel the current upload can and then try to initiate a multipart upload again.""" try: - multipart_upload = initiate_multipart_upload(api_url=api_url, file_id=file_id) + multipart_upload = initiate_multipart_upload( + api_url=api_url, + file_id=file_id, + pubkey_path=pubkey_path, + ) return multipart_upload except exceptions.NoUploadPossibleError as error: file_metadata = get_file_metadata(api_url=api_url, file_id=file_id) @@ -363,7 +379,9 @@ def start_multipart_upload(*, api_url: str, file_id: str) -> Tuple[str, int]: upload_status=UploadStatus.CANCELLED, ) - multipart_upload = initiate_multipart_upload(api_url=api_url, file_id=file_id) + multipart_upload = initiate_multipart_upload( + api_url=api_url, file_id=file_id, pubkey_path=pubkey_path + ) except Exception as error: raise error diff --git a/ghga_connector/core/exceptions.py b/ghga_connector/core/exceptions.py index ccbd5033..98a0c352 100644 --- a/ghga_connector/core/exceptions.py +++ b/ghga_connector/core/exceptions.py @@ -41,13 +41,21 @@ def __init__(self, *, output_file: str): class FileDoesNotExistError(RuntimeError): - """Thrown, when the specified file already exists.""" + """Thrown, when the specified file does not exist.""" def __init__(self, *, file_path: Path): message = f"The file {file_path} does not exist." super().__init__(message) +class PubKeyFileDoesNotExistError(RuntimeError): + """Thrown, when the specified public key file already exists.""" + + def __init__(self, *, pubkey_path: Path): + message = f"The public key file {pubkey_path} does not exist." + super().__init__(message) + + class ApiNotReachableError(RuntimeError): """Thrown, when the api is not reachable.""" diff --git a/ghga_connector/core/main.py b/ghga_connector/core/main.py index 95babf4c..9420cf7e 100644 --- a/ghga_connector/core/main.py +++ b/ghga_connector/core/main.py @@ -50,17 +50,22 @@ def check_url(api_url, *, wait_time=1000) -> bool: return True -def upload( # noqa C901, pylint: disable=too-many-statements +def upload( # noqa C901, pylint: disable=too-many-statements,too-many-branches *, api_url: str, file_id: str, file_path: Path, message_display: AbstractMessageDisplay, + pubkey_path: Path, ) -> None: """ Core command to upload a file. Can be called by CLI, GUI, etc. """ + if not os.path.isfile(pubkey_path): + message_display.failure(f"The file {pubkey_path} does not exist.") + raise exceptions.PubKeyFileDoesNotExistError(pubkey_path=pubkey_path) + if not os.path.isfile(file_path): message_display.failure(f"The file {file_path} does not exist.") raise exceptions.FileDoesNotExistError(file_path=file_path) @@ -70,7 +75,9 @@ def upload( # noqa C901, pylint: disable=too-many-statements raise exceptions.ApiNotReachableError(api_url=api_url) try: - upload_id, part_size = start_multipart_upload(api_url=api_url, file_id=file_id) + upload_id, part_size = start_multipart_upload( + api_url=api_url, file_id=file_id, pubkey_path=pubkey_path + ) except exceptions.NoUploadPossibleError as error: message_display.failure( f"This user can't start a multipart upload for the file_id '{file_id}'" diff --git a/scripts/license_checker.py b/scripts/license_checker.py index 631515b7..332e2672 100755 --- a/scripts/license_checker.py +++ b/scripts/license_checker.py @@ -69,7 +69,7 @@ ] # exclude file by file ending from license header check: -EXCLUDE_ENDINGS = ["json", "pyc", "yaml", "yml", "md", "html", "xml"] +EXCLUDE_ENDINGS = ["json", "pub", "pyc", "sec", "yaml", "yml", "md", "html", "xml"] # exclude any files with names that match any of the following regex: EXCLUDE_PATTERN = [r".*\.egg-info.*", r".*__cache__.*", r".*\.git.*"] diff --git a/setup.cfg b/setup.cfg index c3f667b5..eb9f3be2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,9 +35,10 @@ zip_safe = False include_package_data = True packages = find: install_requires = - ghga-service-chassis-lib[s3]==0.15.1 - httpyexpect==0.2.2 + ghga-service-chassis-lib[s3]==0.16.1 + httpyexpect==0.2.4 typer==0.4.1 + crypt4gh==1.6 python_requires = >= 3.9.10 @@ -47,7 +48,7 @@ console_scripts = [options.extras_require] dev = - ghga-service-chassis-lib[dev]==0.15.1 + ghga-service-chassis-lib[dev]==0.16.1 fastapi uvicorn diff --git a/tests/fixtures/keypair/key.pub b/tests/fixtures/keypair/key.pub new file mode 100644 index 00000000..c16c9146 --- /dev/null +++ b/tests/fixtures/keypair/key.pub @@ -0,0 +1,3 @@ +-----BEGIN CRYPT4GH PUBLIC KEY----- +P1oBHfr9AA37Kg8WW79RKKpDZlp77KoFx+n+3sLzPBY= +-----END CRYPT4GH PUBLIC KEY----- diff --git a/tests/fixtures/keypair/key.sec b/tests/fixtures/keypair/key.sec new file mode 100644 index 00000000..e3107173 --- /dev/null +++ b/tests/fixtures/keypair/key.sec @@ -0,0 +1,3 @@ +-----BEGIN CRYPT4GH PRIVATE KEY----- +YzRnaC12MQAEbm9uZQAEbm9uZQAgvT/vZ90UMmestkwn/7GDRU4uCxzz91KvP017CjYyY0Q= +-----END CRYPT4GH PRIVATE KEY----- diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index dede411a..5fba6d09 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -34,6 +34,9 @@ from tests.fixtures.config import get_test_config from tests.fixtures.mock_api.testcontainer import MockAPIContainer from tests.fixtures.s3 import S3Fixture, get_big_s3_object, s3_fixture # noqa: F401 +from tests.fixtures.utils import BASE_DIR + +PUBLIC_KEY_FILE = BASE_DIR / "keypair/key.pub" @pytest.mark.parametrize( @@ -184,6 +187,7 @@ def test_upload( upload( file_id=uploadable_file.file_id, file_path=uploadable_file.file_path.resolve(), + pubkey_path=Path(PUBLIC_KEY_FILE), ) s3_fixture.storage.complete_multipart_upload( @@ -259,6 +263,7 @@ def test_multipart_upload( upload( file_id=file_id, file_path=Path(file.name), + pubkey_path=Path(PUBLIC_KEY_FILE), ) # confirm upload