Compare commits

...

3 Commits

Author SHA1 Message Date
6fc2ac7969 added some doc comments 2026-02-11 23:20:10 +01:00
7f5e63e397 current version seems to work 2026-02-11 22:28:09 +01:00
7553962f2b Added environments to the database 2026-02-10 22:03:29 +01:00
11 changed files with 304 additions and 29 deletions

1
.gitignore vendored
View File

@ -3,4 +3,5 @@ __build__/
__pycache__/ __pycache__/
/dist/ /dist/
/.idea/ /.idea/
/pillartool.egg-info/
pillar_tool.toml pillar_tool.toml

View File

@ -0,0 +1,32 @@
"""Added environment assignments to database
Revision ID: dd573f631ee4
Revises: e33744090598
Create Date: 2026-02-10 21:47:33.493901
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'dd573f631ee4'
down_revision: Union[str, Sequence[str], None] = 'e33744090598'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@ -15,6 +15,17 @@ class Environment(Base):
) )
class EnvironmentAssignment(Base):
__tablename__ = "pillar_tool_environment_assignment"
environment_id = Column(UUID, ForeignKey("pillar_tool_environment.id"), nullable=False)
host_id = Column(UUID, ForeignKey("pillar_tool_host.id"), nullable=False)
__table_args__ = (
UniqueConstraint('environment_id', 'host_id', name="pillar_tool_unique_environment_assignment")
)
class State(Base): class State(Base):
__tablename__ = "pillar_tool_state" __tablename__ = "pillar_tool_state"

View File

@ -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 import load_config, config
from pillar_tool.util.validation import validate_and_split_path_and_domain_name
load_config() 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.middleware.logging import request_logging_middleware
from pillar_tool.schemas import HostCreateParams
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from pillar_tool.db.database import get_connection 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 fastapi import FastAPI
from starlette.middleware.authentication import AuthenticationMiddleware 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.middleware.db_connection import db_connection_middleware
from pillar_tool.db.database import run_db_migrations 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 any pending migrations
run_db_migrations() run_db_migrations()
@ -59,6 +54,7 @@ def on_db_error(request: Request, exc: Exception):
def on_general_error(request: Request, exc: Exception): def on_general_error(request: Request, exc: Exception):
print("wtf?") print("wtf?")
response = PlainTextResponse(str(exc), status_code=500) response = PlainTextResponse(str(exc), status_code=500)
return response return response
@ -68,17 +64,27 @@ app.add_middleware(BaseHTTPMiddleware, dispatch=db_connection_middleware)
app.add_middleware(BaseHTTPMiddleware, dispatch=request_logging_middleware) app.add_middleware(BaseHTTPMiddleware, dispatch=request_logging_middleware)
app.exception_handler(Exception)(on_general_error) app.exception_handler(Exception)(on_general_error)
# Setup the api router
app.include_router(host_router)
@app.get("/") @app.get("/")
async def root(): async def root():
return {"message": "Hello World"} return {"message": "Hello World"}
@app.get("/health") @app.get("/health")
async def health(): async def health():
# TODO: improve health check # Check database connection
return {"message": "Healthy"} 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}") @app.get("/pillar/{host}")
async def pillar_get(req: Request, host: str): async def pillar_get(req: Request, host: str):
print(req.headers) 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") @app.get("/hosts")
async def host_list(request: Request): async def host_list(request: Request):
all_hosts = list_all_hosts(request.state.db) 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") @app.get("/hostgroups")
async def hostgroup_list(request: Request): async def hostgroup_list(request: Request):
all_hosts = list_all_hosts(request.state.db) 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}") @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 # TODO: implement
return JSONResponse({}) return JSONResponse({})
"""

View File

@ -0,0 +1 @@
from .main import main

View File

@ -3,9 +3,10 @@ import base64
import click import click
import requests 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 import config, load_config, Config
from pillar_tool.util.validation import split_and_validate_path, validate_fqdn
cfg: Config | None = None cfg: Config | None = None
base_url: str | None = None base_url: str | None = None
@ -39,6 +40,14 @@ def pillar():
def host(): def host():
pass pass
@main.group("hostgroup")
def hostgroup():
pass
@main.group("environment")
def environment():
pass
@main.group("query") @main.group("query")
def query(): def query():
pass pass
@ -55,7 +64,7 @@ def pillar_list():
def host_list(): def host_list():
click.echo("Listing known hosts...") click.echo("Listing known hosts...")
try: 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() response.raise_for_status()
print(response.json()) print(response.json())
@ -68,16 +77,48 @@ def host_list():
def host_create(fqdn: str, parent: str | None): def host_create(fqdn: str, parent: str | None):
click.echo("Creating host...") click.echo("Creating host...")
try: 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() response.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
raise click.ClickException(f"Failed to create host:\n{e}") raise click.ClickException(f"Failed to create host:\n{e}")
click.echo(f"Host '{fqdn}' created!") click.echo(f"Host '{fqdn}' created!")
@host.command("delete") @host.command("delete")
@click.argument("full_path") @click.argument("fqdn")
def host_delete(full_path: str): def host_delete(fqdn: str):
click.confirm(f"Are you sure you want to delete") 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")

View File

@ -18,24 +18,109 @@ router = APIRouter(
) )
# TODO: check comments in this file (they are written by AI)
@router.get("") @router.get("")
def hosts_get(req: Request): def hosts_get(req: Request):
"""
Retrieve a list of all hosts (excluding hostgroups) in the system.
Queries the database for all host entries where is_hostgroup is False,
returning only the names of the hosts in a flat list format.
Args:
req: FastAPI request object containing database session
Returns:
JSONResponse containing a list of host names as strings
"""
db: Session = req.state.db db: Session = req.state.db
result = db.execute(select(Host).where(Host.is_hostgroup == False)).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}") @router.get("/{fqdn}")
def host_get(req: Request, fqdn: str): def host_get(req: Request, fqdn: str):
"""
Retrieve detailed information about a specific host including its hierarchical path.
Fetches host details from the database and constructs the full path by traversing
parent relationships up to the root. Returns both the host name and its complete
hierarchical path as a slash-separated string.
Args:
req: FastAPI request object containing database session
fqdn: Fully qualified domain name of the host to retrieve
Returns:
JSONResponse containing host name and hierarchical path
Raises:
HTTPException: If FQDN format is invalid or host is not found
"""
db: Session = req.state.db db: Session = req.state.db
@router.post("/{fqdn}")
async def host_add(request: Request, fqdn: str, params: HostCreateParams):
db: Session = request.state.db
if not validate_fqdn(fqdn): if not validate_fqdn(fqdn):
raise HTTPException(status_code=400, detail="Provided host is not an 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):
"""
Create a new host with optional parent hierarchy.
Validates FQDN format and parent path structure before creating the host.
If a parent is specified, ensures all parent components exist in the database.
Creates the host with a unique UUID and proper hierarchical relationships.
Args:
request: FastAPI request object containing database session
fqdn: Fully qualified domain name for the new host
params: Host creation parameters including optional parent path
Returns:
JSONResponse with creation details and host information
Raises:
HTTPException: If FQDN format is invalid or parent doesn't exist
"""
db: Session = request.state.db
# Validate that the provided FQDN is properly formatted
if not validate_fqdn(fqdn):
raise HTTPException(status_code=400, detail="Provided host is not an FQDN")
# Process parent path if provided
if params.parent is not None: if params.parent is not None:
parent_labels = split_and_validate_path(params.parent) parent_labels = split_and_validate_path(params.parent)
if parent_labels is None: if parent_labels is None:
@ -43,6 +128,7 @@ async def host_add(request: Request, fqdn: str, params: HostCreateParams):
else: else:
parent_labels = [] parent_labels = []
# Traverse the parent hierarchy to ensure all components exist
parent_id = None parent_id = None
stmt_select_respecting_parent = select(Host).where(Host.name == bindparam("label") and Host.parent_id == bindparam("parent_id")) stmt_select_respecting_parent = select(Host).where(Host.name == bindparam("label") and Host.parent_id == bindparam("parent_id"))
for label in parent_labels: for label in parent_labels:
@ -59,6 +145,7 @@ async def host_add(request: Request, fqdn: str, params: HostCreateParams):
parent_id = result[0][0].parent_id parent_id = result[0][0].parent_id
# Create new host with unique ID and hierarchical structure
new_host = Host( new_host = Host(
id=uuid.uuid4(), id=uuid.uuid4(),
name=fqdn, name=fqdn,

View File

@ -1,7 +1,45 @@
from pydantic import BaseModel 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): class HostCreateParams(BaseModel):
parent: str | None parent: str | None
# Hostgroup operations
class HostgroupCreateParams(BaseModel): class HostgroupCreateParams(BaseModel):
parent: str | None parent: str | None

View File

@ -11,4 +11,5 @@ Requires-Dist: jinja2>=3.1.6
Requires-Dist: psycopg2>=2.9.11 Requires-Dist: psycopg2>=2.9.11
Requires-Dist: pycryptodome>=3.23.0 Requires-Dist: pycryptodome>=3.23.0
Requires-Dist: pydantic>=2.12.5 Requires-Dist: pydantic>=2.12.5
Requires-Dist: requests>=2.32.5
Requires-Dist: sqlalchemy>=2.0.45 Requires-Dist: sqlalchemy>=2.0.45

View File

@ -1,7 +1,7 @@
pyproject.toml pyproject.toml
pillar_tool/__init__.py pillar_tool/__init__.py
pillar_tool/main.py pillar_tool/main.py
pillar_tool/ptcli.py pillar_tool/schemas.py
pillar_tool/db/__init__.py pillar_tool/db/__init__.py
pillar_tool/db/base_model.py pillar_tool/db/base_model.py
pillar_tool/db/database.py pillar_tool/db/database.py
@ -14,19 +14,35 @@ pillar_tool/db/migrations/versions/2025_12_27_1159-4cc7f4e295f1_added_unique_to_
pillar_tool/db/migrations/versions/2025_12_27_1958-678356102624_added_pillar_structure.py pillar_tool/db/migrations/versions/2025_12_27_1958-678356102624_added_pillar_structure.py
pillar_tool/db/migrations/versions/2025_12_30_1009-c6fe061ad732_better_uniqueness_contraints.py pillar_tool/db/migrations/versions/2025_12_30_1009-c6fe061ad732_better_uniqueness_contraints.py
pillar_tool/db/migrations/versions/2026_01_01_1503-54537e95fc4d_coupled_host_and_hostgroup_into_single_.py pillar_tool/db/migrations/versions/2026_01_01_1503-54537e95fc4d_coupled_host_and_hostgroup_into_single_.py
pillar_tool/db/migrations/versions/2026_02_08_2034-7eb66922e256_pillars_are_directly_assigned_to_the_.py
pillar_tool/db/migrations/versions/2026_02_08_2051-0a912926be8b_added_environments_and_states_to_the_db_.py
pillar_tool/db/migrations/versions/2026_02_08_2220-e33744090598_mark_hosts_as_hostgroups.py
pillar_tool/db/migrations/versions/2026_02_10_2147-dd573f631ee4_added_environment_assignments_to_.py
pillar_tool/db/models/__init__.py pillar_tool/db/models/__init__.py
pillar_tool/db/models/pillar_data.py pillar_tool/db/models/pillar_data.py
pillar_tool/db/models/top_data.py
pillar_tool/db/models/user.py pillar_tool/db/models/user.py
pillar_tool/db/queries/__init__.py pillar_tool/db/queries/__init__.py
pillar_tool/db/queries/auth_queries.py pillar_tool/db/queries/auth_queries.py
pillar_tool/db/queries/host_queries.py
pillar_tool/db/queries/pillar_queries.py pillar_tool/db/queries/pillar_queries.py
pillar_tool/frontend/__init__.py pillar_tool/frontend/__init__.py
pillar_tool/frontend/pillar_view.py pillar_tool/frontend/pillar_view.py
pillar_tool/middleware/__init__.py pillar_tool/middleware/__init__.py
pillar_tool/middleware/basicauth_backend.py pillar_tool/middleware/basicauth_backend.py
pillar_tool/middleware/db_connection.py pillar_tool/middleware/db_connection.py
pillar_tool/middleware/logging.py
pillar_tool/ptcli/__init__.py
pillar_tool/ptcli/main.py
pillar_tool/routers/__init__.py
pillar_tool/routers/environment.py
pillar_tool/routers/host.py
pillar_tool/routers/hostgroup.py
pillar_tool/routers/pillar.py
pillar_tool/routers/state.py
pillar_tool/util/__init__.py pillar_tool/util/__init__.py
pillar_tool/util/config.py pillar_tool/util/config.py
pillar_tool/util/validation.py
pillartool.egg-info/PKG-INFO pillartool.egg-info/PKG-INFO
pillartool.egg-info/SOURCES.txt pillartool.egg-info/SOURCES.txt
pillartool.egg-info/dependency_links.txt pillartool.egg-info/dependency_links.txt

View File

@ -6,4 +6,5 @@ jinja2>=3.1.6
psycopg2>=2.9.11 psycopg2>=2.9.11
pycryptodome>=3.23.0 pycryptodome>=3.23.0
pydantic>=2.12.5 pydantic>=2.12.5
requests>=2.32.5
sqlalchemy>=2.0.45 sqlalchemy>=2.0.45