diff --git a/cli/testflinger_cli/__init__.py b/cli/testflinger_cli/__init__.py index c7b9a3d6..4caa7a07 100644 --- a/cli/testflinger_cli/__init__.py +++ b/cli/testflinger_cli/__init__.py @@ -150,6 +150,17 @@ def __init__(self): or os.environ.get("TESTFLINGER_SERVER") or "https://testflinger.canonical.com" ) + self.client_id = ( + self.args.client_id + or self.config.get("client_id") + or os.environ.get("TESTFLINGER_CLIENT_ID") + ) + self.secret_key = ( + self.args.secret_key + or self.config.get("secret_key") + or os.environ.get("TESTFLINGER_SECRET_KEY") + ) + # Allow config subcommand without worrying about server or client if ( hasattr(self.args, "func") @@ -185,6 +196,16 @@ def get_args(self): parser.add_argument( "--server", default=None, help="Testflinger server to use" ) + parser.add_argument( + "--client_id", + default=None, + help="Client ID to authenticate with Testflinger server", + ) + parser.add_argument( + "--secret_key", + default=None, + help="Secret key to be used with client id for authentication", + ) sub = parser.add_subparsers() arg_artifacts = sub.add_parser( "artifacts", @@ -373,11 +394,16 @@ def submit(self): except FileNotFoundError: sys.exit(f"File not found: {self.args.filename}") job_dict = yaml.safe_load(data) + if "job_priority" in job_dict: + jwt = self.authenticate_with_server() + auth_headers = {"Authorization": jwt} + else: + auth_headers = None attachments_data = self.extract_attachment_data(job_dict) if attachments_data is None: # submit job, no attachments - job_id = self.submit_job_data(job_dict) + job_id = self.submit_job_data(job_dict, headers=auth_headers) else: with tempfile.NamedTemporaryFile(suffix="tar.gz") as archive: archive_path = Path(archive.name) @@ -385,7 +411,7 @@ def submit(self): logger.info("Packing attachments into %s", archive_path) self.pack_attachments(archive_path, attachments_data) # submit job, followed by the submission of the archive - job_id = self.submit_job_data(job_dict) + job_id = self.submit_job_data(job_dict, headers=auth_headers) try: logger.info("Submitting attachments for %s", job_id) self.submit_job_attachments(job_id, path=archive_path) @@ -406,10 +432,10 @@ def submit(self): if self.args.poll: self.do_poll(job_id) - def submit_job_data(self, data: dict): + def submit_job_data(self, data: dict, headers: dict = None): """Submit data that was generated or read from a file as a test job""" try: - job_id = self.client.submit_job(data) + job_id = self.client.submit_job(data, headers=headers) except client.HTTPError as exc: if exc.status == 400: sys.exit( @@ -482,6 +508,35 @@ def submit_job_attachments(self, job_id: str, path: Path): f"failed after {tries} tries" ) + def authenticate_with_server(self): + """ + Authenticate client id and secret key with server + and return JWT with permissions + """ + if self.client_id is None or self.secret_key is None: + sys.exit("Must provide client id and secret key for priority jobs") + + try: + jwt = self.client.authenticate(self.client_id, self.secret_key) + except client.HTTPError as exc: + if exc.status == 401: + sys.exit( + "Authentication with Testflinger server failed. " + "Check your client id and secret key" + ) + if exc.status == 404: + sys.exit( + "Received 404 error from server. Are you " + "sure this is a testflinger server?" + ) + # This shouldn't happen, so let's get more information + logger.error( + "Unexpected error status from testflinger server: %s", + exc.status, + ) + sys.exit(1) + return jwt + def show(self): """Show the requested job JSON for a specified JOB_ID""" try: diff --git a/cli/testflinger_cli/client.py b/cli/testflinger_cli/client.py index a5e55426..b0f2a55d 100644 --- a/cli/testflinger_cli/client.py +++ b/cli/testflinger_cli/client.py @@ -23,6 +23,7 @@ from pathlib import Path import sys import urllib.parse +import base64 import requests @@ -45,7 +46,7 @@ class Client: def __init__(self, server): self.server = server - def get(self, uri_frag, timeout=15): + def get(self, uri_frag, timeout=15, headers=None): """Submit a GET request to the server :param uri_frag: endpoint for the GET request @@ -54,7 +55,7 @@ def get(self, uri_frag, timeout=15): """ uri = urllib.parse.urljoin(self.server, uri_frag) try: - req = requests.get(uri, timeout=timeout) + req = requests.get(uri, timeout=timeout, headers=headers) except requests.exceptions.ConnectionError: logger.error("Unable to communicate with specified server.") raise @@ -68,7 +69,7 @@ def get(self, uri_frag, timeout=15): raise HTTPError(req.status_code) return req.text - def put(self, uri_frag, data, timeout=15): + def put(self, uri_frag, data, timeout=15, headers=None): """Submit a POST request to the server :param uri_frag: endpoint for the POST request @@ -77,7 +78,9 @@ def put(self, uri_frag, data, timeout=15): """ uri = urllib.parse.urljoin(self.server, uri_frag) try: - req = requests.post(uri, json=data, timeout=timeout) + req = requests.post( + uri, json=data, timeout=timeout, headers=headers + ) except requests.exceptions.ConnectTimeout: logger.error( "Timeout while trying to communicate with the server." @@ -147,7 +150,7 @@ def post_job_state(self, job_id, state): data = {"job_state": state} self.put(endpoint, data) - def submit_job(self, data: dict) -> str: + def submit_job(self, data: dict, headers: dict = None) -> str: """Submit a test job to the testflinger server :param job_data: @@ -156,9 +159,27 @@ def submit_job(self, data: dict) -> str: ID for the test job """ endpoint = "/v1/job" - response = self.put(endpoint, data) + response = self.put(endpoint, data, headers=headers) return json.loads(response).get("job_id") + def authenticate(self, client_id: str, secret_key: str) -> dict: + """Authenticates client id and secret key with the server + and returns JWT with allowed permissions + + :param job_data: + Dictionary containing data for the job to submit + :return: + ID for the test job + """ + endpoint = "/v1/oauth2/token" + id_key_pair = f"{client_id}:{secret_key}" + encoded_id_key_pair = base64.b64encode( + id_key_pair.encode("utf-8") + ).decode("utf-8") + headers = {"Authorization": f"Basic {encoded_id_key_pair}"} + response = self.put(endpoint, {}, headers=headers) + return response + def post_attachment(self, job_id: str, path: Path, timeout: int): """Send a test job attachment to the testflinger server