-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
api: initial implementation of headless API (Bug 1941363) #194
base: main
Are you sure you want to change the base?
Conversation
Build out the basic functionality of the headless API for Lando. Using django-ninja we define two API endpoints, to POST automation jobs and GET job statuses after submission. The API endpoints take a set of `actions` defined in the request body which are stored in the database for processing by a worker. Authentication is handled by an API key associated with a user profile. A single action, `add-commit` is implemented which can be used to test adding patches to the repo as commits.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Couple of very early comments:
- the new api functionality would be better located in the
lando.api
app not thelando.main
app. It could also go into a more specificheadless_api
app if we want to separate it from other functionality that was ported or will be ported from the old API. - I noticed some boilerplate functionality around generating / saving / getting API tokens, as well as some model fields to encrypt / decrypt / store those. Seems like potentially something that should be offloaded to the auth system (and the API framework), and not something that we should manually implement as libraries already exist that do this.
- This PR should be broken up into multiple PRs (e.g., adding API functionality, adding "action" endpoints and worker functionality).
Again very early feedback based on a quick first pass.
Thanks for looking it over, the PR is still a draft but I wanted to get something up to show progress.
I had originally put the new code in
I just re-used the prior art around Phabricator API token storage/retrieval, but I haven't implemented anything for the API token generation. I was expecting to add the token management to the "settings page" where the Phab API tokens are managed.
I will try and split out some of the changes around test fixtures, and perhaps the token generation/storage/etc could go in a separate PR. Moving the API definition out of the PR where the worker/job/etc is defined seems like it will make things harder to review since the API will do nothing but write to a DB, but I'll look into it. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looking good. Lots of half-baked musing in my comments. Feel free to consider or discard as you see fit (:
# TODO test a few more things? formatting? | ||
|
||
|
||
PATCH_NORMAL_1 = r""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might be worth grabbing this from the existing conftests (there's a normal_patch
fixture that return the desired patch).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also wondering if we should have a Git-formatted patch in the mix, too.
def clear_lando_api_key(self): | ||
"""Set the Lando API key to an empty string and save.""" | ||
self.save_lando_api_key("") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: I wonder if we'd be better making the key a random thing. I see there's a check to see if it's empty or not just above, but I feel it would be less error-prone to have a key that noone knows, rather than an empty string that one might forget to check in subsequent refactor.
Or maybe just make the key non decryptable (or the column nullable).
@@ -77,6 +77,9 @@ class Meta: | |||
# Encrypted Phabricator API token. | |||
encrypted_phabricator_api_key = models.BinaryField(default=b"", blank=True) | |||
|
|||
# Encrypted API key. | |||
encrypted_lando_api_key = models.BinaryField(default=b"", blank=True) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Continuing my thought from the previous comment) I think we should make this column nullable, so that we can set it to None
when clearing it, rather than encrypting an empty string, and having to check if the decrypted string is empty.
AutomationJob, on_delete=models.CASCADE, related_name="actions" | ||
) | ||
|
||
action_type = models.CharField() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it be better as a models.ChoiceField
?
class AutomationJob(BaseModel): | ||
"""An automation job. | ||
|
||
TODO write better docstring |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO write better docstring | |
TODO Write better docstring. |
q:
) | ||
|
||
def run_automation_job(self, job: AutomationJob) -> bool: | ||
"""Run an automation job.""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"""Run an automation job.""" | |
"""Run an automation job. | |
Returns True if the job is in a permanent failure state and should not be retried. | |
""" |
repo = job.target_repo | ||
scm = repo.scm | ||
|
||
# TODO should we check treestatus? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wondering if TreeStatus should have a “CLOSING” state during which jobs like this are allowed to terminate and before the tree is fully closed.
This would mean no need to check here, but also clearer feedback in TreeStatus that the tree is still active (albeit draining).
# TODO should we always update to the latest pull_path for a repo? | ||
# or perhaps we need to specify some commit SHA? | ||
scm.update_repo(repo.pull_path) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At the moment it cleans the repo back to a known state, and pulls the default branch.
) -> bool: | ||
"""Process an automation action.""" | ||
if action.action == "add-commit": | ||
return self.add_commit_action(job, repo, scm, action) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wondering the pro/cons of having the action on the ... err... Action
object instead, so you could just do a action.run(job, repo, scm)
patch_helper = HgPatchHelper(StringIO(action.content)) | ||
|
||
date = patch_helper.get_header("Date") | ||
user = patch_helper.get_header("User") | ||
|
||
try: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wondering if there's a way to modularise the code from the landing worker, as this is essentially the same feature...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we could add the run()
method as suggested in https://github.com/mozilla-conduit/lando/pull/194/files#r1923160292, and then just have the landing_worker also create an AddCommitAction
and run it, so we have a single codepath for both.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few more comments related to our discussion on tokens.
class HeadlessAPIAuthentication(HttpBearer): | ||
"""Authentication class to verify API token.""" | ||
|
||
def authenticate(self, request, token: str) -> str: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As we need a user name, I wonder if we'd be better off with Basic Auth, so we have a standard carrier for user+secret, rather than piggy-backing on the User-Agent (or other) header to pass the user identifier.
I agree with this. We can require a user-agent field, but we shouldn't use it as a username. At that point, we basically do have a username / password basic authentication.
However, if we want to continue with this username + token approach (which would have the added benefit of being able to manage the token more easily, than managing a user's password), I think it would make sense to create an api_token
table to store these tokens in, and we can have a foreign key to a user profile. This is if we don't want to use any third party libraries.
try: | ||
user = User.objects.get(email=user_agent) | ||
except User.DoesNotExist: | ||
raise APIPermissionDenied(f"No user found for `User-Agent` {user_agent}") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be better to be more explicit about a username or email here, and not use the user-agent for it. The user agent should be something that can change (e.g., it may include a version number, for example).
# some APIs may have authentication without user management. Our | ||
# API tokens always correspond to a specific user, so set that on | ||
# the request here. | ||
request.user = user |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wondering if we should be using something like django.auth.login
here to properly mark the user as authenticated. I.e., request.user.is_authenticated
should be True
.
@contextmanager | ||
def job_processing(job: AutomationJob): | ||
"""Mutex-like context manager that manages job processing miscellany. | ||
|
||
This context manager facilitates graceful worker shutdown, tracks the duration of | ||
the current job, and commits changes to the DB at the very end. | ||
|
||
Args: | ||
job: the job currently being processed | ||
db: active database session | ||
""" | ||
start_time = datetime.now() | ||
try: | ||
yield | ||
finally: | ||
job.duration_seconds = (datetime.now() - start_time).seconds |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is copy-pasted, but should probably just be slightly modified and reused.
return | ||
|
||
with job_processing(job): | ||
job.status = LandingJobStatus.IN_PROGRESS |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this a copy/paste error?
class HeadlessAPIAuthentication(HttpBearer): | ||
"""Authentication class to verify API token.""" | ||
|
||
def authenticate(self, request, token: str) -> str: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be more ideal if we could do proper authentication with only a token (i.e., fetch a user based on a valid token, which is typically what tokens are able to do.)
This could be done by means of adding a tokens table, and you could also possibly leverage Django's check_password
and make_password
functions for hashing.
@@ -77,6 +77,9 @@ class Meta: | |||
# Encrypted Phabricator API token. | |||
encrypted_phabricator_api_key = models.BinaryField(default=b"", blank=True) | |||
|
|||
# Encrypted API key. | |||
encrypted_lando_api_key = models.BinaryField(default=b"", blank=True) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned elsewhere, this should probably go in a separate table. That way we can automatically track creation time, and more easily revoke keys (which should be added as a field token.is_valid
or something like that.
Also, I think it's more secure to hash these tokens after showing them once to a user. Could leverage Django's django.contrib.auth.hashers
for this too.
Build out the basic functionality of the headless API for Lando.
Using django-ninja we define two API endpoints, to POST automation
jobs and GET job statuses after submission. The API endpoints take
a set of
actions
defined in the request body which are stored inthe database for processing by a worker. Authentication is handled by
an API key associated with a user profile. A single action,
add-commit
is implemented which can be used to test adding patches to the repo
as commits.