diff --git a/pillar_tool/main.py b/pillar_tool/main.py index 2cabaad..d6aaeff 100644 --- a/pillar_tool/main.py +++ b/pillar_tool/main.py @@ -1,21 +1,14 @@ -# load config so everything else can work +from pillar_tool.schemas import HealthCheckError from pillar_tool.util import load_config, config -from pillar_tool.util.validation import validate_and_split_path_and_domain_name - load_config() -from http.server import BaseHTTPRequestHandler - -from pillar_tool.db.base_model import as_dict from pillar_tool.middleware.logging import request_logging_middleware -from pillar_tool.schemas import HostCreateParams from starlette.middleware.base import BaseHTTPMiddleware from pillar_tool.db.database import get_connection -from pillar_tool.db.queries.auth_queries import create_user -from pillar_tool.db.queries.host_queries import * +from pillar_tool.db.queries.auth_queries import create_user from fastapi import FastAPI from starlette.middleware.authentication import AuthenticationMiddleware @@ -27,6 +20,8 @@ from pillar_tool.middleware.basicauth_backend import BasicAuthBackend from pillar_tool.middleware.db_connection import db_connection_middleware from pillar_tool.db.database import run_db_migrations +# import all the routers +from pillar_tool.routers.host import router as host_router # run any pending migrations run_db_migrations() @@ -59,6 +54,7 @@ def on_db_error(request: Request, exc: Exception): def on_general_error(request: Request, exc: Exception): print("wtf?") + response = PlainTextResponse(str(exc), status_code=500) return response @@ -68,17 +64,27 @@ app.add_middleware(BaseHTTPMiddleware, dispatch=db_connection_middleware) app.add_middleware(BaseHTTPMiddleware, dispatch=request_logging_middleware) app.exception_handler(Exception)(on_general_error) +# Setup the api router +app.include_router(host_router) @app.get("/") async def root(): return {"message": "Hello World"} + @app.get("/health") async def health(): - # TODO: improve health check - return {"message": "Healthy"} + # Check database connection + try: + db = get_connection() + db.execute("SELECT 1") + db.close() + except Exception as e: + return HealthCheckError(500, f"Database connection error:\n{e}").response() + return HealthCheckSuccess().response() +""" @app.get("/pillar/{host}") async def pillar_get(req: Request, host: str): print(req.headers) @@ -96,19 +102,59 @@ async def pillar_set(request: Request, host: str, value: str): } }) + +# TODO: list, create update and delete hosts @app.get("/hosts") async def host_list(request: Request): all_hosts = list_all_hosts(request.state.db) - return JSONResponse([x.name for x in all_hosts if x.parent_id is None]) + return JSONResponse([x.name for x in all_hosts if not x.is_hostgroup]) + +# TODO: list, create, update and delete hostgroups @app.get("/hostgroups") async def hostgroup_list(request: Request): all_hosts = list_all_hosts(request.state.db) - return JSONResponse([x.name for x in all_hosts if x.parent_id is not None]) + return JSONResponse([x.name for x in all_hosts if x.is_hostgroup]) +# TODO: list, create, update and delete states +# TODO: list, create, update and delete environments + +# TODO: top files generated on a per host basis @app.get("/top/{fqdn}") -async def host_top(request: Request, fqdn: str): +async def host_top(req: Request, fqdn: str): + db: Session = req.state.db + + if not validate_fqdn(fqdn): + return JSONResponse(status_code=400, content={ + 'message': f"Invalid FQDN: {fqdn}" + }) + + environment_stmt = select(Environment) + result = db.execute(environment_stmt).fetchall() + + if len(result) == 0: + return JSONResponse(status_code=400, content={ + 'message': "There are no environments defined" + }) + + environments: list[Environment] = list(map(lambda x: x[0], result)) + + + stmt_host = select(Host).where(Host.name == fqdn) + result = db.execute(stmt_host).fetchall() + if len(result) < 1: + return JSONResponse(status_code=404, content={ + 'message': f"No such Host is known: {fqdn}" + }) + + # this should be enforced by the database + assert len(result) == 1 + + host: Host = result[0][0] + + stmt_top = select(Environment, Host, State).where(Environment).join # TODO: implement return JSONResponse({}) +""" \ No newline at end of file diff --git a/pillar_tool/ptcli.py b/pillar_tool/ptcli.py index f5daea8..edc3e1f 100644 --- a/pillar_tool/ptcli.py +++ b/pillar_tool/ptcli.py @@ -3,9 +3,10 @@ import base64 import click import requests -from click import Context +from pillar_tool.schemas import HostCreateParams from pillar_tool.util import config, load_config, Config +from pillar_tool.util.validation import split_and_validate_path, validate_fqdn cfg: Config | None = None base_url: str | None = None @@ -39,6 +40,14 @@ def pillar(): def host(): pass +@main.group("hostgroup") +def hostgroup(): + pass + +@main.group("environment") +def environment(): + pass + @main.group("query") def query(): pass @@ -55,7 +64,7 @@ def pillar_list(): def host_list(): click.echo("Listing known hosts...") try: - response = requests.get(f"{base_url}/hosts", headers=auth_header) + response = requests.get(f"{base_url}/host", headers=auth_header) response.raise_for_status() print(response.json()) @@ -68,16 +77,48 @@ def host_list(): def host_create(fqdn: str, parent: str | None): click.echo("Creating host...") try: - response = requests.post(f"{base_url}/host/{fqdn}", json={'parent': parent}, headers=auth_header) + params = HostCreateParams( + parent=parent + ) + response = requests.post(f"{base_url}/host/{fqdn}", json=params.model_dump(), headers=auth_header) response.raise_for_status() except requests.exceptions.HTTPError as e: raise click.ClickException(f"Failed to create host:\n{e}") - click.echo(f"Host '{fqdn}' created!") @host.command("delete") -@click.argument("full_path") -def host_delete(full_path: str): - click.confirm(f"Are you sure you want to delete") \ No newline at end of file +@click.argument("fqdn") +def host_delete(fqdn: str): + if not validate_fqdn(fqdn): + click.echo("Invalid FQDN") + return + + if click.confirm(f"Are you sure you want to delete '{fqdn}'?"): + click.echo("Deleting host...") + + try: + response = requests.delete(f'{base_url}/host/{fqdn}', headers=auth_header) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + raise click.ClickException(f"Failed to delete host:\n{e}") + + +@hostgroup.command("list") +def hostgroup_list(): + click.echo("Listing known hostgroups...") + click.echo("TODO: implement") + + +@hostgroup.command("create") +@click.argument("path") +def hostgroup_create(path: str): + click.echo("TODO: implement") + + +@hostgroup.command("delete") +@click.argument("path") +def hostgroup_delete(path: str): + click.echo("TODO: implement") + diff --git a/pillar_tool/routers/host.py b/pillar_tool/routers/host.py index ab66bf4..6dd31e9 100644 --- a/pillar_tool/routers/host.py +++ b/pillar_tool/routers/host.py @@ -23,11 +23,47 @@ router = APIRouter( def hosts_get(req: Request): db: Session = req.state.db + result = db.execute(select(Host)).fetchall() + hosts: list[Host] = list(map(lambda x: x[0], result)) + + return JSONResponse(status_code=200, content=list(map(lambda x: x.name, hosts))) + + @router.get("/{fqdn}") def host_get(req: Request, fqdn: str): db: Session = req.state.db + if not validate_fqdn(fqdn): + raise HTTPException(status_code=400, detail="Provided host is not an FQDN") + + host_stmt = select(Host).where(Host.name == fqdn) + result = db.execute(host_stmt).fetchall() + + if len(result) != 1: + raise HTTPException(status_code=404, detail=f"No such host found (length of result was {len(result)})") + + host: Host = result[0][0] + + last_parent = host + path = [] + parent_stmt = select(Host).where(Host.id == bindparam('parent_id')) + while host.parent_id is not None: + result = db.execute(parent_stmt, { 'parent_id': last_parent.parent_id }).fetchall() + + # Note: this assertion should be enforced by the database + assert len(result) == 1 + + parent = result[0][0] + path.append(parent) + last_parent = parent + + path.reverse() + return JSONResponse(status_code=200, content={ + "host": host.name, + "path": '/'.join(map(lambda x: x.name, path)) + }) + @router.post("/{fqdn}") async def host_add(request: Request, fqdn: str, params: HostCreateParams): diff --git a/pillar_tool/schemas.py b/pillar_tool/schemas.py index f441260..a3b115e 100644 --- a/pillar_tool/schemas.py +++ b/pillar_tool/schemas.py @@ -1,7 +1,45 @@ from pydantic import BaseModel +from starlette.responses import JSONResponse + +# Error returns +class ErrorDetails(BaseModel): + status: str + message: str + +class ErrorReturn(BaseModel): + status_code: int + content: ErrorDetails + + def response(self): + return JSONResponse(self.model_dump()) + +class HealthCheckError(ErrorReturn): + def __init__(self, code: int, reason: str): + super().__init__(**{ + 'status_code': code, + 'content': ErrorDetails( + status="error", + message=f"Host is not healthy: {reason}" + ) + }) + +class HealthCheckSuccess(ErrorReturn): + def __init__(self): + super().__init__(**{ + 'status_code': 200, + 'content': ErrorDetails( + status='ok', + message='API is healthy' + ) + }) + + +# Host operations class HostCreateParams(BaseModel): parent: str | None +# Hostgroup operations class HostgroupCreateParams(BaseModel): - parent: str | None \ No newline at end of file + parent: str | None +