Compare commits
2 Commits
9eaf451213
...
b1d308a977
Author | SHA1 | Date | |
---|---|---|---|
b1d308a977 | |||
62be326197 |
@ -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
49
backend/security.py
Normal 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
19
backend/types.py
Normal 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
26
backend/utils.py
Normal 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),
|
||||||
|
)
|
@ -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>(
|
||||||
|
Loading…
Reference in New Issue
Block a user