Skip to content

Commit b1633a9

Browse files
estebanx64github-actions
andauthored
✨ Add deployment presigned url service and logic (#239)
Co-authored-by: github-actions <[email protected]>
1 parent ca5c292 commit b1633a9

File tree

10 files changed

+633
-30
lines changed

10 files changed

+633
-30
lines changed

.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,6 @@ SENTRY_DSN="https://[email protected]
4141
# Configure these with your own Docker registry images
4242
DOCKER_IMAGE_BACKEND=backend
4343
DOCKER_IMAGE_FRONTEND=frontend
44+
45+
# AWS
46+
AWS_DEPLOYMENT_BUCKET=s3-deployment-customer-apps

backend/app/api/routes/deployments.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import uuid
22
from typing import Any, Literal
33

4-
from fastapi import APIRouter, HTTPException, UploadFile
4+
from fastapi import APIRouter, HTTPException
55
from sqlalchemy import func
66
from sqlmodel import col, select
77

88
from app.api.deps import CurrentUser, SessionDep
9+
from app.api.utils.aws_s3 import generate_presigned_url_post
10+
from app.core.config import settings
911
from app.crud import get_user_team_link_by_user_id_and_team_slug
1012
from app.models import (
1113
App,
1214
Deployment,
1315
DeploymentPublic,
1416
DeploymentsPublic,
1517
DeploymentStatus,
18+
DeploymentUploadOut,
1619
)
1720

1821
router = APIRouter()
@@ -94,12 +97,44 @@ def create_deployment(
9497
return new_deployment
9598

9699

97-
@router.post("/deployments/{deployment_id}/upload")
100+
@router.post("/deployments/{deployment_id}/upload", response_model=DeploymentUploadOut)
98101
def upload_deployment_artifact(
99-
deployment_id: uuid.UUID, # noqa F841
100-
upload_file: UploadFile,
102+
session: SessionDep,
103+
current_user: CurrentUser,
104+
deployment_id: uuid.UUID,
101105
) -> Any:
102106
"""
103107
Upload a new deployment artifact.
104108
"""
105-
return {"filename": upload_file.filename}
109+
deployment = session.exec(
110+
select(Deployment).where(Deployment.id == deployment_id)
111+
).first()
112+
if not deployment:
113+
raise HTTPException(status_code=404, detail="Deployment not found")
114+
115+
app = session.exec(select(App).where(App.id == deployment.app_id)).first()
116+
if not app:
117+
raise HTTPException(status_code=404, detail="App not found")
118+
119+
user_team_link = get_user_team_link_by_user_id_and_team_slug(
120+
session=session, user_id=current_user.id, team_slug=app.team.slug
121+
)
122+
if not user_team_link:
123+
raise HTTPException(
124+
status_code=404, detail="Team not found for the current user"
125+
)
126+
127+
object_name = f"{app.id}/{deployment.id}.tar"
128+
129+
presigned_url = generate_presigned_url_post(
130+
bucket_name=settings.AWS_DEPLOYMENT_BUCKET,
131+
object_name=object_name,
132+
)
133+
if not presigned_url:
134+
raise HTTPException(status_code=500, detail="Error generating presigned URL")
135+
136+
deployment_url = DeploymentUploadOut(
137+
url=presigned_url["url"], fields=presigned_url["fields"]
138+
)
139+
140+
return deployment_url

backend/app/api/utils/aws_s3.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from typing import Any
2+
3+
import boto3
4+
from botocore.exceptions import ClientError
5+
6+
7+
def generate_presigned_url_post(
8+
bucket_name: str, object_name: str, expiration: int = 600
9+
) -> dict[str, Any] | None:
10+
"""
11+
Generate a presigned POST URL to upload a file to an S3 bucket
12+
"""
13+
s3_client = boto3.client("s3")
14+
try:
15+
response = s3_client.generate_presigned_post(
16+
Bucket=bucket_name,
17+
Key=object_name,
18+
ExpiresIn=expiration,
19+
)
20+
except ClientError as e:
21+
print(f"Error generating presigned POST URL: {e}")
22+
return None
23+
24+
return response

backend/app/core/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,5 +138,7 @@ def _enforce_non_default_secrets(self) -> Self:
138138

139139
return self
140140

141+
AWS_DEPLOYMENT_BUCKET: str
142+
141143

142144
settings = Settings() # type: ignore

backend/app/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import uuid
22
from datetime import datetime
33
from enum import Enum
4+
from typing import Any
45

56
from pydantic import EmailStr, computed_field
67
from sqlalchemy.ext.hybrid import hybrid_property
@@ -319,3 +320,8 @@ class DeploymentPublic(SQLModel):
319320
class DeploymentsPublic(SQLModel):
320321
data: list[DeploymentPublic]
321322
count: int
323+
324+
325+
class DeploymentUploadOut(SQLModel):
326+
url: str
327+
fields: dict[str, Any]

backend/poetry.lock

Lines changed: 529 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ pydantic-settings = "^2.2.1"
2727
sentry-sdk = {extras = ["fastapi"], version = "^1.40.6"}
2828
pyjwt = "^2.8.0"
2929
redis = {extras = ["hiredis"], version = "^5.0.7"}
30+
boto3-stubs = {extras = ["s3"], version = "^1.35.10"}
31+
boto3 = "^1.35.10"
3032

3133
[tool.poetry.group.dev.dependencies]
3234
pytest = "^7.4.3"

frontend/src/client/models.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,6 @@ export type AuthorizeDeviceIn = {
3737

3838

3939

40-
export type Body_deployments_upload_deployment_artifact = {
41-
upload_file: Blob | File;
42-
};
43-
44-
45-
4640
export type Body_login_device_authorization = {
4741
client_id: string;
4842
};
@@ -84,6 +78,13 @@ export type DeploymentStatus = 'waiting_upload' | 'building' | 'deploying' | 'su
8478

8579

8680

81+
export type DeploymentUploadOut = {
82+
url: string;
83+
fields: Record<string, unknown>;
84+
};
85+
86+
87+
8788
export type DeploymentsPublic = {
8889
data: Array<DeploymentPublic>;
8990
count: number;

frontend/src/client/schemas.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,16 +90,6 @@ export const $AuthorizeDeviceIn = {
9090
},
9191
} as const;
9292

93-
export const $Body_deployments_upload_deployment_artifact = {
94-
properties: {
95-
upload_file: {
96-
type: 'binary',
97-
isRequired: true,
98-
format: 'binary',
99-
},
100-
},
101-
} as const;
102-
10393
export const $Body_login_device_authorization = {
10494
properties: {
10595
client_id: {
@@ -207,6 +197,23 @@ export const $DeploymentStatus = {
207197
enum: ['waiting_upload','building','deploying','success','failed',],
208198
} as const;
209199

200+
export const $DeploymentUploadOut = {
201+
properties: {
202+
url: {
203+
type: 'string',
204+
isRequired: true,
205+
},
206+
fields: {
207+
type: 'dictionary',
208+
contains: {
209+
properties: {
210+
},
211+
},
212+
isRequired: true,
213+
},
214+
},
215+
} as const;
216+
210217
export const $DeploymentsPublic = {
211218
properties: {
212219
data: {

frontend/src/client/services.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { CancelablePromise } from './core/CancelablePromise';
22
import { OpenAPI } from './core/OpenAPI';
33
import { request as __request } from './core/request';
44

5-
import type { AccessTokenWithUserMe,AuthorizeDeviceIn,Body_login_device_authorization,Body_login_login_access_token,Body_login_login_token,DeviceAuthorizationInfo,DeviceAuthorizationResponse,Message,NewPassword,Token,UserPublic,EmailVerificationToken,UpdatePassword,UserMePublic,UserRegister,UserUpdateEmailMe,UserUpdateMe,HealthCheckResponse,TeamCreate,TeamPublic,TeamsPublic,TeamUpdate,TeamUpdateMember,TeamWithUserPublic,UserTeamLinkPublic,InvitationCreate,InvitationPublic,InvitationsPublic,InvitationStatus,InvitationToken,AppCreate,AppPublic,AppsPublic,Body_deployments_upload_deployment_artifact,DeploymentPublic,DeploymentsPublic } from './models';
5+
import type { AccessTokenWithUserMe,AuthorizeDeviceIn,Body_login_device_authorization,Body_login_login_access_token,Body_login_login_token,DeviceAuthorizationInfo,DeviceAuthorizationResponse,Message,NewPassword,Token,UserPublic,EmailVerificationToken,UpdatePassword,UserMePublic,UserRegister,UserUpdateEmailMe,UserUpdateMe,HealthCheckResponse,TeamCreate,TeamPublic,TeamsPublic,TeamUpdate,TeamUpdateMember,TeamWithUserPublic,UserTeamLinkPublic,InvitationCreate,InvitationPublic,InvitationsPublic,InvitationStatus,InvitationToken,AppCreate,AppPublic,AppsPublic,DeploymentPublic,DeploymentsPublic,DeploymentUploadOut } from './models';
66

77
export type TDataLoginAccessToken = {
88
formData: Body_login_login_access_token
@@ -1007,7 +1007,6 @@ export type TDataCreateDeployment = {
10071007
}
10081008
export type TDataUploadDeploymentArtifact = {
10091009
deploymentId: string
1010-
formData: Body_deployments_upload_deployment_artifact
10111010

10121011
}
10131012

@@ -1067,22 +1066,19 @@ appId,
10671066
/**
10681067
* Upload Deployment Artifact
10691068
* Upload a new deployment artifact.
1070-
* @returns unknown Successful Response
1069+
* @returns DeploymentUploadOut Successful Response
10711070
* @throws ApiError
10721071
*/
1073-
public static uploadDeploymentArtifact(data: TDataUploadDeploymentArtifact): CancelablePromise<unknown> {
1072+
public static uploadDeploymentArtifact(data: TDataUploadDeploymentArtifact): CancelablePromise<DeploymentUploadOut> {
10741073
const {
10751074
deploymentId,
1076-
formData,
10771075
} = data;
10781076
return __request(OpenAPI, {
10791077
method: 'POST',
10801078
url: '/api/v1/deployments/{deployment_id}/upload',
10811079
path: {
10821080
deployment_id: deploymentId
10831081
},
1084-
formData: formData,
1085-
mediaType: 'multipart/form-data',
10861082
errors: {
10871083
422: `Validation Error`,
10881084
},

0 commit comments

Comments
 (0)