diff --git a/backend/pilotwings.py b/backend/pilotwings.py index b9fa723..fa912f5 100644 --- a/backend/pilotwings.py +++ b/backend/pilotwings.py @@ -1,86 +1,24 @@ -from os import getenv, path +from os import path from typing import Annotated from docker import errors, from_env from docker.models.containers import Container from dotenv import load_dotenv -from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi import Depends, FastAPI, HTTPException from fastapi.responses import PlainTextResponse -from fastapi.security import HTTPBasic, HTTPBasicCredentials -from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel -from starlette import status, types +from fastapi.security import HTTPBasicCredentials +from starlette import status from uvicorn import run +from .security import AuthStaticFiles, check_auth, security +from .types import ContainerRequest, SerializedContainer +from .utils import serialize_container + load_dotenv() client = from_env() -security = HTTPBasic() - - -def http_401() -> HTTPException: - return HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect username or password", - headers={"WWW-Authenticate": "Basic"}, - ) - - -async def check_auth( - credentials: Annotated[HTTPBasicCredentials, Depends(security)], -) -> HTTPBasicCredentials: - usernames = getenv("USERNAMES", "").split(",") - passwords = getenv("PASSWORDS", "").split(",") - - if credentials.username not in usernames or credentials.password not in passwords: - raise http_401() - - user_index = usernames.index(credentials.username) - password = passwords[user_index] - - if credentials.password != password: - raise http_401() - - return credentials - - app = FastAPI(dependencies=[Depends(check_auth)]) -class SerializedContainer(BaseModel): - id: str - name: str | None - image: str | None - labels: dict[str, str] - status: str - health: str - engine: str | None - owner: str | None - environment: list[str] - logs: str | None - - -def container_logs(container: Container, tail: int) -> str | None: - try: - return container.logs(tail=tail).decode() - except errors.APIError: - return None - - -def serialize_container(container: Container) -> SerializedContainer: - return SerializedContainer( - id=container.short_id, - name=container.name, - image=container.image.tags[0] if container.image else None, - labels=container.labels, - status=container.status, - health=container.health, - engine=container.labels.get("engine"), - owner=container.labels.get("owner"), - environment=container.attrs["Config"]["Env"], - logs=container_logs(container, 100), - ) - - def select_container( container_name: str, credentials: Annotated[HTTPBasicCredentials, Depends(security)] ) -> Container: @@ -107,8 +45,7 @@ def get_containers( return [ serialize_container(container) for container in client.containers.list( - all=True, - filters={"label": ["engine=pilotwings"]} + all=True, filters={"label": ["engine=pilotwings"]} ) ] @@ -129,11 +66,6 @@ def get_container( return serialize_container(select_container(container_name, credentials)) -class ContainerRequest(BaseModel): - image: str - environment: list[str] - - @app.post("/api/container/{container_name}") def create_or_update_container( container_name: str, @@ -152,7 +84,7 @@ def create_or_update_container( except HTTPException: pass - return serialize_container( + container = serialize_container( client.containers.run( request_body.image, detach=True, @@ -164,6 +96,10 @@ def create_or_update_container( ) ) + client.images.prune({"dangling": True}) + + return container + @app.post("/api/container/{container_name}/pull") def pull_container( @@ -232,20 +168,7 @@ def delete_container( container = select_container(container_name, credentials) container.stop() container.remove(v=True, force=True) - - -class AuthStaticFiles(StaticFiles): - async def __call__( - self, scope: types.Scope, receive: types.Receive, send: types.Send - ) -> None: - request = Request(scope, receive) - credentials = await security(request) - - if not credentials: - raise http_401() - - await check_auth(credentials) - await super().__call__(scope, receive, send) + client.images.prune({"dangling": True}) app.mount( diff --git a/backend/security.py b/backend/security.py new file mode 100644 index 0000000..80ec107 --- /dev/null +++ b/backend/security.py @@ -0,0 +1,49 @@ +from os import getenv +from typing import Annotated + +from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from fastapi.staticfiles import StaticFiles +from starlette import status, types + +security = HTTPBasic() + + +def http_401() -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Basic"}, + ) + + +async def check_auth( + credentials: Annotated[HTTPBasicCredentials, Depends(security)], +) -> HTTPBasicCredentials: + usernames = getenv("USERNAMES", "").split(",") + passwords = getenv("PASSWORDS", "").split(",") + + if credentials.username not in usernames or credentials.password not in passwords: + raise http_401() + + user_index = usernames.index(credentials.username) + password = passwords[user_index] + + if credentials.password != password: + raise http_401() + + return credentials + + +class AuthStaticFiles(StaticFiles): + async def __call__( + self, scope: types.Scope, receive: types.Receive, send: types.Send + ) -> None: + request = Request(scope, receive) + credentials = await security(request) + + if not credentials: + raise http_401() + + await check_auth(credentials) + await super().__call__(scope, receive, send) diff --git a/backend/types.py b/backend/types.py new file mode 100644 index 0000000..fef890a --- /dev/null +++ b/backend/types.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + + +class ContainerRequest(BaseModel): + image: str + environment: list[str] + + +class SerializedContainer(BaseModel): + id: str + name: str | None + image: str | None + labels: dict[str, str] + status: str + health: str + engine: str | None + owner: str | None + environment: list[str] + logs: str | None diff --git a/backend/utils.py b/backend/utils.py new file mode 100644 index 0000000..5f6d7cc --- /dev/null +++ b/backend/utils.py @@ -0,0 +1,26 @@ +from docker import errors +from docker.models.containers import Container + +from .types import SerializedContainer + + +def container_logs(container: Container, tail: int) -> str | None: + try: + return container.logs(tail=tail).decode() + except errors.APIError: + return None + + +def serialize_container(container: Container) -> SerializedContainer: + return SerializedContainer( + id=container.short_id, + name=container.name, + image=container.image.tags[0] if container.image else None, + labels=container.labels, + status=container.status, + health=container.health, + engine=container.labels.get("engine"), + owner=container.labels.get("owner"), + environment=container.attrs["Config"]["Env"], + logs=container_logs(container, 100), + )