From 9216c95f10b4b3c11de196402e3e0d7c39d58917 Mon Sep 17 00:00:00 2001 From: Linus Vogel Date: Sat, 14 Feb 2026 15:48:32 +0100 Subject: [PATCH] implemented hostgroup api endpoints --- pillar_tool/ptcli/cli_main.py | 0 pillar_tool/ptcli/host.py | 0 pillar_tool/ptcli/hostgroup.py | 0 pillar_tool/ptcli/main.py | 44 ++++++++++- pillar_tool/routers/hostgroup.py | 123 ++++++++++++++++++++----------- pillar_tool/schemas.py | 15 +++- 6 files changed, 134 insertions(+), 48 deletions(-) create mode 100644 pillar_tool/ptcli/cli_main.py create mode 100644 pillar_tool/ptcli/host.py create mode 100644 pillar_tool/ptcli/hostgroup.py diff --git a/pillar_tool/ptcli/cli_main.py b/pillar_tool/ptcli/cli_main.py new file mode 100644 index 0000000..e69de29 diff --git a/pillar_tool/ptcli/host.py b/pillar_tool/ptcli/host.py new file mode 100644 index 0000000..e69de29 diff --git a/pillar_tool/ptcli/hostgroup.py b/pillar_tool/ptcli/hostgroup.py new file mode 100644 index 0000000..e69de29 diff --git a/pillar_tool/ptcli/main.py b/pillar_tool/ptcli/main.py index 09b82a3..1118317 100644 --- a/pillar_tool/ptcli/main.py +++ b/pillar_tool/ptcli/main.py @@ -3,8 +3,9 @@ import base64 import click import requests +from urllib.parse import quote -from pillar_tool.schemas import HostCreateParams +from pillar_tool.schemas import HostCreateParams, HostgroupParams from pillar_tool.util import config, load_config, Config from pillar_tool.util.validation import split_and_validate_path, validate_fqdn @@ -120,14 +121,51 @@ def hostgroup_list(): raise click.ClickException(f"Failed to list hostgroups:\n{e}") +@hostgroup.command("show") +@click.argument("path") +def hostgroup_show(path: str): + click.echo(f"Showing hostgroup '{path}'...") + try: + labels = split_and_validate_path(path) + name = labels[-1] + path = '/'.join(labels[:-1]) if len(labels) > 1 else None + data = HostgroupParams( + path=path + ) + response = requests.get(f'{base_url}/hostgroup/{name}', headers=auth_header, params=data.model_dump()) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + raise click.ClickException(f"Failed to show hostgroup:\n{e}") + + @hostgroup.command("create") @click.argument("path") def hostgroup_create(path: str): - click.echo("TODO: implement") + click.echo(f"Creating hostgroup '{path}'...") + try: + labels = split_and_validate_path(path) + path = "/".join(labels[:-1]) if len(labels) > 1 else '' + name = labels[-1] + data = HostgroupParams( + path=path + ) + response = requests.post(f'{base_url}/hostgroup/{name}', headers=auth_header, json=data.model_dump()) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + raise click.ClickException(f"Failed to create hostgroup:\n{e}") @hostgroup.command("delete") @click.argument("path") def hostgroup_delete(path: str): - click.echo("TODO: implement") + click.echo(f"Deleting hostgroup {path}...") + try: + labels = split_and_validate_path(path) + name = labels[-1] + prefix = "/".join(labels[:-1]) if len(labels) > 1 else None + query_params = f"?path={prefix}" if prefix is not None else '' + response = requests.delete(f'{base_url}/hostgroup/{name}{query_params}', headers=auth_header) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + raise click.ClickException(f"Failed to delete hostgroup:\n{e}") diff --git a/pillar_tool/routers/hostgroup.py b/pillar_tool/routers/hostgroup.py index abf5523..22b371c 100644 --- a/pillar_tool/routers/hostgroup.py +++ b/pillar_tool/routers/hostgroup.py @@ -1,14 +1,15 @@ -from http.client import HTTPResponse +import uuid -from sqlalchemy import select, insert, bindparam + +from sqlalchemy import select, insert, bindparam, delete from sqlalchemy.orm import Session from starlette.exceptions import HTTPException from starlette.requests import Request -from fastapi import APIRouter +from fastapi import APIRouter, Query, Depends from starlette.responses import JSONResponse from pillar_tool.db import Host -from pillar_tool.schemas import HostgroupCreateParams +from pillar_tool.schemas import HostgroupParams, get_hostgroup_params_from_query, get_model_from_query from pillar_tool.util.validation import split_and_validate_path router = APIRouter( @@ -33,13 +34,20 @@ def hostgroups_get(req: Request): result = db.execute(select(Host).where(Host.is_hostgroup == True)).fetchall() hosts: list[Host] = list(map(lambda x: x[0], result)) - return JSONResponse(status_code=200, content=list(map(lambda x: x.name, hosts))) + all_hostgroups = { x.id: x for x in hosts } + all_hostgroup_names = [] + for host in hosts: + ancestors = [host] + while ancestors[-1].parent_id is not None: + ancestors.append(all_hostgroups[ancestors[-1].parent_id]) + all_hostgroup_names.append('/'.join(map(lambda x: x.name, reversed(ancestors)))) + return JSONResponse(status_code=200, content=all_hostgroup_names) @router.get("/{name}") -def hostgroup_get(req: Request, name: str, path_input: str | None = None): +def hostgroup_get(req: Request, name: str, params: HostgroupParams = Depends(get_model_from_query(HostgroupParams))): """ - Retrieve a specific host group by name. + Retrieve a specific host group by name with additional details Fetches and returns details of the specified host group. Returns 404 if no such host group exists. @@ -47,7 +55,7 @@ def hostgroup_get(req: Request, name: str, path_input: str | None = None): Args: req (Request): The incoming request object. name (str): The name of the host group to retrieve. - path_input (str): the path of the group if desired + params (HostgroupCreateParams): the path of the group if desired Returns: JSONResponse: A JSON response with status code 200 and the host group details on success, @@ -58,21 +66,20 @@ def hostgroup_get(req: Request, name: str, path_input: str | None = None): # decode the path last = None ancestors = [] - if path_input is not None: - path = split_and_validate_path(path_input) + path = split_and_validate_path(params.path) if params.path else [] - # get the path from the db - path_stmt = select(Host).where(Host.name == bindparam('name') and Host.parent_id == bindparam('parent_id')) - for label in path: - result = db.execute(path_stmt, {'name': label, 'parent_id': last}).fetchall() + # get the path from the db + path_stmt = select(Host).where(Host.name == bindparam('name') and Host.parent_id == bindparam('parent_id')) + for label in path: + result = db.execute(path_stmt, {'name': label, 'parent_id': last}).fetchall() - # error 404 if there is no matching item - if len(result) != 1: - raise HTTPException(status_code=404, detail="No such hostgroup path exists") + # error 404 if there is no matching item + if len(result) != 1: + raise HTTPException(status_code=404, detail="No such hostgroup path exists") - tmp: Host = result[0][0] - ancestors.append(tmp) - last = tmp.id + tmp: Host = result[0][0] + ancestors.append(tmp) + last = tmp.id # get the host in question stmt = select(Host).where(Host.name == name and Host.is_hostgroup == True and Host.parent_id == last) @@ -83,7 +90,6 @@ def hostgroup_get(req: Request, name: str, path_input: str | None = None): # Note: this should be enforced by the database assert len(result) == 1 - print("check 1") hg: Host = result[0][0] @@ -94,7 +100,7 @@ def hostgroup_get(req: Request, name: str, path_input: str | None = None): @router.post("/{name}") -def hostgroup_create(req: Request, name: str, params: HostgroupCreateParams): +def hostgroup_create(req: Request, name: str, params: HostgroupParams): """ Create a new host group. @@ -103,37 +109,40 @@ def hostgroup_create(req: Request, name: str, params: HostgroupCreateParams): Args: req (Request): The incoming request object. name (str): The name of the host group (used as identifier). - params (HostgroupCreateParams): The creation parameters (e.g., description, associated hosts). + params (HostgroupCreateParams): Additional Parameters Returns: JSONResponse: A JSON response with status code 201 on success, or appropriate error codes (e.g., 409 if already exists). """ - pass + db = req.state.db + path = params.path + labels = split_and_validate_path(path) if path is not None else [] + labels += [ name ] + stmt = select(Host).where(Host.name == bindparam('name') and Host.is_hostgroup == True and Host.parent_id == bindparam('last')) + last = None + for label in labels: + result = db.execute(stmt, {'name': label, 'last': last}).fetchall() -@router.patch("/{name}") -def hostgroup_update(req: Request, name: str, params: HostgroupCreateParams): - """ - Update an existing host group by name. + if len(result) == 1: + # simply step down through the hierarchy + host = result[0][0] + last = host.id + elif len(result) == 0: + new_id = uuid.uuid4() + db.execute(insert(Host).values(id=new_id, name=label, is_hostgroup=True, parent_id=last)) + last = new_id + else: + # this should not be possible + assert False - Updates the specified host group with new parameters. - Returns 404 if no such host group exists. - - Args: - req (Request): The incoming request object. - name (str): The current name of the host group to update. - params (HostgroupCreateParams): The updated parameters. - - Returns: - JSONResponse: A JSON response with status code 200 on success, - or 404 if not found. - """ - pass + # TODO: return the newly created hostgroups + return JSONResponse(status_code=201, content={}) @router.delete("/{name}") -def hostgroup_delete(req: Request, name: str, params: HostgroupCreateParams): +def hostgroup_delete(req: Request, name: str, params: HostgroupParams = Depends(get_model_from_query(HostgroupParams))): """ Delete a host group by name. @@ -149,4 +158,32 @@ def hostgroup_delete(req: Request, name: str, params: HostgroupCreateParams): JSONResponse: A JSON response with status code 204 on successful deletion, or 404 if not found. """ - pass + db = req.state.db + + labels = split_and_validate_path(params.path) or [] + labels.append(name) + last = None + + stmt_step = select(Host).where(Host.name == bindparam('name') and Host.parent_id == bindparam('last') and Host.is_hostgroup == True) + for label in labels: + result = db.execute(stmt_step, {'name': label, 'last': last}).fetchall() + + if len(result) == 0: + return JSONResponse(status_code=404, content={}) # TODO: truly define a error format + + # this should be enforced by the database + assert len(result) == 1 + + host: Host = result[0][0] + last = host.id + + children_stmt = select(Host).where(Host.parent_id == last) + children: list[Host] = list(map(lambda x: x[0], db.execute(children_stmt).fetchall())) + if len(children) != 0: + return JSONResponse(status_code=400, content={ + 'message': "Cannot delete a hostgroup that still has children", + 'children': [ '/'.join(labels + [x.name]) for x in children ] + }) + + db.execute(delete(Host).where(Host.id == last)) + return JSONResponse(status_code=204, content={}) diff --git a/pillar_tool/schemas.py b/pillar_tool/schemas.py index a3b115e..27752dc 100644 --- a/pillar_tool/schemas.py +++ b/pillar_tool/schemas.py @@ -1,4 +1,7 @@ +from typing import Type, Callable + from pydantic import BaseModel +from starlette.requests import Request from starlette.responses import JSONResponse @@ -40,6 +43,14 @@ class HostCreateParams(BaseModel): parent: str | None # Hostgroup operations -class HostgroupCreateParams(BaseModel): - parent: str | None +class HostgroupParams(BaseModel): + path: str | None + +def get_hostgroup_params_from_query(req: Request): + return HostgroupParams(**dict(req.query_params)) + +def get_model_from_query[T](model: T) -> Callable[[Request], T]: + def aux(req: Request) -> T: + return model.model_validate(dict(req.query_params or {})) + return aux