Skip to content

Commit

Permalink
feat: Add ability to delete uploaded recordings, switched off by default
Browse files Browse the repository at this point in the history
  • Loading branch information
KIRA009 committed Nov 11, 2024
1 parent f673cb8 commit 8e4bf17
Show file tree
Hide file tree
Showing 13 changed files with 265 additions and 57 deletions.
75 changes: 55 additions & 20 deletions admin/recording_uploader/deploy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Entrypoint to deploy the uploader to AWS Lambda."""

import os
import pathlib
import re
import subprocess

from loguru import logger
Expand All @@ -10,31 +12,64 @@
CURRENT_DIR = pathlib.Path(__file__).parent


def main(region_name: str = "us-east-1", guided: bool = True) -> None:
def main(region_name: str = "us-east-1", destroy: bool = False) -> None:
"""Deploy the uploader to AWS Lambda.
Args:
region_name (str): The AWS region to deploy the Lambda function to.
guided (bool): Whether to use the guided SAM deployment.
destroy (bool): Whether to delete the Lambda function.
"""
s3 = boto3.client(
"s3",
region_name=region_name,
endpoint_url=f"https://s3.{region_name}.amazonaws.com",
)
bucket = "openadapt"

s3.create_bucket(
ACL="private",
Bucket=bucket,
)

# deploy the code to AWS Lambda
commands = ["sam", "deploy"]
if guided:
commands.append("--guided")
subprocess.run(commands, cwd=CURRENT_DIR, check=True)
logger.info("Lambda function deployed successfully.")
# check if aws credentials are set
if os.getenv("AWS_ACCESS_KEY_ID") is None:
raise ValueError("AWS_ACCESS_KEY_ID is not set")
if os.getenv("AWS_SECRET_ACCESS_KEY") is None:
raise ValueError("AWS_SECRET_ACCESS_KEY is not set")
if destroy:
commands = ["sam", "delete", "--no-prompts"]
else:
s3 = boto3.client(
"s3",
region_name=region_name,
endpoint_url=f"https://s3.{region_name}.amazonaws.com",
)
bucket = "openadapt"

s3.create_bucket(
ACL="private",
Bucket=bucket,
)
commands = ["sam", "deploy", "--no-fail-on-empty-changeset"]
try:
std_kwargs = {}
if not destroy:
std_kwargs["stderr"] = subprocess.PIPE
std_kwargs["stdout"] = subprocess.PIPE
ret = subprocess.run(
commands, cwd=CURRENT_DIR, check=True, shell=True, **std_kwargs
)
if destroy:
logger.info("Lambda function deleted successfully.")
else:
stdout = ret.stdout.decode("utf-8") if ret.stdout else ""
# find the url, which is in the format https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/upload/
url_match = re.search(
r"https://([^\.]+)\.execute-api\.([^\.]+)\.amazonaws\.com/Prod/upload/",
stdout,
)
if url_match:
logger.info(
f"Lambda function deployed successfully. URL: {url_match.group(0)},"
" copy it to your config."
)
else:
logger.error("Lambda function deployed, but failed to find the URL")
print(stdout)
except subprocess.CalledProcessError as e:
if destroy:
logger.error("Failed to delete Lambda function")
else:
logger.error("Failed to deploy Lambda function")
raise e


if __name__ == "__main__":
Expand Down
3 changes: 2 additions & 1 deletion admin/recording_uploader/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ Resources:
Method: post
Policies:
- Statement:
- Sid: S3GetPutObjectPolicy
- Sid: S3GetPutDeleteObjectPolicy
Effect: Allow
Action:
- s3:PutObject
- s3:GetObject
- s3:DeleteObject
Resource: !Sub "arn:aws:s3:::openadapt/*"

Outputs:
Expand Down
63 changes: 50 additions & 13 deletions admin/recording_uploader/uploader/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ def lambda_handler(event: dict, context: Any) -> dict:
"""Main entry point for the lambda function."""
data = json.loads(event["body"])
lambda_function = data["lambda_function"]
if lambda_function == "get_presigned_url":
handler = handlers.get(lambda_function)
if not handler:
return {
"body": json.dumps(get_presigned_url(data)),
"statusCode": 200,
"statusCode": 400,
"body": json.dumps(
{"error": f"Unknown lambda function: {lambda_function}"}
),
}
return {
"statusCode": 400,
"body": json.dumps({"error": f"Unknown lambda function: {lambda_function}"}),
}
return handler(data)


def get_presigned_url(data: dict) -> dict:
Expand All @@ -47,22 +47,59 @@ def get_presigned_url(data: dict) -> dict:
{"error": "Missing 'key' or 'client_method' in request body."}
),
}
region_name = DEFAULT_REGION_NAME
bucket = DEFAULT_BUCKET
s3 = boto3.client(
"s3",
config=Config(signature_version="s3v4"),
region_name=region_name,
endpoint_url=f"https://s3.{region_name}.amazonaws.com",
region_name=DEFAULT_REGION_NAME,
endpoint_url=f"https://s3.{DEFAULT_REGION_NAME}.amazonaws.com",
)

presigned_url = s3.generate_presigned_url(
ClientMethod=client_method,
Params={
"Bucket": bucket,
"Bucket": DEFAULT_BUCKET,
"Key": key,
},
ExpiresIn=ONE_HOUR_IN_SECONDS,
)

return {"url": presigned_url}
return {
"statusCode": 200,
"body": json.dumps({"url": presigned_url}),
}


def delete_object(data: dict) -> dict:
"""Delete an object from the s3 bucket
Args:
data (dict): The data from the request.
Returns:
dict: A dictionary containing the deleted status
"""
try:
key = data["key"]
except Exception as e:
print(e)
return {
"statusCode": 400,
"body": json.dumps(
{"error": "Missing 'key' or 'client_method' in request body."}
),
}

s3 = boto3.client(
"s3",
config=Config(signature_version="s3v4"),
region_name=DEFAULT_REGION_NAME,
endpoint_url=f"https://s3.{DEFAULT_REGION_NAME}.amazonaws.com",
)
s3.delete_object(
Bucket=DEFAULT_BUCKET,
Key=key,
)
return {"statusCode": 200, "body": json.dumps({"message": "Deleted"})}


handlers = {"get_presigned_url": get_presigned_url, "delete_object": delete_object}
36 changes: 30 additions & 6 deletions openadapt/app/dashboard/api/recordings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import json

from fastapi import APIRouter, WebSocket
from starlette.responses import RedirectResponse
from starlette.responses import HTMLResponse, RedirectResponse, Response

from openadapt.config import config
from openadapt.custom_logger import logger
Expand All @@ -13,7 +13,12 @@
from openadapt.models import Recording
from openadapt.plotting import display_event
from openadapt.share import upload_recording_to_s3
from openadapt.utils import get_recording_url, image2utf8, row2dict
from openadapt.utils import (
delete_uploaded_recording,
get_recording_url,
image2utf8,
row2dict,
)


class RecordingsAPI:
Expand All @@ -33,10 +38,15 @@ def attach_routes(self) -> APIRouter:
self.app.add_api_route("/stop", self.stop_recording)
self.app.add_api_route("/status", self.recording_status)
self.app.add_api_route(
"/{recording_id}/upload", self.upload_recording, methods=["POST"]
"/cloud/{recording_id}/upload", self.upload_recording, methods=["POST"]
)
self.app.add_api_route(
"/{recording_id}/view", self.view_recording, methods=["GET"]
"/cloud/{recording_id}/view", self.view_recording_on_cloud, methods=["GET"]
)
self.app.add_api_route(
"/cloud/{recording_id}/delete",
self.delete_recording_on_cloud,
methods=["POST"],
)
self.recording_detail_route()
return self.app
Expand Down Expand Up @@ -80,15 +90,29 @@ def upload_recording(self, recording_id: int) -> dict[str, str]:
return {"message": "Recording uploaded"}

@staticmethod
def view_recording(recording_id: int) -> dict[str, str]:
"""View a recording."""
def view_recording_on_cloud(recording_id: int) -> Response:
"""View a recording from cloud."""
session = crud.get_new_session(read_only=True)
recording = crud.get_recording_by_id(session, recording_id)
if recording.upload_status == Recording.UploadStatus.NOT_UPLOADED:
return HTMLResponse(status_code=404)
url = get_recording_url(
recording.uploaded_key, recording.uploaded_to_custom_bucket
)
return RedirectResponse(url)

@staticmethod
def delete_recording_on_cloud(recording_id: int) -> dict[str, bool]:
"""Delete a recording from cloud"""
session = crud.get_new_session(read_only=True)
recording = crud.get_recording_by_id(session, recording_id)
if recording.upload_status == Recording.UploadStatus.NOT_UPLOADED:
return {"success": True}
delete_uploaded_recording(
recording_id, recording.uploaded_key, recording.uploaded_to_custom_bucket
)
return {"success": True}

def recording_detail_route(self) -> None:
"""Add the recording detail route as a websocket."""

Expand Down
5 changes: 4 additions & 1 deletion openadapt/app/dashboard/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ColorSchemeScript, MantineProvider } from '@mantine/core'
import { Notifications } from '@mantine/notifications'
import { Shell } from '@/components/Shell'
import { CSPostHogProvider } from './providers'
import { ModalsProvider } from '@mantine/modals'

export const metadata = {
title: 'OpenAdapt.AI',
Expand All @@ -23,7 +24,9 @@ export default function RootLayout({
<body>
<MantineProvider>
<Notifications />
<Shell>{children}</Shell>
<ModalsProvider>
<Shell>{children}</Shell>
</ModalsProvider>
</MantineProvider>
</body>
</CSPostHogProvider>
Expand Down
69 changes: 61 additions & 8 deletions openadapt/app/dashboard/app/recordings/RawRecordings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,25 @@ import { timeStampToDateString } from '../utils'
import { useRouter } from 'next/navigation'
import { Anchor, Button, Group, Text, Tooltip } from '@mantine/core'
import { IconInfoCircle } from '@tabler/icons-react'
import { modals } from '@mantine/modals'
import { getRecordingUploadSettings } from '../settings/utils'

export const RawRecordings = () => {
const [recordings, setRecordings] = useState<Recording[]>([])
const router = useRouter()
const [settings, setSettings] = useState<
Awaited<ReturnType<typeof getRecordingUploadSettings>>
>({
OVERWRITE_RECORDING_DESTINATION: false,
RECORDING_PUBLIC_KEY: '',
RECORDING_PRIVATE_KEY: '',
RECORDING_BUCKET_NAME: '',
RECORDING_BUCKET_REGION: '',
RECORDING_DELETION_ENABLED: false,
})
useEffect(() => {
getRecordingUploadSettings().then(setSettings)
}, [])

function fetchRecordings() {
fetch('/api/recordings').then((res) => {
Expand Down Expand Up @@ -36,14 +51,41 @@ export const RawRecordings = () => {
recording_id: number
) {
e.stopPropagation()
fetch(`/api/recordings/${recording_id}/upload`, {
fetch(`/api/recordings/cloud/${recording_id}/upload`, {
method: 'POST',
}).then((res) => {
if (res.ok) {
fetchRecordings()
}
})
}
function deleteUploadedRecording(
e: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
recording_id: number
) {
e.stopPropagation();
modals.openConfirmModal({
title: 'Confirm deletion',
children: (
<Text size="sm">
Are you sure you want to delete the recording from cloud?
</Text>
),
labels: { confirm: 'Delete', cancel: 'Cancel' },
confirmProps: {
color: 'red'
},
onConfirm: () => {
fetch(`/api/recordings/cloud/${recording_id}/delete`, {
method: 'POST',
}).then((res) => {
if (res.ok) {
fetchRecordings()
}
})
}
})
}

return (
<SimpleTable
Expand Down Expand Up @@ -87,13 +129,24 @@ export const RawRecordings = () => {
),
accessor: (recording: Recording) =>
recording.upload_status === UploadStatus.UPLOADED ? (
<Anchor
onClick={(e) => e.stopPropagation()}
href={`/api/recordings/${recording.id}/view`}
target="_blank"
>
View
</Anchor>
<Group>
<Anchor
onClick={(e) => e.stopPropagation()}
href={`/api/recordings/cloud/${recording.id}/view`}
target="_blank"
>
View
</Anchor>
{settings.RECORDING_DELETION_ENABLED && (
<Anchor
onClick={e => deleteUploadedRecording(e, recording.id)}
target="_blank"
c="red"
>
Delete
</Anchor>
)}
</Group>
) : UploadStatus.UPLOADING ===
recording.upload_status ? (
'Uploading...'
Expand Down
Loading

0 comments on commit 8e4bf17

Please sign in to comment.