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

Upload assets #121

Merged
merged 3 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/app/api/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
)
from . import api_with_auth

# This file handles assets uploaded under /settings. For assets on frames, see frame.py.

@api_with_auth.get("/assets", response_model=list[AssetResponse])
async def list_assets(
path: Optional[str] = Query(None, description="Optional substring filter on the asset path"),
Expand Down
62 changes: 50 additions & 12 deletions backend/app/api/frames.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime, timedelta
import asyncssh
import io
import json
import os
Expand All @@ -8,7 +9,7 @@
from tempfile import NamedTemporaryFile

import httpx
from fastapi import Depends, Request, HTTPException
from fastapi import Depends, File, Form, Request, HTTPException, UploadFile
from fastapi.responses import Response, StreamingResponse
from sqlalchemy.orm import Session

Expand Down Expand Up @@ -238,8 +239,7 @@ async def api_frame_get_assets(id: int, db: Session = Depends(get_db), redis: Re
command = f"find {assets_path} -type f -exec stat --format='%s %Y %n' {{}} +"
output: list[str] = []
await exec_command(db, redis, frame, ssh, command, output, log_output=False)
await remove_ssh_connection(ssh)
await log(db, redis, id, "stdinfo", "SSH connection closed")
await remove_ssh_connection(db, redis, ssh, frame)

assets = []
for line in output:
Expand Down Expand Up @@ -290,7 +290,6 @@ async def api_frame_get_asset(
# 1) Generate an MD5 sum of the remote file
escaped_path = shlex.quote(normalized_path)
command = f"md5sum {escaped_path}"
await log(db, redis, frame.id, "stdinfo", f"> {command}")

# We'll read the MD5 from the command output
md5_output: list[str] = []
Expand Down Expand Up @@ -321,7 +320,6 @@ async def api_frame_get_asset(

# scp from remote -> local
# Note: (ssh, normalized_path) means "download from 'normalized_path' on the remote `ssh` connection"
import asyncssh
await asyncssh.scp(
(ssh, escaped_path),
local_temp_path,
Expand Down Expand Up @@ -352,16 +350,15 @@ async def api_frame_get_asset(
raise e

finally:
await remove_ssh_connection(ssh)
await log(db, redis, id, "stdinfo", "SSH connection closed")
await remove_ssh_connection(db, redis, ssh, frame)

except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))

@api_with_auth.post("/frames/{id:int}/assets/sync")
async def api_frame_get_assets_upload_fonts(id: int, db: Session = Depends(get_db), redis: Redis = Depends(get_redis)):
async def api_frame_assets_sync(id: int, db: Session = Depends(get_db), redis: Redis = Depends(get_redis)):
frame = db.get(Frame, id)
if frame is None:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Frame not found")
Expand All @@ -371,12 +368,54 @@ async def api_frame_get_assets_upload_fonts(id: int, db: Session = Depends(get_d
try:
await sync_assets(db, redis, frame, ssh)
finally:
await remove_ssh_connection(ssh)
await log(db, redis, id, "stdinfo", "SSH connection closed")
await remove_ssh_connection(db, redis, ssh, frame)
return {"message": "Assets synced successfully"}
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))

@api_with_auth.post("/frames/{id:int}/assets/upload")
async def api_frame_assets_upload(
id: int,
path: str = Form(..., description="Folder where to place this asset"),
file: UploadFile = File(...),
db: Session = Depends(get_db), redis: Redis = Depends(get_redis)
):
frame = db.get(Frame, id)
if frame is None:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Frame not found")
if not path:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Path parameter is required")
if "*" in path:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Invalid character * in path")
assets_path = frame.assets_path or "/srv/assets"
combined_path = os.path.normpath(os.path.join(assets_path, path, file.filename))
if not combined_path.startswith(os.path.normpath(assets_path) + '/'):
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail="Invalid asset path")

# TODO: stream and reuse connections
ssh = await get_ssh_connection(db, redis, frame)
try:
with NamedTemporaryFile(delete=True) as temp_file:
local_temp_path = temp_file.name
contents = await file.read()
with open(local_temp_path, "wb") as f:
f.write(contents)
await log(db, redis, id, "stdout", f"Uploading: {combined_path}")
scp_escaped_path = shlex.quote(combined_path)
await asyncssh.scp(
local_temp_path,
(ssh, scp_escaped_path),
recurse=False
)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))

finally:
await remove_ssh_connection(db, redis, ssh, frame)

path_without_combined = os.path.relpath(combined_path, assets_path)

return {"path": path_without_combined, "size": len(contents), "mtime": int(datetime.now().timestamp())}

@api_with_auth.post("/frames/{id:int}/clear_build_cache")
async def api_frame_clear_build_cache(id: int, redis: Redis = Depends(get_redis), db: Session = Depends(get_db)):
Expand All @@ -389,8 +428,7 @@ async def api_frame_clear_build_cache(id: int, redis: Redis = Depends(get_redis)
command = "rm -rf /srv/frameos/build/cache"
await exec_command(db, redis, frame, ssh, command)
finally:
await remove_ssh_connection(ssh)
await log(db, redis, id, "stdinfo", "SSH connection closed")
await remove_ssh_connection(db, redis, ssh, frame)
return {"message": "Build cache cleared successfully"}
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
Expand Down
2 changes: 1 addition & 1 deletion backend/app/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ async def read_index():

@app.exception_handler(StarletteHTTPException)
async def custom_404_handler(request: Request, exc: StarletteHTTPException):
if os.environ.get("TEST") == "1" or exc.status_code != 404:
if os.environ.get("TEST") == "1" or exc.status_code != 404 or request.url.path.startswith("/api"):
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail or f"Error {exc.status_code}"}
Expand Down
3 changes: 1 addition & 2 deletions backend/app/tasks/deploy_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,8 +321,7 @@ async def install_if_necessary(pkg: str, raise_on_error=True) -> int:
await update_frame(db, redis, frame)
finally:
if ssh is not None:
await remove_ssh_connection(ssh)
await log(db, redis, int(frame.id), "stdinfo", "SSH connection closed")
await remove_ssh_connection(db, redis, ssh, frame)


def find_nim_v2():
Expand Down
3 changes: 1 addition & 2 deletions backend/app/tasks/restart_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,4 @@ async def restart_frame_task(ctx: dict[str, Any], id: int):
await update_frame(db, redis, frame)
finally:
if ssh is not None:
await remove_ssh_connection(ssh)
await log(db, redis, id, "stdinfo", "SSH connection closed")
await remove_ssh_connection(db, redis, ssh, frame)
3 changes: 1 addition & 2 deletions backend/app/tasks/stop_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,4 @@ async def stop_frame_task(ctx: dict[str, Any], id: int):
finally:
if ssh is not None:
ssh.close()
await remove_ssh_connection(ssh)
await log(db, redis, id, "stdinfo", "SSH connection closed")
await remove_ssh_connection(db, redis, ssh, frame)
Loading
Loading