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__/
/dist/
/.idea/
/pillartool.egg-info/
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):
__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.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({})
"""

View File

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

View File

@ -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")
@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")

View File

@ -18,24 +18,109 @@ router = APIRouter(
)
# TODO: check comments in this file (they are written by AI)
@router.get("")
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
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}")
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
@router.post("/{fqdn}")
async def host_add(request: Request, fqdn: str, params: HostCreateParams):
db: Session = request.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):
"""
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:
parent_labels = split_and_validate_path(params.parent)
if parent_labels is None:
@ -43,6 +128,7 @@ async def host_add(request: Request, fqdn: str, params: HostCreateParams):
else:
parent_labels = []
# Traverse the parent hierarchy to ensure all components exist
parent_id = None
stmt_select_respecting_parent = select(Host).where(Host.name == bindparam("label") and Host.parent_id == bindparam("parent_id"))
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
# Create new host with unique ID and hierarchical structure
new_host = Host(
id=uuid.uuid4(),
name=fqdn,

View File

@ -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
parent: str | None

View File

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

View File

@ -1,7 +1,7 @@
pyproject.toml
pillar_tool/__init__.py
pillar_tool/main.py
pillar_tool/ptcli.py
pillar_tool/schemas.py
pillar_tool/db/__init__.py
pillar_tool/db/base_model.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_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_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/pillar_data.py
pillar_tool/db/models/top_data.py
pillar_tool/db/models/user.py
pillar_tool/db/queries/__init__.py
pillar_tool/db/queries/auth_queries.py
pillar_tool/db/queries/host_queries.py
pillar_tool/db/queries/pillar_queries.py
pillar_tool/frontend/__init__.py
pillar_tool/frontend/pillar_view.py
pillar_tool/middleware/__init__.py
pillar_tool/middleware/basicauth_backend.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/config.py
pillar_tool/util/validation.py
pillartool.egg-info/PKG-INFO
pillartool.egg-info/SOURCES.txt
pillartool.egg-info/dependency_links.txt

View File

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