diff --git a/.vscode/settings.json b/.vscode/settings.json index cce7f4d..b7160ad 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,8 @@ ], "https://raw.githubusercontent.com/ansible-community/schemas/main/f/ansible-tasks.json": "tasks/deploy.yml" }, - "yaml.customTags": ["!vault scalar"] + "yaml.customTags": [ + "!vault scalar" + ], + "python.formatting.provider": "black" } diff --git a/api/Dockerfile b/api/Dockerfile index 33a2d75..bc72307 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -7,6 +7,8 @@ RUN adduser \ --gecos "" \ api-server +RUN mkdir -p /var/run/ansible + USER api-server WORKDIR /opt/api-server @@ -21,7 +23,10 @@ ENV API_PROJECT_DIR=/var/project \ API_RUNAS=api-server \ PATH="/home/api-server/.local/bin:${PATH}" + VOLUME [ "${API_PROJECT_DIR}" ] +VOLUME [ "/var/run/ansible" ] + WORKDIR ${API_PROJECT_DIR} # switch to root as it must be available diff --git a/api/api.py b/api/api.py index 911f1cc..1aad64c 100644 --- a/api/api.py +++ b/api/api.py @@ -1,14 +1,59 @@ import asyncio import uuid -from wsgiref.util import request_uri import ansible_runner import os +import sys from typing import Any, Dict -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, HTTPException, Request, Security, Depends from fastapi.responses import StreamingResponse +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel, Field from ansible_runner import Runner from dataclasses import dataclass +from yaml import safe_load + +from starlette.status import HTTP_403_FORBIDDEN + +bearer_auth = HTTPBearer() + + +class User(BaseModel): + token: str + allowed_services: list[str] + + +def load_users(filename) -> dict[str, User]: + try: + with open(filename, "r") as file: + data = safe_load(file) + + if not isinstance(data, list): + print(f"{filename} must be list of users", file=sys.stderr) + sys.exit(1) + + return {user["token"]: User(**user) for user in data} + except FileNotFoundError as e: + print( + f"File {filename} was not found, please make sure that you provide users file.", + file=sys.stderr, + ) + sys.exit(1) + except ValueError as e: + print(e, file=sys.stderr) + sys.exit(1) + + +users = load_users("/etc/api-server/users.yaml") + + +def get_user(bearer_auth: HTTPAuthorizationCredentials = Security(bearer_auth)) -> User: + if bearer_auth and bearer_auth.credentials in users: + return users[bearer_auth.credentials] + + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) class Link(BaseModel): @@ -29,6 +74,7 @@ class DeploymentLinks(BaseModel): @dataclass class Deployment: id: str + args: DeployArgs runner: Runner | None = None @@ -50,15 +96,42 @@ class DeploymentDTO(BaseModel): deployments: Dict[str, Deployment] = {} -app = FastAPI() - -@app.get("/deployment/{id}/logs") -async def deployment_logs(id: str): +def get_deployment(id: str) -> Deployment: if id not in deployments: raise HTTPException(status_code=404, detail="Deployment was not found") - runner = deployments.get(id).runner + return deployments[id] + + +app = FastAPI( + docs_url="/", + openapi_url="/swagger.json", + description="API for services management", + title="Server Management API", +) + + +@app.get("/deployment") +async def deployment_list(request: Request, user: User = Depends(get_user)): + return [ + DeploymentDTO.from_deployment(deployment, request) + for deployment in deployments.values() + if deployment.args.service in user.allowed_services + ] + + +@app.get("/deployment/{id}/logs") +async def deployment_logs( + deployment: Deployment = Depends(get_deployment), user: User = Depends(get_user) +): + if deployment.args.service not in user.allowed_services: + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail=f"This token does not allow to access {deployment.args.service} deployments.", + ) + + runner = deployment.runner async def stream(): stdout = runner.stdout @@ -77,34 +150,42 @@ async def deployment_logs(id: str): return StreamingResponse(stream(), media_type="text/plain") -@app.get("/deployment") -async def deployment_list(request: Request): - return [ - DeploymentDTO.from_deployment(deployment, request) - for deployment in deployments.values() - ] - - @app.get("/deployment/{id}") -async def deployment(id: str, request: Request): +async def deployment( + request: Request, + deployment: Deployment = Depends(get_deployment), + user: User = Depends(get_user), +): + if deployment.args.service not in user.allowed_services: + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail=f"This token does not allow to access {deployment.args.service} deployments.", + ) + return DeploymentDTO.from_deployment(deployments[id], request) @app.post("/deployment") -async def deploy(args: DeployArgs, request: Request): +async def deploy(args: DeployArgs, request: Request, user: User = Depends(get_user)): + if args.service not in user.allowed_services: + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail=f"This token does not allow to deploy {args.service} service.", + ) + ident = str(uuid.uuid4()) _, runner = ansible_runner.run_async( playbook="deploy.yaml", ident=ident, extravars={"services": [args.service], **args.vars}, - private_data_dir="/home/api-server", + private_data_dir="/var/run/ansible", project_dir=os.environ.get("API_PROJECT_DIR", "/var/project"), inventory=args.inventory or os.environ.get("API_INVENTORY"), settings={"suppress_ansible_output": True}, ) - deployment = Deployment(ident, runner) + deployment = Deployment(id=ident, runner=runner, args=args) deployments[ident] = deployment return DeploymentDTO.from_deployment(deployment, request) diff --git a/api/bin/docker-entrypoint.d/02-make-private-directory-writable.sh b/api/bin/docker-entrypoint.d/02-make-private-directory-writable.sh new file mode 100755 index 0000000..d4891dc --- /dev/null +++ b/api/bin/docker-entrypoint.d/02-make-private-directory-writable.sh @@ -0,0 +1 @@ +chown $API_RUNAS /var/run/ansible diff --git a/api/fixtures/users.yaml b/api/fixtures/users.yaml new file mode 100644 index 0000000..16ca41a --- /dev/null +++ b/api/fixtures/users.yaml @@ -0,0 +1,2 @@ +- token: test + allowed_services: ["wipe-stg"] diff --git a/docker-compose.yaml b/docker-compose.yaml index 2ccf353..0a18805 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,4 +12,5 @@ services: volumes: - .:/var/project - ./api:/opt/api-server + - ./api/fixtures/users.yaml:/etc/api-server/users.yaml:ro - ${SSH_AUTH_SOCK}:${SSH_AUTH_SOCK}