implemented more state and topfile logic

This commit is contained in:
Linus Vogel 2026-05-03 19:49:26 +02:00
parent 045049cfa4
commit 4b5b9cfe6a
9 changed files with 203 additions and 22 deletions

13
pillar_tool.service Normal file
View File

@ -0,0 +1,13 @@
[Unit]
Description=The PillarTool backend Service
Documentation=
After=network.target postgresql.service
[Service]
Type=simple
User=pillar_tool
Group=pillar_tool
EnvironmentFile=/usr/local/homelab/apps/PillarTool/.venv/bin/activate
WorkingDirectory=/usr/local/homelab/apps/PillarTool
ExecStart=/usr/bin/uv run --project . --module hypercorn pillar_tool.main:app --workers 2

View File

@ -1,3 +1,5 @@
from multiprocessing.connection import default_family
import click
import requests
@ -47,10 +49,14 @@ def state_show(name: str):
@state.command("create")
@click.argument("name")
def state_create(name: str):
@click.option("--addenv", nargs=1, default=None, required=False, multiple=True)
def state_create(name: str, addenv: list[str] | None):
click.echo(f"Creating state '{name}'...")
try:
data = StateParams()
data = StateParams(
addenv=addenv,
delenv=None
)
response = requests.post(f'{base_url()}/state/{name}', headers=auth_header(), json=data.model_dump())
response.raise_for_status()
@ -70,5 +76,25 @@ def state_delete(name: str):
response.raise_for_status()
click.echo("State deleted successfully.")
except requests.exceptions.HTTPError:
raise click.ClickException(f"Failed to delete state:\n{response.text}")
@state.command("update")
@click.argument("state")
@click.option("--addenv", nargs=1, default=None, required=False, multiple=True)
@click.option("--delenv", nargs=1, default=None, required=False, multiple=True)
def state_update(state: str, addenv: list[str], delenv: list[str]):
click.echo(f"Updating state '{state}'...")
try:
params = StateParams(
addenv=list(addenv),
delenv=list(delenv)
)
response = requests.patch(f'{base_url()}/state/{state}', headers=auth_header(), json=params.model_dump())
response.raise_for_status()
click.echo("State updated successfully.")
except requests.exceptions.HTTPError as e:
raise click.ClickException(f"Failed to delete state:\n{e}")
raise click.ClickException(f"Failed to update state:\n{e}")

View File

@ -50,4 +50,19 @@ def top_assign(host: str, state: str):
click.echo("Assigned state")
except requests.exceptions.HTTPError as e:
raise click.ClickException(f"Failed to assign state:\n{e}")
raise click.ClickException(f"Failed to assign state:\n{e}")
@top.command("unassign")
@click.argument("host")
@click.argument("state")
def top_assign(host: str, state: str):
click.echo("Assigning state to host...")
try:
response = requests.delete(f'{base_url()}/top/assign/{host}/{state}', headers=auth_header())
response.raise_for_status()
click.echo("Assigned state")
except requests.exceptions.HTTPError as e:
click.echo(f"Failed to assign state:\n{response.text}")

View File

@ -1,13 +1,15 @@
import uuid
from sqlalchemy import select, insert, delete
from sqlalchemy import select, insert, delete, bindparam, and_
from sqlalchemy.orm import Session
from starlette.exceptions import HTTPException
from starlette.requests import Request
from fastapi import APIRouter
from starlette.responses import JSONResponse
from pillar_tool.db.models.top_data import State, StateAssignment
from pillar_tool.db import TopFile, Host
from pillar_tool.db.models.top_data import State, StateAssignment, Environment
from pillar_tool.schemas import StateParams
from pillar_tool.util.validation import validate_state_name
router = APIRouter(
@ -79,7 +81,7 @@ def state_get(req: Request, name: str):
@router.post("/{name}")
def state_create(req: Request, name: str):
def state_create(req: Request, name: str, patch_params: StateParams):
"""
Create a new state.
@ -110,6 +112,16 @@ def state_create(req: Request, name: str):
new_id = uuid.uuid4()
db.execute(insert(State).values(id=new_id, name=name))
stmt_set_env = insert(StateAssignment).values(state_id=new_id, environment_id=bindparam('env_id'))
stmt_get_env_id = select(Environment).where(Environment.name == bindparam('env_name'))
for env in patch_params.addenv:
env_id_res = db.execute(stmt_get_env_id, {'env_name': env}).fetchall()
if len(env_id_res) < 1:
raise HTTPException(status_code=404, detail="No such environment exists")
env_id = env_id_res[0][0].id
db.execute(stmt_set_env, {'env_id': env_id})
return JSONResponse(status_code=201, content={
'id': str(new_id),
'name': name
@ -149,20 +161,38 @@ def state_delete(req: Request, name: str):
state: State = result[0][0]
# Check for assigned hosts before deleting
# Check for assigned environments before deleting
assignments_stmt = select(StateAssignment).where(
StateAssignment.state_id == state.id
)
assignments = db.execute(assignments_stmt).fetchall()
if len(assignments) > 0:
host_ids_stmt = select(StateAssignment.host_id).where(
env_ids_stmt = select(StateAssignment.environment_id).where(
StateAssignment.state_id == state.id
)
env_ids = [row[0] for row in db.execute(env_ids_stmt).fetchall()]
# Get host names (could optimize this)
envs_stmt = select(Environment).where(Environment.id.in_(env_ids))
envs: list[Environment] = list(map(lambda x: x[0], db.execute(envs_stmt).fetchall()))
return JSONResponse(status_code=409, content={
'message': "Cannot delete a state that still has environment assignments",
'assigned_envs': [e.name for e in envs]
})
# Check for assigned top files before deleting
top_stmt = select(TopFile).where(TopFile.state_id == state.id)
top = db.execute(top_stmt).fetchall()
if len(top) > 0:
host_ids_stmt = select(TopFile.host_id).where(
TopFile.state_id == state.id
)
host_ids = [row[0] for row in db.execute(host_ids_stmt).fetchall()]
# Get host names (could optimize this)
from pillar_tool.db import Host
hosts_stmt = select(Host).where(Host.id.in_(host_ids))
hosts: list[Host] = list(map(lambda x: x[0], db.execute(hosts_stmt).fetchall()))
@ -174,4 +204,39 @@ def state_delete(req: Request, name: str):
# Delete the state
db.execute(delete(State).where(State.id == state.id))
return JSONResponse(status_code=204, content={})
@router.patch("/{name}")
def state_patch(req: Request, name: str, patch_params: StateParams):
db: Session = req.state.db
stmt_state_id = select(State).where(State.name == name)
selected_state_res = db.execute(stmt_state_id).fetchall()
if len(selected_state_res) != 1:
raise HTTPException(status_code=404, detail="No such state exists")
state: State = selected_state_res[0][0]
# Statement for getting the
stmt_get_env_id = select(Environment).where(Environment.name == bindparam('env_name'))
# add any requested environments to the state in question
stmt_set_env = insert(StateAssignment).values(state_id=state.id, environment_id=bindparam('env_id'))
for env in patch_params.addenv:
env_id_res = db.execute(stmt_get_env_id, {'env_name': env}).fetchall()
if len(env_id_res) < 1:
raise HTTPException(status_code=404, detail="No such environment exists")
env_id = env_id_res[0][0].id
db.execute(stmt_set_env, {'env_id': env_id})
stmt_del_env = delete(StateAssignment).where(and_(StateAssignment.state_id == state.id, StateAssignment.environment_id == bindparam('env_id')))
for env in patch_params.delenv:
env_id_res = db.execute(stmt_get_env_id, {'env_name': env}).fetchall()
if len(env_id_res) < 1:
raise HTTPException(status_code=404, detail="No such environment exists")
env_id = env_id_res[0][0].id
db.execute(stmt_del_env, {'env_id': env_id})
return JSONResponse(status_code=204, content={})

View File

@ -111,8 +111,6 @@ def top_setenv(req: Request, host: str, environment: str):
def top_state_assign(req: Request, host_name: str, state_name: str):
db: Session = req.state.db
print(f"Assigning {state_name} to {host_name}")
# get the host in question
host_stmt = select(Host).where(Host.name == host_name)
host_res = db.execute(host_stmt).fetchall()
@ -120,7 +118,6 @@ def top_state_assign(req: Request, host_name: str, state_name: str):
return JSONResponse(status_code=404, content={"error": f"Host '{host_name} not found"})
host: Host = host_res[0][0]
print(f"Found host: {host.id}")
parent_stmt = select(Host).where(Host.id == bindparam("parent_id"))
parents: list[Host] = []
@ -141,10 +138,8 @@ def top_state_assign(req: Request, host_name: str, state_name: str):
env_assign: EnvironmentAssignment | None = None
for current_host in parents:
env_res = db.execute(env_assign_stmt, {'host_id': current_host.id}).fetchall()
print(f"Looking at host: {current_host.name}")
if len(env_res) == 1:
env_assign: EnvironmentAssignment = env_res[0][0]
print(f"Found host with assigned environment: {current_host.name} with environment: {env_assign.environment_id}")
break
env_stmt = select(Environment).where(Environment.id == env_assign.environment_id)
@ -152,23 +147,74 @@ def top_state_assign(req: Request, host_name: str, state_name: str):
if len(env_res) != 1:
return JSONResponse(status_code=404, content={"error": f"Host '{host_name}' has no environment assigned"})
env: Environment = env_res[0][0]
print(f"Environment found: {env.name if env else 'None'}")
# get the state in question
state_stmt = (select(State).join(StateAssignment, State.id == StateAssignment.state_id)
.where(and_(State.name == state_name, StateAssignment.environment_id == env.id))
)
print(f"Check 1: {state_stmt}")
state_res = db.execute(state_stmt).fetchall()
print("Check 2")
if len(state_res) != 1:
print(f"Check 3: State '{state_name}' not found in environment '{env.name}'")
return JSONResponse(status_code=404, content={"error": f"No state '{state_name}' found in environment '{env.name}'"})
state: State = state_res[0][0]
print("Check 4")
# insert the relation into the database
db.execute(insert(TopFile).on_conflict_do_nothing('pillar_tool_top_file_unique_state_host').values(state_id=state.id, host_id=host.id))
print("Check 5")
return JSONResponse(status_code=200, content={})
@router.delete("/assign/{host_name}/{state_name}")
def top_state_unassign(req: Request, host_name: str, state_name: str):
db: Session = req.state.db
# get the host in question
host_stmt = select(Host).where(Host.name == host_name)
host_res = db.execute(host_stmt).fetchall()
if len(host_res) != 1:
return JSONResponse(status_code=404, content={"error": f"Host '{host_name} not found"})
host: Host = host_res[0][0]
parent_stmt = select(Host).where(Host.id == bindparam("parent_id"))
parents: list[Host] = []
current: Host = host
while current is not None:
parents.append(current)
if current.parent_id is None:
current = None
else:
parent = db.execute(parent_stmt, {'parent_id': current.parent_id}).fetchall()
if len(parent) == 0:
return JSONResponse(status_code=500, content={"error": f"Host Hierarchy seems broken: parent_id '{current.parent_id}' does not exist"})
# Note: more than one result is impossible, since the id is a primary key
current: Host = parent[0][0]
# get the hosts environment
env_assign_stmt = select(EnvironmentAssignment).where(EnvironmentAssignment.host_id == bindparam("host_id"))
env_assign: EnvironmentAssignment | None = None
for current_host in parents:
env_res = db.execute(env_assign_stmt, {'host_id': current_host.id}).fetchall()
if len(env_res) == 1:
env_assign: EnvironmentAssignment = env_res[0][0]
break
env_stmt = select(Environment).where(Environment.id == env_assign.environment_id)
env_res = db.execute(env_stmt).fetchall()
if len(env_res) != 1:
return JSONResponse(status_code=404, content={"error": f"Host '{host_name}' has no environment assigned"})
env: Environment = env_res[0][0]
# get the state in question
state_stmt = (select(State).join(StateAssignment, State.id == StateAssignment.state_id)
.where(and_(State.name == state_name, StateAssignment.environment_id == env.id))
)
state_res = db.execute(state_stmt).fetchall()
if len(state_res) != 1:
return JSONResponse(status_code=404, content={"error": f"No state '{state_name}' found in environment '{env.name}'"})
state: State = state_res[0][0]
# delete the relation from the database
db.execute(delete(TopFile).where(and_(TopFile.state_id == state.id, TopFile.host_id == host.id)))
return JSONResponse(status_code=200, content={})

View File

@ -48,7 +48,8 @@ class HostgroupParams(BaseModel):
# State operations
class StateParams(BaseModel):
pass # No parameters needed for state operations currently
addenv: list[str] | None
delenv: list[str] | None
# Pillar operations
class PillarParams(BaseModel):

View File

@ -11,5 +11,7 @@ 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: pygit2>=1.19.2
Requires-Dist: pyyaml>=6.0.3
Requires-Dist: requests>=2.32.5
Requires-Dist: sqlalchemy>=2.0.45

View File

@ -18,6 +18,11 @@ pillar_tool/db/migrations/versions/2026_02_08_2034-7eb66922e256_pillars_are_dire
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/migrations/versions/2026_02_14_2335-a5848dcca950_fixed_errors_in_database_schema.py
pillar_tool/db/migrations/versions/2026_02_14_2337-58c2a8e7c302_import_top_data.py
pillar_tool/db/migrations/versions/2026_02_21_2338-ec7c818f92b5_renamed_bad_parameter.py
pillar_tool/db/migrations/versions/2026_04_25_0004-9ebc4cadee1c_separate_top_file_for_environments_and_.py
pillar_tool/db/migrations/versions/2026_04_25_1133-d3406fae0ecf_remove_id_from_env_assign.py
pillar_tool/db/models/__init__.py
pillar_tool/db/models/pillar_data.py
pillar_tool/db/models/top_data.py
@ -26,6 +31,8 @@ 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/git/__init__.py
pillar_tool/git/repository.py
pillar_tool/middleware/__init__.py
pillar_tool/middleware/basicauth_backend.py
pillar_tool/middleware/db_connection.py
@ -40,14 +47,18 @@ pillar_tool/ptcli/cli/hostgroup.py
pillar_tool/ptcli/cli/pillar.py
pillar_tool/ptcli/cli/query.py
pillar_tool/ptcli/cli/state.py
pillar_tool/ptcli/cli/top.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/routers/top.py
pillar_tool/util/__init__.py
pillar_tool/util/config.py
pillar_tool/util/files.py
pillar_tool/util/pillar_utilities.py
pillar_tool/util/validation.py
pillartool.egg-info/PKG-INFO
pillartool.egg-info/SOURCES.txt

View File

@ -6,5 +6,7 @@ jinja2>=3.1.6
psycopg2>=2.9.11
pycryptodome>=3.23.0
pydantic>=2.12.5
pygit2>=1.19.2
pyyaml>=6.0.3
requests>=2.32.5
sqlalchemy>=2.0.45