Skip to content

Commit

Permalink
Add authentication to CLI when submitting jobs with priority (#360)
Browse files Browse the repository at this point in the history
* Add authentication to CLI when submitting jobs with priority

* Add lint exception for too many methods

* Added documentation for authentication in the CLI

* Added unit tests for authorization functionality within job submission

* Added more documentation describing job priority and authentication

* fix spelling errors in docs

* Add env to spell check wordlist

* More spelling rules and fixes

* Minor documentation changes
  • Loading branch information
val500 authored Nov 1, 2024
1 parent 9b3285f commit 66b231f
Show file tree
Hide file tree
Showing 12 changed files with 213 additions and 11 deletions.
5 changes: 5 additions & 0 deletions cli/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ You may also set the environment variable 'TESTFLINGER_SERVER' to
the URI of your server, and it will prefer that over the default
or the string specified by --server.

To specify Testflinger authentication parameters, like client_id
and secret_key, you can use '--client_id' and '--secret_key' respectively.
You can also specify these parameters as environment variables,
'TESTFLINGER_CLIENT_ID' and 'TESTFLINGER_SECRET_KEY'.

To submit a new test job, first create a yaml or json file containing
the job definition. Then run:
.. code-block:: console
Expand Down
64 changes: 60 additions & 4 deletions cli/testflinger_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ class AttachmentError(Exception):
"""Exception thrown when attachments fail to be submitted"""


# pylint: disable=R0904
class TestflingerCli:
"""Class for handling the Testflinger CLI"""

Expand All @@ -150,6 +151,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")
Expand Down Expand Up @@ -185,6 +197,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",
Expand Down Expand Up @@ -373,19 +395,24 @@ 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)
# create attachments archive prior to job submission
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)
Expand All @@ -406,10 +433,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(
Expand Down Expand Up @@ -482,6 +509,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:
Expand Down
33 changes: 27 additions & 6 deletions cli/testflinger_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from pathlib import Path
import sys
import urllib.parse
import base64

import requests

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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."
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
42 changes: 42 additions & 0 deletions cli/testflinger_cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,48 @@ def test_submit_attachments_timeout(tmp_path):
assert history[3].path == f"/v1/job/{job_id}/action"


def test_submit_with_priority(tmp_path, requests_mock):
"""Tests authorization of jobs submitted with priority"""
job_id = str(uuid.uuid1())
job_data = {
"queue": "fake",
"job_priority": 100,
}
job_file = tmp_path / "test.json"
job_file.write_text(json.dumps(job_data))

sys.argv = ["", "submit", str(job_file)]
tfcli = testflinger_cli.TestflingerCli()
tfcli.client_id = "my_client_id"
tfcli.secret_key = "my_secret_key"

fake_jwt = "my_jwt"
requests_mock.post(f"{URL}/v1/oauth2/token", text=fake_jwt)
mock_response = {"job_id": job_id}
requests_mock.post(f"{URL}/v1/job", json=mock_response)
tfcli.submit()
assert requests_mock.last_request.headers.get("Authorization") == fake_jwt


def test_submit_priority_no_credentials(tmp_path):
"""Tests priority jobs rejected with no specified credentials"""
job_data = {
"queue": "fake",
"job_priority": 100,
}
job_file = tmp_path / "test.json"
job_file.write_text(json.dumps(job_data))

sys.argv = ["", "submit", str(job_file)]
tfcli = testflinger_cli.TestflingerCli()
with pytest.raises(SystemExit) as exc_info:
tfcli.submit()
assert (
"Must provide client id and secret key for priority jobs"
in exc_info.value
)


def test_show(capsys, requests_mock):
"""Exercise show command"""
jobid = str(uuid.uuid1())
Expand Down
4 changes: 4 additions & 0 deletions docs/.wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ APIs
authorized
artifact
artifacts
Authorization
autoinstall
balancer
BG
Expand All @@ -23,6 +24,7 @@ EBS
EFI
EKS
EMMC
env
favicon
Git
GitHub
Expand All @@ -38,6 +40,7 @@ IoT
Jira
JSON
Juju
JWT
Kubeflow
Kubernetes
KVM
Expand All @@ -63,6 +66,7 @@ netboot
NodePort
noprovision
NVidia
oauth
observability
OEM
oem
Expand Down
9 changes: 9 additions & 0 deletions docs/explanation/authentication.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Authentication and Authorisation
--------------------------------

Authentication requires a client_id and a secret_key. These credentials can be
obtained by contacting the server administrator with the queues you want priority
access for as well as the maximum priority level to set for each queue. The
expectation is that these credentials are shared between users on a team.

These credentials can be :doc:`set using the Testflinger CLI <../how-to/authentication>`.
2 changes: 2 additions & 0 deletions docs/explanation/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ This section covers conceptual questions about Testflinger.

agents
queues
job-priority
authentication
9 changes: 9 additions & 0 deletions docs/explanation/job-priority.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Job Priority
============

Adding job priority to your jobs gives them the ability to be selected before
other jobs. Job priority can be specified by adding the job_priority field to
your job YAML. This field takes an integer value with a default value of 0. Jobs
with a higher job_priority value will be selected over jobs with lower value.
Using this feature requires :doc:`authenticating <./authentication>` with
Testflinger server.
36 changes: 36 additions & 0 deletions docs/how-to/authentication.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
Authentication using Testflinger CLI
====================================

:doc:`Authentication <../explanation/authentication>` is only required for submitting jobs with priority.

Authenticating with Testflinger server requires a client id and a secret key.
These credentials can be provided to the CLI using the environment variables
``TESTFLINGER_CLIENT_ID`` and ``TESTFLINGER_SECRET_KEY``. You can put these
variables in a .env file:

.. code-block:: shell
TESTFLINGER_CLIENT_ID=my_client_id
TESTFLINGER_SECRET_KEY=my_secret_key
You can then export these variables in your shell:

.. code-block:: shell
set -a
source .env
set +a
With these variables set, you can ``testflinger_cli submit`` your jobs normally, and the authentication will be done by the CLI
automatically.

Alternatively, you can set the client id and secret key using
command line arguments:

.. code-block:: shell
$ testflinger-cli submit example-job.yaml --client_id "my_client_id" --secret_key "my_secret_key"
However, this is not recommended for security purposes.
2 changes: 2 additions & 0 deletions docs/how-to/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ Work with jobs via Testflinger CLI
submit-job
cancel-job
search-job
job-priority
authentication
15 changes: 15 additions & 0 deletions docs/how-to/job-priority.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
Submit a test job with priority
===============================

You can add the :doc:`job_priority <../explanation/job-priority>` field to your
job YAML like this:

.. code-block:: yaml
job_priority: 100
This field requires an integer value with a default value of 0. The maximum
priority you can set depends on the permissions that you have for the queue
you are submitting to.

In order to use this field, you need an :doc:`authorisation token <./authentication>` from the server.
3 changes: 2 additions & 1 deletion docs/reference/job-schema.rst
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ The following table lists the key elements that a job definition file should con
- | (Optional) URL to send job status updates to. These updates originate from the agent and get posted to the server which then posts the update to the webhook. If no webhook is specified, these updates will not be generated.
* - ``job_priority``
- integer
- | (Optional) Integer specifying how much priority this job has. Jobs with higher job_priority will be selected by agents before other jobs. Specifying a job priority requires authorization in the form of a JWT obtained by sending a POST request to /v1/oauth2/token with a client id and client key specified in an Authorization header.
- 0
- | (Optional) Integer specifying how much priority this job has. Jobs with higher job_priority will be selected by agents before other jobs. Specifying a job priority requires authorisation in the form of a JWT obtained by sending a POST request to /v1/oauth2/token with a client id and client key specified in an `Authorization` header.

Example jobs in YAML
----------------------------
Expand Down

0 comments on commit 66b231f

Please sign in to comment.