-
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
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.
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
?
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.
We could use a ChoiceField
, but the tradeoff is that we would have to do a DB migration when we add a new action type. Perhaps not a big deal, I left it as-is for the moment. Is there any advantage to the ChoiceField
aside from being more explicit? :)
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's semantically clearer, and would help build forms... but we don't really need forms for this anyway, I don't think.
Depending on the implementation, I guess it may be using ENUM
s in the underlying DB, which could have a marginal space and lookup advantage, but I don't think that'd be a major selling point.
I see that we can add choices
to CharField
s, too, e.g.
lando/src/lando/headless_api/models/automation_job.py
Lines 24 to 29 in c0f6019
# Current status of the job. | |
status = models.CharField( | |
max_length=32, | |
choices=LandingJobStatus, | |
default=None, | |
) |
Curiosity: what is the cost of a migration? My initial thought is that adding a new action would require a code change and a deployment anyway, so the migration would tag along with it.
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 state and should not be retried. | |
""" |
# 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.
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.
Correction: now it creates a new branch in git. This may be a problem we want to come back to?
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.
FWIW, update_repo
accepts an optional target_cset
in case we want to provide a commit SHA
lando/src/lando/main/scm/git.py
Line 272 in ff0dbd3
def update_repo(self, pull_path: str, target_cset: Optional[str] = None) -> str: |
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.
src/lando/main/api.py
Outdated
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.
src/lando/main/api.py
Outdated
# 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
.
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 have yet to look into this but it is on my radar.
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?
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.
What do you mean by "this"?
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.
commit: str | ||
|
||
|
||
Action = Union[AddCommitAction, MergeOntoAction, AddBranchAction, TagAction] |
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.
We could make Action
a parent class that the others inherit from, with a process
@abstractmethod
.
This would also allow to make map_to_pydantic_action
more programatic, by looping over Action.__subclasses__()
and mapping klass.action -> klass
when building the dict. This means we won't need to update map_to_pydantic_action
if adding more actions.
# 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.
FWIW, update_repo
accepts an optional target_cset
in case we want to provide a commit SHA
lando/src/lando/main/scm/git.py
Line 272 in ff0dbd3
def update_repo(self, pull_path: str, target_cset: Optional[str] = None) -> str: |
user_agent = request.headers.get("User-Agent") | ||
if not user_agent: | ||
raise APIPermissionDenied("`User-Agent` header is required.") |
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.
Do we still need this?
action: Literal["add-commit"] | ||
content: str | ||
|
||
def process( | ||
self, job: AutomationJob, repo: Repo, scm: AbstractSCM, index: int | ||
) -> bool: |
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'm now thinking we could just use this in the normal Landing Worker.
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.
Rather than using the HgPatchHelper directly, here, I'd suggest using Revision.new_from_patch
lando/src/lando/main/models/revision.py
Lines 87 to 100 in ff0dbd3
@classmethod | |
def new_from_patch(cls, raw_diff: str, patch_data: dict[str, str]) -> Revision: | |
"""Construct a new Revision from patch data. | |
`patch_data` is expected to contain the following keys: | |
- author_name | |
- author_email | |
- commit_message | |
- timestamp (unix timestamp as a string) | |
""" | |
rev = Revision() | |
rev.set_patch(raw_diff, patch_data) | |
rev.save() | |
return rev |
revision
object to extract the patch details, like the LandingWorker
now does lando/src/lando/api/legacy/workers/landing_worker.py
Lines 256 to 261 in ff0dbd3
scm.apply_patch( | |
revision.diff, | |
revision.commit_message, | |
revision.author, | |
revision.timestamp, | |
) |
...
However, Revision.new_from_patch
does a save to the DB, which I don't think we want here... We may need to rejig that a little bit if we want to go down that path.
class MergeOntoAction(Schema): | ||
"""Merge the current branch into the target commit.""" | ||
|
||
action: Literal["merge-onto"] | ||
target: str | ||
message: str | ||
|
||
|
||
class TagAction(Schema): | ||
"""Create a new tag with the given name.""" | ||
|
||
action: Literal["tag"] | ||
name: str | ||
|
||
|
||
class AddBranchAction(Schema): | ||
"""Create a new branch at the given commit.""" | ||
|
||
action: Literal["add-branch"] | ||
name: str | ||
commit: 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 assume the process
implementation is yet to be added?
@pytest.mark.django_db | ||
def test_auth_missing_user_agent(client, headless_user): | ||
user, token = headless_user | ||
|
||
# Create a job and actions | ||
job = AutomationJob.objects.create(status=LandingJobStatus.SUBMITTED) | ||
AutomationAction.objects.create( | ||
job_id=job, action_type="add-commit", data={"content": "test"}, order=0 | ||
) | ||
|
||
# Fetch job status. | ||
response = client.get( | ||
f"/api/job/{job.id}", | ||
headers={ | ||
"Authorization": f"Bearer {token}", | ||
}, | ||
) | ||
|
||
assert response.status_code == 401, "Missing `User-Agent` should result in 401." | ||
assert response.json() == {"details": "`User-Agent` header is required."} |
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 don't think we want to enforce this anymore.
PATCH_NORMAL_1 = r""" | ||
# HG changeset patch | ||
# User Test User <[email protected]> | ||
# Date 0 0 | ||
# Thu Jan 01 00:00:00 1970 +0000 | ||
# Diff Start Line 7 | ||
add another file. | ||
diff --git a/test.txt b/test.txt | ||
--- a/test.txt | ||
+++ b/test.txt | ||
@@ -1,1 +1,2 @@ | ||
TEST | ||
+adding another line | ||
""".strip() |
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.
We can use the normal_patch
fixture https://github.com/mozilla-conduit/lando/blob/ff0dbd3c06af3830975e3f3e70f6949b9c964938/src/lando/api/tests/conftest.py#L131-L143to get this one out, rather than duplicating it.
|
||
@pytest.mark.django_db | ||
def test_automation_job_add_commit_success( | ||
hg_server, hg_clone, hg_automation_worker, repo_mc, monkeypatch |
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.
Might be worth adding test for git repos upfront, 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.