Direct artifacts upload - #13178
Conversation
humitos
left a comment
There was a problem hiding this comment.
This is looking good. This is a preliminary review since I just saw this PR while browsing other stuffs. I know it's in draft.
I'd like to suggest following the APIv3 structure we already have everywhere else and return full objects (or at least re-using the same structure we have) in the API. Example: instead of returning:
build: 12345
we should do
build:
pk: 12345
https://docs.readthedocs.com/platform/stable/api/v3.html#project-details
The same idea applies to urls and any other field we are returning in this new API.
I would also say that we should re-use UpdateDocsTask and short-circuit it to unzip the file only. That task already handles a lot of known and unknown exceptions. We should re-use that code instead of duplicating it.
| slug = serializers.SlugField( | ||
| required=False, | ||
| allow_blank=True, | ||
| help_text="Optional slug. If not provided, derived from the name.", | ||
| ) |
There was a problem hiding this comment.
We may need extra validation here. I think we have a specific validator on the Version serializer, but I haven't checked it.
There was a problem hiding this comment.
There is a validator in the model, but I ended up dropping this field from the initial implemenation. It requires deciding how to handle syncing of versions. If we automatically sync versions, the slug would already be created.
There was a problem hiding this comment.
So, I understand that there is no way to "create a new version" when uploading artifacts, right? The version has to exist already in Read the Docs (meaning that there should be a branch/tag/pr number already created in the repository). Am I correct here?
There was a problem hiding this comment.
No, versions can be created on the fly.
| url = serializers.URLField( | ||
| help_text="URL for uploading the build artifacts.", | ||
| ) | ||
| # NOTE: url_fields is used to avoid a conflict with the `fields` attribute of the serializer. | ||
| url_fields = serializers.DictField( | ||
| source="fields", | ||
| help_text="Additional fields required for the upload.", | ||
| ) |
There was a problem hiding this comment.
What about using urls field with nest all the required fields under it? Similar to what we are doing in other serializers: https://docs.readthedocs.com/platform/stable/api/v3.html#project-details -- urls.documentation, urls.home, etc...
There was a problem hiding this comment.
Feels like this is a different thing, the fields are part of the response, they are used together with the URL.
|
|
||
| build = serializers.DictField() | ||
| version = serializers.DictField() | ||
| upload_url = UploadURLSerializer() |
There was a problem hiding this comment.
Here would be better to use urls. Then we can have:
urls:
upload:
extra_fields: // data:
That way we avoid creating a new pattern here and re-use the existing one.
There was a problem hiding this comment.
they aren't individual URLs, it's just one with the fields for the post request.
| build = serializers.IntegerField( | ||
| help_text="Build ID returned from the initiate endpoint.", | ||
| ) | ||
| status = serializers.ChoiceField( | ||
| choices=["success", "failed"], | ||
| help_text="Status of the upload after completion.", | ||
| ) |
There was a problem hiding this comment.
Same here, I would follow the structure we have across the whole APIv3 and return a BuildSerializer instead here as we are doing everywhere else.
There was a problem hiding this comment.
This the input for finalizing the upload, this doesn't need a full build object.
There was a problem hiding this comment.
Yeah, we can return only the data that's required, but following the structure we already have for the build objects. That way we share the pattern and if we need to add more data here in the future it will looks like a full build object.
There was a problem hiding this comment.
Again, this is an input, not an output. The client is giving us the build id and the status of the upload, we aren't returting that.
There was a problem hiding this comment.
Gotcha. I thought this was a response.
We should name the field build_id instead of just build, then, to make it explicit.
There was a problem hiding this comment.
DRF defaults to using the name of the field for foreign keys when used as inputs for IDs (the only case we have in our code is for subprojects (child is the field for an slug)). If someone else feels this should be renamed, I'd be fine to do it.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as off-topic.
This comment was marked as off-topic.
|
This is ready for an initial review. Ignore the upload.sh file, that's only used for testing. I was thinking of having a small GH workflow in this repo to upload on every push/PR to test with our own docs (this would be a different RTD project). |
|
If you want to test this locally, you can get a RTD token from your user (settings > API tokens), and set the required env vars from the upload.sh script (RTD_TOKEN, RTD_PROJECT, RTD_OUTPUT_HTML). RTD_OUTPUT_HTML is where your docs are stored, you can put anything as long as it has an index.html file. |
humitos
left a comment
There was a problem hiding this comment.
I'm leaving a few important topics to continue the discussion here, since I think we are still not all in the same page here and we have different ideas in mind. I left more comments in the code itself, but at a high-level, these are the most important ones to me at this point.
API structure responses
My main feedback here is around the API response structure. I really want to follow the API response structure we currently have in APIv3 instead of diverge from there and create a new structure that it's only used for upload artifacts API endpoints.
I understand that there are places where the response should be just a few fields of the build and not the full object, but instead of returning {build: 3, status: cloning} (a complete new response structure); I think it's better follow the current APIv3 structure and return { id: 3 , state: { code: cloning, name: cloning } }. It reduces divergence between API endpoints and allows us to expand the fields returned in the future while keeping a known structure.
Versions allowed to upload
I'm confused about what versions can be uploaded and which ones can not. I understand that with this implementation, any user can upload any version, right? That seems dangerous to me -- the user can easily delete the content of a version by mistake and they cannot go back to the previous state (building it from Git) because we are blocking triggering a build on a version with is_uploaded=True.
I strongly think the user should be in explicit control of this. We should expose a field on the Edit version page the user can switch on/off to allow that particular version to support upload artifacts (#13134 (comment)). False by default.
Allow versions to be created or not
I understood from this thread that we don't want to allow the user to create random versions if they don't exist in Git. However, it seems this PR allows the user to create the version if the slug doesn't exist in the database.
Terminate "not uploaded" builds
We need a way (I think it's not implemented in this PR) to terminate/finish builds that were requested to be uploaded but never completed. We don't necessary need to implement this in this PR, in particular if we are changing the concurrency limit approach on ephemeral builds (see #13203); but something to keep in mind.
| slug = serializers.SlugField( | ||
| required=False, | ||
| allow_blank=True, | ||
| help_text="Optional slug. If not provided, derived from the name.", | ||
| ) |
There was a problem hiding this comment.
So, I understand that there is no way to "create a new version" when uploading artifacts, right? The version has to exist already in Read the Docs (meaning that there should be a branch/tag/pr number already created in the repository). Am I correct here?
| build = serializers.IntegerField( | ||
| help_text="Build ID returned from the initiate endpoint.", | ||
| ) | ||
| status = serializers.ChoiceField( | ||
| choices=["success", "failed"], | ||
| help_text="Status of the upload after completion.", | ||
| ) |
There was a problem hiding this comment.
Yeah, we can return only the data that's required, but following the structure we already have for the build objects. That way we share the pattern and if we need to add more data here in the future it will looks like a full build object.
| # If there are builds triggered/running for this particular project and version, | ||
| # we cancel all of them and trigger a new one for the latest commit received. | ||
| for running_build in running_builds: | ||
| cancel_build(running_build) | ||
|
|
There was a problem hiding this comment.
This method does a lot of stuffs we are already doing in prepare_build:
- check if the Project is active
- create a Build object
- call
send_build_status - check for concurrency limits
- check for running builds
- cancel the current running build if a new one for the same commit/version arrives
There is a bunch of code that could be shared here instead of duplicated. Ideally, I'd like to call prepare_build(upload=True) here and share as much code as possible.
| # Check build is in a valid state for processing. | ||
| if build.state != BUILD_STATE_TRIGGERED or build.task_id: | ||
| return Response( | ||
| {"detail": "Build is already in process."}, | ||
| status=status.HTTP_409_CONFLICT, | ||
| ) |
There was a problem hiding this comment.
I think I'm not following this. Isn't this logic inverted here?
- If there is a
build.task_idit means the build was already processed. - If the
build.stateis not TRIGGERED it means the build was already processed.
Shouldn't it be if build.state not in BUILD_FINAL_STATES instead?
There was a problem hiding this comment.
When an upload build is created, it is created in the triggered state, so it's pending to be uploaded. We can also just check for build.task_id, since that means that the build is already queued to post-process.
There was a problem hiding this comment.
This is also another case where builds from the upload API benefit from having more states.
There was a problem hiding this comment.
Gotcha! The /complete API endpoint has to be called before our build system start processing it. That means the build is TRIGGERED and it was not queued in Celery yet. That makes sense.
We should add a comment here that explains this clearly.
|
|
||
| Example: 1234/1111/artifacts.zip | ||
| """ | ||
| return f"{self.project.id}/{self.id}/artifacts.zip" |
There was a problem hiding this comment.
I just saw that we are uploading the file under the project_id as the first folder; but we are using project_slug in all the other buckets. Makes sense to keep it consistency and also it's a lot easier to find it while exploring it.
| return f"{self.project.id}/{self.id}/artifacts.zip" | |
| return f"{self.project.slug}/{self.id}/artifacts.zip" |
There was a problem hiding this comment.
I do prefer IDs, they are immutable. slugs can be changed. These are temporary files anyway, if you need to debug something getting the ID or slug is just one query away.
There was a problem hiding this comment.
What are the benefits of IDs over slugs here? All the other buckets use project slug, I don't see a reason to have this particular bucket naming it differently here. It only adds confusion.
There was a problem hiding this comment.
I do think we should have started with IDs instead of slugs, we now have the problem that if a slug is changed, the files associated with that slug are left there for another project with that slug to take (this isn't often that happens). I don't think we should carry on with that on new features.
There was a problem hiding this comment.
That's not wrong, but consistency is more important. We should use slugs like we do for the rest of our storage paths.
There was a problem hiding this comment.
I don't agree that consistency should be the reason to keep expanding on a wrong pattern.
* Create bucket for temporary build artifacts uploads Ref readthedocs/readthedocs.org#13178 * This option doesn't exist, it's private by default
…13234) Adds test coverage for `get_s3_build_uploads_scoped_credentials()` (introduced in #13178) in `readthedocs/aws/tests/test_security_token_service.py`, mirroring the existing tests for the build-media and build-tools scoped credentials: - Global (non-AWS/debug) credentials - Scoped credentials with the correct inline policy and session name - Custom `duration` override Stacked on #13178. --- _Generated by [Claude Code](https://claude.ai/code/session_013My5Hgj5E2kuMXyfrZPzJA)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
`prepare_build` (called from `trigger_build`) skips creating a `Build` and returns `(None, None)` when `version.is_uploaded` is `True` — this path didn't have test coverage yet. This adds a test mirroring the existing `test_trigger_skipped_project` case, asserting the build is skipped and `update_docs_task` is never invoked when the version is marked as uploaded. Built on top of #13178. --- *Generated by an AI agent.* --- _Generated by [Claude Code](https://claude.ai/code/session_015dDz3bSrPhVZNMwSJsnGx3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Adds test coverage for `RTDS3Storage.generate_presigned_post()` (introduced in #13178) in `readthedocs/storage/tests/test_s3_storage.py`. This follows the existing mock-`_bucket` pattern already used in that file for `delete_directory`/`delete_paths`, mocking `bucket.meta.client.generate_presigned_post` and asserting the `Bucket`, `Key`, `Fields`, `Conditions`, and `ExpiresIn` passed to boto3, both with default arguments and with custom `expires_in`/`min_size`/`max_size` overrides. Stacked on #13178. Note for reviewers: I couldn't run the full local test suite in this session — the checked-out base branch fails to import (`SyntaxError` from Python-2-style `except X, Y:` clauses in several unrelated files, e.g. `readthedocs/core/apps.py`, `readthedocs/integrations/models.py`). This looks like a pre-existing/environment issue unconnected to this change, so I verified the new tests directly against `RTDS3Storage.generate_presigned_post()` outside of the full Django app registry, and ran `ruff format`/`ruff check` on the changed file (both clean). --- _Generated by [Claude Code](https://claude.ai/code/session_01U7muj3LqvSL625pgLBAk4n)_ Co-authored-by: Claude <noreply@anthropic.com>
Adds test coverage for `UploadInitiateView` and `UploadCompleteView` (`readthedocs/upload/api/views.py`), introduced in #13178. This PR targets that branch as its base, since it depends on the `upload` app it adds. Tests focus on the upload API endpoints themselves — authentication, permission checks, the feature flag gate, pending-upload limits, get-or-create version behavior, cancellation of in-flight builds on a new upload, and the concurrency-delayed processing path on complete. Storage (`generate_presigned_post`/`exists`) and the Celery calls (`send_build_status`, `process_uploaded_build`) are mocked throughout, so nothing here exercises the actual build/processing pipeline. A couple of things worth double-checking on review: - `test_cancels_running_builds_for_same_version` asserts on `app.control.revoke` via patching `readthedocs.core.utils.app`, mirroring the pattern used in `readthedocs/builds/tests/test_views.py`. - `test_private_version_not_allowed` relies on `ALLOW_PRIVATE_REPOS=False` being the test-settings default; if that ever changes, the test's expectation should move with it. --- Generated by an AI agent. --- _Generated by [Claude Code](https://claude.ai/code/session_01EZuRwycA8Zk1hgaRNrLbWA)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
|
I re-checked this PR and there are things that are still missing from my point of view to leave it in a mergeable state:
Note: I've been testing this PR as backend together with new builder implementation and it worked great. We are pretty close to deploy this feature if we can move forward with the previous points 👍🏼 |
This is based on Santos' PR #13178 to send DAU builds to the new builder's queue. Required by readthedocs/readthedocs-builder#6
Initial implementation of #13134