Skip to content
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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

cgsheeh
Copy link
Member

@cgsheeh cgsheeh commented Jan 13, 2025

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.

Copy link
Collaborator

@zzzeid zzzeid left a 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 the lando.main app. It could also go into a more specific headless_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.

@cgsheeh
Copy link
Member Author

cgsheeh commented Jan 13, 2025

Thanks for looking it over, the PR is still a draft but I wanted to get something up to show progress.

* the new api functionality would be better located in the `lando.api` app not the `lando.main` app. It could also go into a more specific `headless_api` app if we want to separate it from other functionality that was ported or will be ported from the old API.

I had originally put the new code in lando.api but I ran into some issues with creating new models and referencing the existing models from them. In particular Django didn't like that I was trying to set a foreign key reference to the Repo model across apps. In the interest of staying focused on the headless API I moved everything into lando.main, but I will look into this further.

* 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.

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.

* This PR should be broken up into multiple PRs (e.g., adding API functionality, adding "action" endpoints and worker functionality).

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.

shtrom
shtrom previously requested changes Jan 21, 2025
Copy link
Member

@shtrom shtrom left a 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"""
Copy link
Member

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).

Copy link
Member

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()
Copy link
Member

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?

Copy link
Member Author

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? :)

Copy link
Member

@shtrom shtrom Mar 6, 2025

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 ENUMs 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 CharFields, too, e.g.

# Current status of the job.
status = models.CharField(
max_length=32,
choices=LandingJobStatus,
default=None,
)
, maybe that would be enough.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
TODO write better docstring
TODO Write better docstring.

q:

)

def run_automation_job(self, job: AutomationJob) -> bool:
"""Run an automation job."""
Copy link
Member

@shtrom shtrom Jan 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""Run an automation job."""
"""Run an automation job.
Returns True if the job is in a permanent state and should not be retried.
"""

Comment on lines +192 to +100
# 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)
Copy link
Member

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.

Copy link
Member

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?

Copy link
Member

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

def update_repo(self, pull_path: str, target_cset: Optional[str] = None) -> str:

Comment on lines 126 to 131
patch_helper = HgPatchHelper(StringIO(action.content))

date = patch_helper.get_header("Date")
user = patch_helper.get_header("User")

try:
Copy link
Member

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...

Copy link
Member

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.

@shtrom shtrom dismissed their stale review January 21, 2025 07:01

Not request for change. Just comments.

Copy link
Collaborator

@zzzeid zzzeid left a 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:
Copy link
Collaborator

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.

# 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
Copy link
Collaborator

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.

Copy link
Member Author

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
Copy link
Collaborator

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?

Copy link
Member Author

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.
@cgsheeh cgsheeh marked this pull request as ready for review March 6, 2025 00:48
@cgsheeh cgsheeh requested review from shtrom and zzzeid March 6, 2025 00:48
commit: str


Action = Union[AddCommitAction, MergeOntoAction, AddBranchAction, TagAction]
Copy link
Member

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.

Comment on lines +192 to +100
# 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)
Copy link
Member

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

def update_repo(self, pull_path: str, target_cset: Optional[str] = None) -> str:

Comment on lines +41 to +43
user_agent = request.headers.get("User-Agent")
if not user_agent:
raise APIPermissionDenied("`User-Agent` header is required.")
Copy link
Member

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?

Comment on lines +81 to +86
action: Literal["add-commit"]
content: str

def process(
self, job: AutomationJob, repo: Repo, scm: AbstractSCM, index: int
) -> bool:
Copy link
Member

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.

Copy link
Member

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

@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
, so we can then just use the revision object to extract the patch details, like the LandingWorker now does
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.

Comment on lines +122 to +142
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
Copy link
Member

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?

Comment on lines +14 to +33
@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."}
Copy link
Member

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.

Comment on lines +328 to +341
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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


@pytest.mark.django_db
def test_automation_job_add_commit_success(
hg_server, hg_clone, hg_automation_worker, repo_mc, monkeypatch
Copy link
Member

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants