From 8d8c2d9e004248cf72d0abe04a9f9e6bef8acbcf Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Fri, 19 Jan 2024 17:43:25 +0000 Subject: [PATCH 01/11] Add init command --- nlds_client/clientlib/config.py | 38 +++++++ nlds_client/clientlib/nlds_client_setup.py | 3 +- nlds_client/clientlib/transactions.py | 117 +++++++++++++++++---- nlds_client/nlds_client.py | 57 +++++++++- requirements.txt | 4 + setup.py | 5 +- 6 files changed, 197 insertions(+), 27 deletions(-) diff --git a/nlds_client/clientlib/config.py b/nlds_client/clientlib/config.py index dbe448b..ab745eb 100644 --- a/nlds_client/clientlib/config.py +++ b/nlds_client/clientlib/config.py @@ -4,6 +4,9 @@ from click import option from nlds_client.clientlib.nlds_client_setup import get_config_file_location from nlds_client.clientlib.exceptions import ConfigError + +TEMPLATE_FILE_LOCATION = os.path.join(os.path.dirname(__file__), + '../templates/nlds-config.j2') CONFIG_FILE_LOCATION = get_config_file_location() def validate_config_file(json_config): @@ -135,6 +138,40 @@ def load_config(): return json_config +def create_config(url: str): + # Read contents of template file + with open(os.path.expanduser(f"{TEMPLATE_FILE_LOCATION}"), 'r') as f_templ: + template_contents = json.load(f_templ) + + # Change the default server to something useable + template_contents['server']['url'] = url + + # Delete the tenancy option from the config so the user doesn't + # accidentally leave it empty + del template_contents["object_storage"]["tenancy"] + + # Location of config file should be {CONFIG_FILE_LOCATION}. Create it and + # fail if it already exists + with open(os.path.expanduser(f"{CONFIG_FILE_LOCATION}"), 'x') as f: + json.dump(template_contents, f, indent=4) + + # Lastly, validate the config file to make sure we're not missing anyhting + validate_config_file(template_contents) + + return template_contents + + +def write_auth_section(config, auth_config, url: str = None): + # Second, validate the config again and make sure we're not missing anything + validate_config_file(config) + + # Overwrite the authentication block with the one given and write it back to + # the file + config['authentication'] |= auth_config + with open(os.path.expanduser(f"{CONFIG_FILE_LOCATION}"), 'w') as f: + json.dump(config, f, indent=4) + + def get_user(config, user): """Get the user from either the function parameter or the config.""" user = user @@ -192,6 +229,7 @@ def get_access_key(config): access_key = config["object_storage"]["access_key"] return access_key + def get_secret_key(config): """Get the object storage secret key from the config file. """ secret_key = "" diff --git a/nlds_client/clientlib/nlds_client_setup.py b/nlds_client/clientlib/nlds_client_setup.py index bcbfba4..f421d7c 100644 --- a/nlds_client/clientlib/nlds_client_setup.py +++ b/nlds_client/clientlib/nlds_client_setup.py @@ -4,4 +4,5 @@ def get_config_file_location(): CONFIG_DIR = os.environ["HOME"] return CONFIG_DIR + "/.nlds-config" -CONFIG_FILE_LOCATION = get_config_file_location() \ No newline at end of file +CONFIG_FILE_LOCATION = get_config_file_location() +DEFAULT_SERVER_URL = "http://nlds.jasmin.ac.uk" diff --git a/nlds_client/clientlib/transactions.py b/nlds_client/clientlib/transactions.py index dc858cd..a9e7689 100644 --- a/nlds_client/clientlib/transactions.py +++ b/nlds_client/clientlib/transactions.py @@ -1,23 +1,37 @@ -import requests import json import uuid import urllib.parse import os from pathlib import Path from datetime import datetime +from base64 import b64decode +from typing import List, Dict, Any -from typing import List, Dict +import requests -from nlds_client.clientlib.config import load_config, get_user, get_group, \ - get_option, \ - get_tenancy, get_access_key, get_secret_key -from nlds_client.clientlib.authentication import load_token,\ - get_username_password,\ - fetch_oauth2_token,\ - fetch_oauth2_token_from_refresh +from nlds_client.clientlib.config import ( + load_config, + create_config, + write_auth_section, + get_user, + get_group, + get_option, + get_tenancy, + get_access_key, + get_secret_key +) +from nlds_client.clientlib.authentication import ( + load_token, + get_username_password, + fetch_oauth2_token, + fetch_oauth2_token_from_refresh +) from nlds_client.clientlib.exceptions import * -from nlds_client.clientlib.nlds_client_setup import CONFIG_FILE_LOCATION +from nlds_client.clientlib.nlds_client_setup import ( + CONFIG_FILE_LOCATION, + DEFAULT_SERVER_URL, +) def construct_server_url(config: Dict, method=""): @@ -134,6 +148,7 @@ def tag_to_string(tag: dict): def main_loop(url: str, input_params: dict={}, body_params: dict={}, + authenticate_fl: bool = True, method=requests.get): """Generalised main loop to make requests to the NLDS server :param url: the API URL to contact @@ -158,21 +173,23 @@ def main_loop(url: str, while c_try < MAX_LOOPS: c_try += 1 - try: - auth_token = load_token(config) - except FileNotFoundError: - # we need the username and password to get the OAuth2 token in - # the password flow - username, password = get_username_password(config) - auth_token = fetch_oauth2_token(config, username, password) - # we don't want to do the rest of the loop! - continue - token_headers = { "Content-Type" : "application/json", "cache-control" : "no-cache", - "Authorization" : f"Bearer {auth_token['access_token']}" } + + # Attempt to do authentication if flag set + if authenticate_fl: + try: + auth_token = load_token(config) + except FileNotFoundError: + # we need the username and password to get the OAuth2 token in + # the password flow + username, password = get_username_password(config) + auth_token = fetch_oauth2_token(config, username, password) + # we don't want to do the rest of the loop! + continue + token_headers["Authorization"] = f"Bearer {auth_token['access_token']}" # make the request try: @@ -895,4 +912,60 @@ def change_metadata(user: str, # mark as failed in RPC call elif "details" in response_dict and "failure" in response_dict["details"]: response_dict["success"] = False - return response_dict \ No newline at end of file + return response_dict + + +def init_client(url: str = None) -> Dict[str, Any]: + """Make two requests to the API to get some secret, encrypted configuration + information and then the token to help decrypt it. + + :return: A dict containing information about the outcome of the initiation, + i.e. whether it succeeded and whether a file was created. + :rtype: Dict + """ + from cryptography.fernet import Fernet + + # Use the default NLDS url if none provided + if url is None: + url = DEFAULT_SERVER_URL + cli_response = {"success": True, "new_config": False} + + try: + # get the config, if it exists, and change the url to that provided + config = load_config() + config['server']['url'] = url + except FileNotFoundError: + # If the file doesn't exist then create it + config = create_config(url) + cli_response["new_config"] = True + + responses = {} + for endpoint in ['init', 'init/token']: + url = construct_server_url(config, endpoint) + response_dict = main_loop( + url=url, + input_params=None, + method=requests.get + ) + + # If we get to this point then the transaction could not be processed + if not response_dict: + raise RequestError(f"Could not init NLDS, empty response") + responses[endpoint] = response_dict + + try: + key = b64decode(responses["init/token"]["token"]) + auth_config_enc = responses["init"]["encrypted_keys"] + except (KeyError, AttributeError) as e: + raise RequestError("Malformed init response from api, could not create " + f"config file {type(e).__name__}:{e}", + requests.codes.unprocessable) + + # Decrypt the encrypted keys + f = Fernet(key) + auth_config = json.loads(f.decrypt(auth_config_enc)) + + # Write auth_config to config file + write_auth_section(config, auth_config) + + return cli_response diff --git a/nlds_client/nlds_client.py b/nlds_client/nlds_client.py index c6521f0..32816f4 100755 --- a/nlds_client/nlds_client.py +++ b/nlds_client/nlds_client.py @@ -6,10 +6,13 @@ list_holding, find_file, monitor_transactions, get_transaction_state, - change_metadata) -from nlds_client.clientlib.exceptions import ConnectionError, RequestError, \ - AuthenticationError + change_metadata, + init_client) +from nlds_client.clientlib.exceptions import (ConnectionError, + RequestError, + AuthenticationError) from nlds_client.clientlib.config import get_user, get_group, load_config +from nlds_client.clientlib.nlds_client_setup import CONFIG_FILE_LOCATION json=False @@ -782,6 +785,54 @@ def meta(user, group, label, holding_id, tag, new_label, new_tag, del_tag, json) except RequestError as re: raise click.UsageError(re) +@nlds_client.command( + "init", + help=(f"Set up the nlds client on first use. Will either create a new " + "config file if one doesn't exist or fill the 'authentication' " + "section with appropriate values if it does.") +) +@click.option("-u", "--url", default=None, type=str, + help=("Url to use for getting config info. Must start with " + "http(s)://")) +def init(url: str = None): + click.echo(click.style("Initiating the Near-line Data Store...\n", + fg="yellow")) + try: + response = init_client(url) + if (("success" in response and not response['success']) + or "new_config" not in response): + raise RequestError(f"Could not init NLDS, something has gone wrong") + success_msg = f"Successfully initiated, " + path_str = click.style(CONFIG_FILE_LOCATION, fg='black') + if response["new_config"]: + success_msg += (f"a template config file has been created at " + f"{path_str} with some of the information necessary" + " to use the NLDS.") + else: + success_msg += (f"the config file at {path_str} has been updated " + "with some of the necessary information to start " + "using the NLDS.") + + link_str = click.style('https://s3-portal.jasmin.ac.uk/', fg="blue") + success_msg += ("\n\nYou may still need to manually update the fields:" + "\n - user.default_user \n - user.default_group " + "\n - object_storage.access_key" + "\n - object_storage.secret_key" + "\n - object_storage.tenancy " + + click.style( + '(will default to nlds-cache-02 if not set)', + fg='yellow') + + "\n\nThe latter three values can be obtained from the " + "object store portal for any object stores you have " + f"access to ({link_str}).") + click.echo(success_msg) + + except ConnectionError as ce: + raise click.UsageError(ce) + except RequestError as re: + raise click.UsageError(re) + except Exception as e: + raise click.UsageError(e) def main(): nlds_client(prog_name="nlds") diff --git a/requirements.txt b/requirements.txt index f41f423..dbfbd48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,10 @@ certifi==2023.11.17 charset-normalizer==3.3.2 click==8.1.7 +<<<<<<< Updated upstream +======= +cryptography==41.0.7 +>>>>>>> Stashed changes idna==3.6 -e git+ssh://git@github.com/cedadev/nlds-client.git@91498130427e9d77885745b41f530d9cefcb0b50#egg=nlds_client oauthlib==3.2.2 diff --git a/setup.py b/setup.py index f7e8a26..aee2f8e 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name='nlds_client', - version='0.0.2', + version='0.2.1', packages=find_packages(), install_requires=[ 'requests', @@ -17,6 +17,9 @@ 'click', ], include_package_data=True, + package_data={ + 'nlds_client': ['templates/*'], + }, license='LICENSE.txt', # example license description=('Client libary and command line for CEDA Near-Line Data Store'), long_description=README, From c4f513e660b683dced33f709dc504bb63ffdcf66 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Fri, 19 Jan 2024 17:47:32 +0000 Subject: [PATCH 02/11] Add cryptography back to requirements --- requirements.txt | 3 --- 1 file changed, 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index dbfbd48..37b007d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,7 @@ certifi==2023.11.17 charset-normalizer==3.3.2 click==8.1.7 -<<<<<<< Updated upstream -======= cryptography==41.0.7 ->>>>>>> Stashed changes idna==3.6 -e git+ssh://git@github.com/cedadev/nlds-client.git@91498130427e9d77885745b41f530d9cefcb0b50#egg=nlds_client oauthlib==3.2.2 From 9d99f1744f438ba10bdf963390a9dbc0ced62986 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Fri, 19 Jan 2024 17:49:49 +0000 Subject: [PATCH 03/11] Add cryptography to setup.py --- requirements.txt | 1 - setup.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 37b007d..ea3653c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,6 @@ charset-normalizer==3.3.2 click==8.1.7 cryptography==41.0.7 idna==3.6 --e git+ssh://git@github.com/cedadev/nlds-client.git@91498130427e9d77885745b41f530d9cefcb0b50#egg=nlds_client oauthlib==3.2.2 requests==2.31.0 requests-oauthlib==1.3.1 diff --git a/setup.py b/setup.py index aee2f8e..f89ba88 100644 --- a/setup.py +++ b/setup.py @@ -15,6 +15,7 @@ 'requests', 'requests_oauthlib', 'click', + "cryptography", ], include_package_data=True, package_data={ From da025cb7f49edd75144a2e9180c1d6be14af3be3 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Sat, 20 Jan 2024 14:35:22 +0000 Subject: [PATCH 04/11] Update request method kwargs access --- nlds_client/clientlib/config.py | 5 +-- nlds_client/clientlib/nlds_client_setup.py | 2 +- nlds_client/clientlib/transactions.py | 39 +++++++++++++++++++--- nlds_client/nlds_client.py | 8 +++-- 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/nlds_client/clientlib/config.py b/nlds_client/clientlib/config.py index ab745eb..7764068 100644 --- a/nlds_client/clientlib/config.py +++ b/nlds_client/clientlib/config.py @@ -138,13 +138,14 @@ def load_config(): return json_config -def create_config(url: str): +def create_config(url: str, verify_certificates: bool): # Read contents of template file with open(os.path.expanduser(f"{TEMPLATE_FILE_LOCATION}"), 'r') as f_templ: template_contents = json.load(f_templ) # Change the default server to something useable template_contents['server']['url'] = url + template_contents['options']['verify_certificates'] = verify_certificates # Delete the tenancy option from the config so the user doesn't # accidentally leave it empty @@ -161,7 +162,7 @@ def create_config(url: str): return template_contents -def write_auth_section(config, auth_config, url: str = None): +def write_auth_section(config, auth_config): # Second, validate the config again and make sure we're not missing anything validate_config_file(config) diff --git a/nlds_client/clientlib/nlds_client_setup.py b/nlds_client/clientlib/nlds_client_setup.py index f421d7c..62fe755 100644 --- a/nlds_client/clientlib/nlds_client_setup.py +++ b/nlds_client/clientlib/nlds_client_setup.py @@ -5,4 +5,4 @@ def get_config_file_location(): return CONFIG_DIR + "/.nlds-config" CONFIG_FILE_LOCATION = get_config_file_location() -DEFAULT_SERVER_URL = "http://nlds.jasmin.ac.uk" +DEFAULT_SERVER_URL = "https://nlds.jasmin.ac.uk" diff --git a/nlds_client/clientlib/transactions.py b/nlds_client/clientlib/transactions.py index a9e7689..b8b75f9 100644 --- a/nlds_client/clientlib/transactions.py +++ b/nlds_client/clientlib/transactions.py @@ -149,7 +149,8 @@ def main_loop(url: str, input_params: dict={}, body_params: dict={}, authenticate_fl: bool = True, - method=requests.get): + method=requests.get, + **kwargs): """Generalised main loop to make requests to the NLDS server :param url: the API URL to contact :type user: string @@ -170,6 +171,20 @@ def main_loop(url: str, config = load_config() c_try = 0 MAX_LOOPS = 2 + + # Prioritise kwarg over config file value + if "verify" in kwargs: + verify = kwargs.pop("verify") + else: + verify = get_option(config, 'verify_certificates') + + # If we're not verifying the certificate we can turn off the warnings about + # it + if not verify: + pass + from urllib3.connectionpool import InsecureRequestWarning + import warnings + warnings.filterwarnings("ignore", category=InsecureRequestWarning) while c_try < MAX_LOOPS: c_try += 1 @@ -198,7 +213,8 @@ def main_loop(url: str, headers = token_headers, params = input_params, json = body_params, - verify = get_option(config, 'verify_certificates') + verify = verify, + **kwargs, ) except requests.exceptions.ConnectionError: raise ConnectionError( @@ -915,10 +931,21 @@ def change_metadata(user: str, return response_dict -def init_client(url: str = None) -> Dict[str, Any]: +def init_client( + url: str = None, + verify_certificates: bool = True, + ) -> Dict[str, Any]: """Make two requests to the API to get some secret, encrypted configuration information and then the token to help decrypt it. + :param url: The url to request initiation details from. Must start with + 'http://' or 'https://'. + :type url: str, optional + + :param verify_certificates: Boolean flag controlling whether to verify ssl + certificates during the get request. + :type verify_certificates: bool, optional + :return: A dict containing information about the outcome of the initiation, i.e. whether it succeeded and whether a file was created. :rtype: Dict @@ -936,7 +963,7 @@ def init_client(url: str = None) -> Dict[str, Any]: config['server']['url'] = url except FileNotFoundError: # If the file doesn't exist then create it - config = create_config(url) + config = create_config(url, verify_certificates) cli_response["new_config"] = True responses = {} @@ -945,7 +972,9 @@ def init_client(url: str = None) -> Dict[str, Any]: response_dict = main_loop( url=url, input_params=None, - method=requests.get + method=requests.get, + allow_redirects=True, + verify=verify_certificates, ) # If we get to this point then the transaction could not be processed diff --git a/nlds_client/nlds_client.py b/nlds_client/nlds_client.py index 32816f4..50ced65 100755 --- a/nlds_client/nlds_client.py +++ b/nlds_client/nlds_client.py @@ -794,11 +794,15 @@ def meta(user, group, label, holding_id, tag, new_label, new_tag, del_tag, json) @click.option("-u", "--url", default=None, type=str, help=("Url to use for getting config info. Must start with " "http(s)://")) -def init(url: str = None): +@click.option("-k", "--verify", default=True, type=bool, + help="Boolean flag to control whether to verify ssl certificates " + "during request. Defaults to true, only needs to be False " + "for the staging/test version of the nlds.") +def init(url: str = None, verify: bool = True): click.echo(click.style("Initiating the Near-line Data Store...\n", fg="yellow")) try: - response = init_client(url) + response = init_client(url, verify_certificates=verify) if (("success" in response and not response['success']) or "new_config" not in response): raise RequestError(f"Could not init NLDS, something has gone wrong") From 3d314f6e3cc889caf3571fbd7c4267520e368807 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Sun, 21 Jan 2024 10:52:53 +0000 Subject: [PATCH 05/11] Switch verify option to a flag --- nlds_client/nlds_client.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/nlds_client/nlds_client.py b/nlds_client/nlds_client.py index 50ced65..ff985ba 100755 --- a/nlds_client/nlds_client.py +++ b/nlds_client/nlds_client.py @@ -791,18 +791,21 @@ def meta(user, group, label, holding_id, tag, new_label, new_tag, del_tag, json) "config file if one doesn't exist or fill the 'authentication' " "section with appropriate values if it does.") ) -@click.option("-u", "--url", default=None, type=str, - help=("Url to use for getting config info. Must start with " - "http(s)://")) -@click.option("-k", "--verify", default=True, type=bool, - help="Boolean flag to control whether to verify ssl certificates " - "during request. Defaults to true, only needs to be False " - "for the staging/test version of the nlds.") -def init(url: str = None, verify: bool = True): +@click.option( + "-u", "--url", default=None, type=str, + help=("Url to use for getting config info. Must start with http(s)://"), +) +@click.option( + "-k", "--insecure", is_flag=True, default=False, + help="Boolean flag to control whether to turn off verification of ssl " + "certificates during request. Defaults to true, only needs to be False" + " for the staging/test version of the nlds." +) +def init(url: str = None, insecure: bool = False): click.echo(click.style("Initiating the Near-line Data Store...\n", fg="yellow")) try: - response = init_client(url, verify_certificates=verify) + response = init_client(url, verify_certificates=(not insecure)) if (("success" in response and not response['success']) or "new_config" not in response): raise RequestError(f"Could not init NLDS, something has gone wrong") From 3f1d5ff1a380d35486995e7e842bea11b7ce6fb2 Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Tue, 23 Jan 2024 13:56:55 +0000 Subject: [PATCH 06/11] Add build directory to gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8932e95..f8ffcad 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .pytest_cache/ *egg-info .venv/ -tests/test_dir* \ No newline at end of file +tests/test_dir* +build/ \ No newline at end of file From 41b31df438b5c13023f22f92817aea78bcb9e56c Mon Sep 17 00:00:00 2001 From: Jack Leland Date: Thu, 25 Jan 2024 09:07:54 +0000 Subject: [PATCH 07/11] Implement auto-generation of docs --- .github/workflows/sphinx.yml | 33 + .gitignore | 5 +- docs/.nojekyll | 0 docs/index.html | 8 + docs/user_guide/requirements.txt | 4 + .../source/catalog_organisation.rst | 175 ++++ docs/user_guide/source/command_ref.rst | 30 + docs/user_guide/source/conf.py | 10 +- docs/user_guide/source/configuration.rst | 56 ++ docs/user_guide/source/index.rst | 41 + docs/user_guide/source/installation.rst | 23 + docs/user_guide/source/license.rst | 35 + docs/user_guide/source/simple_catalog.png | Bin 0 -> 28308 bytes docs/user_guide/source/simple_catalog.puml | 37 + docs/user_guide/source/status_codes.rst | 54 + docs/user_guide/source/tutorial.rst | 942 ++++++++++++++++++ docs/user_guide/source/userview.png | Bin 0 -> 20498 bytes docs/user_guide/source/userview.puml | 28 + 18 files changed, 1475 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/sphinx.yml create mode 100644 docs/.nojekyll create mode 100644 docs/index.html create mode 100644 docs/user_guide/requirements.txt create mode 100644 docs/user_guide/source/catalog_organisation.rst create mode 100644 docs/user_guide/source/command_ref.rst create mode 100644 docs/user_guide/source/configuration.rst create mode 100644 docs/user_guide/source/index.rst create mode 100644 docs/user_guide/source/installation.rst create mode 100644 docs/user_guide/source/license.rst create mode 100644 docs/user_guide/source/simple_catalog.png create mode 100644 docs/user_guide/source/simple_catalog.puml create mode 100644 docs/user_guide/source/status_codes.rst create mode 100644 docs/user_guide/source/tutorial.rst create mode 100644 docs/user_guide/source/userview.png create mode 100644 docs/user_guide/source/userview.puml diff --git a/.github/workflows/sphinx.yml b/.github/workflows/sphinx.yml new file mode 100644 index 0000000..1212193 --- /dev/null +++ b/.github/workflows/sphinx.yml @@ -0,0 +1,33 @@ +name: Sphinx build + +on: + push: + branches: [ "main", "development" ] + pull_request: + branches: [ "main" ] + + +jobs: + build: + runs-on: ubuntu-latest + steps: + # Checkout and build the docs with sphinx + - uses: actions/checkout@v2 + - name: Build HTML + uses: ammaraskar/sphinx-action@master + with: + docs-folder: "docs/user_guide" + # pre-build-command: "mkdir /tmp/sphinx-log" + - name: Upload artifacts + uses: actions/upload-artifact@v1 + with: + name: html-docs + path: docs/user_guide/build/html/ + # Deploys to the gh-pages branch if the commit was made to main, the + # gh-pages then takes over serving the html + - name: Deploy + uses: peaceiris/actions-gh-pages@v3 + if: github.ref == 'refs/heads/main' + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: docs/build/html \ No newline at end of file diff --git a/.gitignore b/.gitignore index f8ffcad..806b931 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ *egg-info .venv/ tests/test_dir* -build/ \ No newline at end of file +**/.DS_Store + +build/ +docs/user_guide/build/ \ No newline at end of file diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..0368041 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/user_guide/requirements.txt b/docs/user_guide/requirements.txt new file mode 100644 index 0000000..08bbf46 --- /dev/null +++ b/docs/user_guide/requirements.txt @@ -0,0 +1,4 @@ +Sphinx==7.2.6 +sphinx-click==5.1.0 +sphinx-rtd-theme==2.0.0 +-r ../../requirements.txt \ No newline at end of file diff --git a/docs/user_guide/source/catalog_organisation.rst b/docs/user_guide/source/catalog_organisation.rst new file mode 100644 index 0000000..d63a73c --- /dev/null +++ b/docs/user_guide/source/catalog_organisation.rst @@ -0,0 +1,175 @@ +.. _catalog_organisation: + +Catalog Organisation +==================== + +When a user PUTs files to the NLDS, the files are recorded in a catalog on +behalf of the user. The user can then list which files they have in the catalog +and also search for files based on a regular expression. Additionally, users +can associate a label and tags, in the form of *key:value* pairs with a file or +collection of files. + +*Figure 1* shows a simplified version of the structure of the catalog, with just +the information relevant to the user remaining. + +.. figure:: ./simple_catalog.png + + Figure 1: Simplified view of the NLDS data-catalog + +The terms in figure 1 are explained below: + +#. :ref:`Holdings` +#. :ref:`Transactions` +#. :ref:`Tags` +#. :ref:`File` +#. :ref:`Location` + +.. _holding: + +Holdings +-------- + +**Holdings** are collections of files, that the user has chosen to collect +together and assign a label to the collection. A reason to collect files in a +holding might be that they are from the same experiment, or climate model run, +or measuring campaign. Users can give the holding a **label**, but if they do +not then a seemingly random **label** will be assigned to the holding. This is +actually the id of the first **transaction** that created the holding. Users +can change the **label** that a holding has at any time. + +**Holdings** are created when a user PUTs a file into the NLDS, using either the +``nlds put`` or ``nlds putlist`` command. These commands take a **label** +argument with the ``-l`` or ``--label`` option. The first time a user PUTs a +file, or list of files, into a **holding**, the **holding** will be created. +If a **label** is specified then the **holding** will be assigned that **label**. +If a **label** is not specified then the seemingly random **label** will be +assigned. + +After this, if a user PUTs a file into the NLDS and specifies a **label** for a +**holding** that already exists, then the file will be added to that **holding**. +If the **holding** with the specified **label** does not exist then the file +will be added to a new **holding**. This leads to the behaviour that, if a +**label** is not specified when PUTting a file (or list of files) into the NLDS, +a new **holding** will be created for each file (or list of files). + +Reading this, you may ask the question "What happens if I add a file that +already exists in the NLDS?". This is a good question, and a number of rules +cover it: + +1. The ``original_path`` of a file must be unique within a **holding**. An +error is given if a user PUTs a file into a **holding** that already exists and +the file with ``original_path`` already exists in the **holding**. + +2. The ``original_path`` does not have to be unique across **holdings**. +Multiple files with the same ``original_path`` can exist in the NLDS, providing +that they belong to different **holdings**, with different **labels**. + +3. Users can GET files without specifying which **holding** to get them from, +i.e. the ``-l`` or ``--label`` option is not given when ``nlds get`` or ``nlds +getlist`` commands are invoked. In this case, the newest file is returned. + +Organising the catalog in this way means that users can use the NLDS as an +iterative backup solution, by PUTting files into differently labelled +**holdings** at different times. GETting the files will returned the latest +files, while leaving the older files still accessible by specifying the +**holding** **label**. + +.. _transaction: + +Transactions +------------ + +**Transactions** record the user's action when PUTting a file into the NLDS. +As alluded to above, in the :ref:`_holding` section, each **holding** can contain +numerous **transactions**. A **transaction** is created every time a user PUTs +a single file, or list of files, into the NLDS. This **transaction** is assigned +to a holding based on the **label** supplied by the user. If a **label** is +specified for a number of PUT actions, then the **holding** with that label will +contain all the **transactions** arising from the PUT actions. + +A **transaction** contains very little information itself, but its place in the +catalog hierarchy is important. As can be seen in figure 2, it contains a list +of **files** and it belongs to a **holding**. This is the mapping that allows +users to add files to **holdings** iteratively and at different times. For +example, a user may PUT the files ``file_1``, ``file_2`` and ``file_3`` into the +**holding** with ``backup_1`` **label** on the 23rd Dec 2023. The user may then +PUT ``file_4``, ``file_5`` and ``file_6`` into the same **holding** on the 4th +Jan 2024, by specifying the label ``backup_1``. This will have the effect of +creating two **transactions** - one containing ``file_1``, ``file_2`` and ``file_3`` +and the other containing ``file_4``, ``file_5`` and ``file_6``, with the +``backup_1`` **holding** containing both **transactions**. Therefore, all **files** +(``file_1`` through to ``file_6``) are associated with the ``backup_1`` +**holding** at particular ``ingest_times``. + +If, at a later time, the user puts ``file_1`` to ``file_6`` into +another **holding** with a **label** of ``backup_2`` then another +**transaction** will be created with a later ``ingest_time`` and the **files** +will be associated with the **transaction** and the ``backup_2`` **holding**. +The **files** may have changed in the interim and, therefore, the **files** +with the same filenames may be different in ``backup_2`` than they are in +``backup_1``. This is the mechanism by which NLDS allows users to perform +iterative backups and how users can get the latest files, via the ``ingest_time``. + +.. _tags: + +Tags +---- + +NLDS allows the user to associate **tags** with a **holding**, in a +``key:value`` format. For example, a series of **holding** could have **tags** +with the ``key`` as ``experiment`` and ``value`` as the experiment name or +number. + +A **holding** can contain numerous **tags** and these are in addition to the +**holdings** **label**. **Tags** can be used for searching for files in the +``list`` and ``find`` commands. + +.. _file: + +File +---- + +The very purpose of NLDS is the long term storage of **files**, recording their +details in a data catalog and then accessing (GETting) them when they are +required. The **file** object in the data catalog records the details of a +single **file**, including the original path of the file, its size and the +ownership and permissions of the file. Users can GET files in a number of ways, +including by using just the ``original_path`` where the NLDS will return the +most recent file with that path. + +Also associated with **files** is the checksum of the file. NLDS supports +different methods of calculating checksums, and so more than one checksum can +be associated with a single file. + +.. _location: + +Location +-------- + +The user interacts with the NLDS by PUTting and GETting **files**, without knowing +(or caring) where those **files** are stored. From a user view, the **files** are +stored in the NLDS. In reality the NLDS first writes the **files** to *object +storage*. Later the **files** are backed up to *tape storage*. When the NLDS +*object storage* approaches capacity, **files** will be removed from the +*object storage* depending on a policy which takes into account several variables, +including when they were last accessed. If a user subsequently GETs a **file** +that has removed from the *object storage* then the NLDS will first retrive +the **file** from the *tape storage* to the *object storage* before copying it +to the user specified target. + +The **location** object in the Catalog database is associated to a file, and +can have one of three states: + +1. The **file** is held on the *object storage* only. It will be backed up +to the *tape storage* later. + +2. The **file** is held on both the *object storage* and *tape storage*. Users +can access the file without any staging required by the NLDS. + +3. The **file** is held on the *tape storage* only. If a user accesses the +**file** then the NLDS will *stage* it to the *tape storage*, before completing +the GET on behalf of the user. The user does not need to concern themselves +with the details of this. However, accessing a file that is stored only on +*tape* will take longer than if it was held on *object storage*. + + diff --git a/docs/user_guide/source/command_ref.rst b/docs/user_guide/source/command_ref.rst new file mode 100644 index 0000000..f32f7e5 --- /dev/null +++ b/docs/user_guide/source/command_ref.rst @@ -0,0 +1,30 @@ +Command Line Reference +====================== + +The primary method of interacting with the Near-Line Data Store is through a +command line client, which can be installed using the instructions. + +Users must specify a command to the ``nlds`` and options and arguments for that +command. + +``nlds [OPTIONS] COMMAND [ARGS]...`` + +As an overview the commands are: + +Commands: + | ``find Find and list files.`` + | ``get Get a single file.`` + | ``getlist Get a number of files specified in a list.`` + | ``list List holdings.`` + | ``meta Alter metadata for a holding.`` + | ``put Put a single file.`` + | ``putlist Put a number of files specified in a list.`` + | ``stat List transactions.`` + +Each command has its own specific options. The argument is generally the file +or filelist that the user wishes to operate on. The full command listing is +given below. + +.. click:: nlds_client.nlds_client:nlds_client + :prog: nlds + :nested: full \ No newline at end of file diff --git a/docs/user_guide/source/conf.py b/docs/user_guide/source/conf.py index a432b94..5dbce95 100644 --- a/docs/user_guide/source/conf.py +++ b/docs/user_guide/source/conf.py @@ -5,11 +5,11 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information - -project = 'Near-Line Data Store client' -copyright = '2023, Neil Massey and Jack Leland' +project = 'Near-Line Data Store' +copyright = '2023, Centre for Environmental Data Analysis, Science and Technologies Facilities Council, UK Research and Innovation' author = 'Neil Massey and Jack Leland' -release = '0.1.0' +version = '0.1.1' +release = '0.1.1-RC1' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -24,5 +24,5 @@ # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = 'alabaster' +html_theme = 'sphinx_rtd_theme' html_static_path = ['_static'] diff --git a/docs/user_guide/source/configuration.rst b/docs/user_guide/source/configuration.rst new file mode 100644 index 0000000..c09c8ab --- /dev/null +++ b/docs/user_guide/source/configuration.rst @@ -0,0 +1,56 @@ +.. _configuration: + +Configuration File +================== + +When the user first invokes ``nlds`` from the command line or issues a command +from the ``nlds_client.clientlib`` API, a configuration file is required in the +user's home directory with the path: + +``~/.nlds-config`` + +This configuration file is JSON formatted and contains the authentication +credentials required by: + + * The OAuth server + * The Object Storage + +It also contains the default user and group to use when issuing a request to the +NLDS. These can be overriden by the ``-u|--user`` and ``-g|--group`` command +line options. + +Finally, it contains the URL of the server and the API version, and the location +of the OAuth token file that is also created the first time the ``nlds`` command +is invoked. + +An example configuration file is shown below. Authentication details have been +redacted. You will have to contact the service provider to gain these +credentials. + +:: + + { + "server" : { + "url" : "{{ nlds_api_url }}", + "api" : "{{ nlds_api_version }}" + }, + "user" : { + "default_user" : "{{ user_name }}", + "default_group" : "{{ user_gws }}" + }, + "authentication" : { + "oauth_client_id" : "{{ oauth_client_id }}", + "oauth_client_secret" : "{{ oauth_client_secret }}", + "oauth_token_url" : "{{ oauth_token_url }}", + "oauth_scopes" : "{{ oauth_scopes }}"", + "oauth_token_file_location" : "~/.nlds-token" + }, + "object_storage" : { + "access_key" : "{{ object_store_access_key }}", + "secret_key" : "{{ object_store_secret_key }}" + + }, + "option" : { + "resolve_filenames" : "false" + } + } diff --git a/docs/user_guide/source/index.rst b/docs/user_guide/source/index.rst new file mode 100644 index 0000000..30a1e81 --- /dev/null +++ b/docs/user_guide/source/index.rst @@ -0,0 +1,41 @@ +.. Near-Line Data Store client documentation master file, created by + sphinx-quickstart on Thu Feb 2 16:17:53 2023. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Near-Line Data Store documentation +================================== + +The Near-Line Data Store (NLDS) is a multi-tiered storage solution that uses +Object Storage as a front end to a tape library. It catalogs the data as it is +ingested and permits multiple versions of files. It has a microservice +architecture using a message broker to communicate between the parts. +Interaction with NLDS is via a HTTP API, with a Python library and command-line +client provided to users for programmatic or interactive use. + +.. toctree:: + :maxdepth: 1 + :caption: Contents: + + installation.rst + configuration.rst + catalog_organisation.rst + tutorial.rst + status_codes.rst + command_ref.rst + license.rst + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + +NLDS was developed at the `Centre for Environmental Data Analysis `_ +with support from the ESiWACE2 project. The project ESiWACE2 has received +funding from the European Union's Horizon 2020 research and innovation programme +under grant agreement No 823988. + +NLDS is Open-Source software with a BSD-2 Clause License. The license can be +read :ref:`here `. \ No newline at end of file diff --git a/docs/user_guide/source/installation.rst b/docs/user_guide/source/installation.rst new file mode 100644 index 0000000..79bb65d --- /dev/null +++ b/docs/user_guide/source/installation.rst @@ -0,0 +1,23 @@ +.. |br| raw:: html + +
+ +.. _installation: + +Installation +============ +To use NLDS, first you must install the client software. This guide will show +you how to install it into a Python virtual-environment (virtualenv) in your +user space or home directory. + +#. log onto the machine where you wish to install the JDMA client into your + user space or home directory. + +#. Create a Python virtual environment: |br| + ``python3 -m venv ~/nlds-client`` + +#. Activate the nlds-client: |br| + ``source ~/nlds-client/bin/activate`` + +#. Install the nlds-client package from github: |br| + ``pip install git+https://github.com/cedadev/nlds-client.git@0.1.1#egg=nlds-client`` diff --git a/docs/user_guide/source/license.rst b/docs/user_guide/source/license.rst new file mode 100644 index 0000000..c819e08 --- /dev/null +++ b/docs/user_guide/source/license.rst @@ -0,0 +1,35 @@ +.. _license: + +NLDS License +------------ + +NLDS is Open Source software made available under a BSD 2-Clause License. + +BSD 2-Clause License +==================== + +Copyright (c) 2019-2023, Centre of Environmental Data Analysis Developers, +Scientific and Technical Facilities Council (STFC), +UK Research and Innovation (UKRI). +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/docs/user_guide/source/simple_catalog.png b/docs/user_guide/source/simple_catalog.png new file mode 100644 index 0000000000000000000000000000000000000000..c2254931bddda47d7cf244f62666623367e68d09 GIT binary patch literal 28308 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 +* :ref:`Setting up the NLDS client ` +* :ref:`Running the NLDS client for the first time ` +* :ref:`How the NLDS data catalog is organised ` +* :ref:`Getting help on the NLDS commands ` +* :ref:`Copying a single file (PUT) to the NLDS ` +* :ref:`Copying a list of files (PUTLIST) to the NLDS ` +* :ref:`Querying the status of a transaction (STAT) ` +* :ref:`Querying the file collections the user holds on the NLDS (LIST) ` +* :ref:`Querying the files the user holds on the NLDS (FIND) ` +* :ref:`Changing the label of a file collection (META)