Compare commits

...

2 Commits

Author SHA1 Message Date
b1d308a977 refactor: ♻️ refacto and fix #5
All checks were successful
pilotwings / python (push) Successful in 1m45s
pilotwings / node (push) Successful in 38s
pilotwings / docker (push) Successful in 1m52s
2024-11-06 15:18:51 +01:00
62be326197 fix: 🐛 env var is not required and securize submit 2024-11-06 15:17:34 +01:00
5 changed files with 111 additions and 94 deletions

View File

@ -1,86 +1,24 @@
from os import getenv, path from os import path
from typing import Annotated from typing import Annotated
from docker import errors, from_env from docker import errors, from_env
from docker.models.containers import Container from docker.models.containers import Container
from dotenv import load_dotenv 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.responses import PlainTextResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBasicCredentials
from fastapi.staticfiles import StaticFiles from starlette import status
from pydantic import BaseModel
from starlette import status, types
from uvicorn import run from uvicorn import run
from .security import AuthStaticFiles, check_auth, security
from .types import ContainerRequest, SerializedContainer
from .utils import serialize_container
load_dotenv() load_dotenv()
client = from_env() 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)]) 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( def select_container(
container_name: str, credentials: Annotated[HTTPBasicCredentials, Depends(security)] container_name: str, credentials: Annotated[HTTPBasicCredentials, Depends(security)]
) -> Container: ) -> Container:
@ -107,8 +45,7 @@ def get_containers(
return [ return [
serialize_container(container) serialize_container(container)
for container in client.containers.list( for container in client.containers.list(
all=True, all=True, filters={"label": ["engine=pilotwings"]}
filters={"label": ["engine=pilotwings"]}
) )
] ]
@ -129,11 +66,6 @@ def get_container(
return serialize_container(select_container(container_name, credentials)) return serialize_container(select_container(container_name, credentials))
class ContainerRequest(BaseModel):
image: str
environment: list[str]
@app.post("/api/container/{container_name}") @app.post("/api/container/{container_name}")
def create_or_update_container( def create_or_update_container(
container_name: str, container_name: str,
@ -152,7 +84,7 @@ def create_or_update_container(
except HTTPException: except HTTPException:
pass pass
return serialize_container( container = serialize_container(
client.containers.run( client.containers.run(
request_body.image, request_body.image,
detach=True, 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") @app.post("/api/container/{container_name}/pull")
def pull_container( def pull_container(
@ -232,20 +168,7 @@ def delete_container(
container = select_container(container_name, credentials) container = select_container(container_name, credentials)
container.stop() container.stop()
container.remove(v=True, force=True) container.remove(v=True, force=True)
client.images.prune({"dangling": 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)
app.mount( app.mount(

49
backend/security.py Normal file
View File

@ -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)

19
backend/types.py Normal file
View File

@ -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

26
backend/utils.py Normal file
View File

@ -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),
)

View File

@ -43,7 +43,6 @@
v-model="environment" v-model="environment"
class="textarea" class="textarea"
placeholder="DURATION=60s" placeholder="DURATION=60s"
required
></textarea> ></textarea>
</div> </div>
</div> </div>
@ -75,7 +74,7 @@ export default {
return { return {
custom_name: null as string | null, custom_name: null as string | null,
image: null as string | null, image: null as string | null,
environment: null as string | null, environment: '',
error: '', error: '',
loading: false, loading: false,
} }
@ -107,6 +106,7 @@ export default {
}, },
methods: { methods: {
async submit() { async submit() {
if (!this.image || !(this.custom_name ?? this.container_name)) return
try { try {
this.loading = true this.loading = true
const { data } = await axios.post<Container>( const { data } = await axios.post<Container>(