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 click
import requests import requests
@ -47,10 +49,14 @@ def state_show(name: str):
@state.command("create") @state.command("create")
@click.argument("name") @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}'...") click.echo(f"Creating state '{name}'...")
try: 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 = requests.post(f'{base_url()}/state/{name}', headers=auth_header(), json=data.model_dump())
response.raise_for_status() response.raise_for_status()
@ -70,5 +76,25 @@ def state_delete(name: str):
response.raise_for_status() response.raise_for_status()
click.echo("State deleted successfully.") 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: 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

@ -51,3 +51,18 @@ def top_assign(host: str, state: str):
click.echo("Assigned state") click.echo("Assigned state")
except requests.exceptions.HTTPError as e: 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 import uuid
from sqlalchemy import select, insert, delete from sqlalchemy import select, insert, delete, bindparam, and_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.requests import Request from starlette.requests import Request
from fastapi import APIRouter from fastapi import APIRouter
from starlette.responses import JSONResponse 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 from pillar_tool.util.validation import validate_state_name
router = APIRouter( router = APIRouter(
@ -79,7 +81,7 @@ def state_get(req: Request, name: str):
@router.post("/{name}") @router.post("/{name}")
def state_create(req: Request, name: str): def state_create(req: Request, name: str, patch_params: StateParams):
""" """
Create a new state. Create a new state.
@ -110,6 +112,16 @@ def state_create(req: Request, name: str):
new_id = uuid.uuid4() new_id = uuid.uuid4()
db.execute(insert(State).values(id=new_id, name=name)) 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={ return JSONResponse(status_code=201, content={
'id': str(new_id), 'id': str(new_id),
'name': name 'name': name
@ -149,20 +161,38 @@ def state_delete(req: Request, name: str):
state: State = result[0][0] state: State = result[0][0]
# Check for assigned hosts before deleting # Check for assigned environments before deleting
assignments_stmt = select(StateAssignment).where( assignments_stmt = select(StateAssignment).where(
StateAssignment.state_id == state.id StateAssignment.state_id == state.id
) )
assignments = db.execute(assignments_stmt).fetchall() assignments = db.execute(assignments_stmt).fetchall()
if len(assignments) > 0: 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 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()] host_ids = [row[0] for row in db.execute(host_ids_stmt).fetchall()]
# Get host names (could optimize this) # Get host names (could optimize this)
from pillar_tool.db import Host
hosts_stmt = select(Host).where(Host.id.in_(host_ids)) hosts_stmt = select(Host).where(Host.id.in_(host_ids))
hosts: list[Host] = list(map(lambda x: x[0], db.execute(hosts_stmt).fetchall())) hosts: list[Host] = list(map(lambda x: x[0], db.execute(hosts_stmt).fetchall()))
@ -175,3 +205,38 @@ def state_delete(req: Request, name: str):
db.execute(delete(State).where(State.id == state.id)) db.execute(delete(State).where(State.id == state.id))
return JSONResponse(status_code=204, content={}) 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): def top_state_assign(req: Request, host_name: str, state_name: str):
db: Session = req.state.db db: Session = req.state.db
print(f"Assigning {state_name} to {host_name}")
# get the host in question # get the host in question
host_stmt = select(Host).where(Host.name == host_name) host_stmt = select(Host).where(Host.name == host_name)
host_res = db.execute(host_stmt).fetchall() 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"}) return JSONResponse(status_code=404, content={"error": f"Host '{host_name} not found"})
host: Host = host_res[0][0] host: Host = host_res[0][0]
print(f"Found host: {host.id}")
parent_stmt = select(Host).where(Host.id == bindparam("parent_id")) parent_stmt = select(Host).where(Host.id == bindparam("parent_id"))
parents: list[Host] = [] parents: list[Host] = []
@ -141,10 +138,8 @@ def top_state_assign(req: Request, host_name: str, state_name: str):
env_assign: EnvironmentAssignment | None = None env_assign: EnvironmentAssignment | None = None
for current_host in parents: for current_host in parents:
env_res = db.execute(env_assign_stmt, {'host_id': current_host.id}).fetchall() 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: if len(env_res) == 1:
env_assign: EnvironmentAssignment = env_res[0][0] env_assign: EnvironmentAssignment = env_res[0][0]
print(f"Found host with assigned environment: {current_host.name} with environment: {env_assign.environment_id}")
break break
env_stmt = select(Environment).where(Environment.id == env_assign.environment_id) 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: if len(env_res) != 1:
return JSONResponse(status_code=404, content={"error": f"Host '{host_name}' has no environment assigned"}) return JSONResponse(status_code=404, content={"error": f"Host '{host_name}' has no environment assigned"})
env: Environment = env_res[0][0] env: Environment = env_res[0][0]
print(f"Environment found: {env.name if env else 'None'}")
# get the state in question # get the state in question
state_stmt = (select(State).join(StateAssignment, State.id == StateAssignment.state_id) state_stmt = (select(State).join(StateAssignment, State.id == StateAssignment.state_id)
.where(and_(State.name == state_name, StateAssignment.environment_id == env.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() state_res = db.execute(state_stmt).fetchall()
print("Check 2")
if len(state_res) != 1: 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}'"}) return JSONResponse(status_code=404, content={"error": f"No state '{state_name}' found in environment '{env.name}'"})
state: State = state_res[0][0] state: State = state_res[0][0]
print("Check 4")
# insert the relation into the database # 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)) 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={}) 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 # State operations
class StateParams(BaseModel): class StateParams(BaseModel):
pass # No parameters needed for state operations currently addenv: list[str] | None
delenv: list[str] | None
# Pillar operations # Pillar operations
class PillarParams(BaseModel): class PillarParams(BaseModel):

View File

@ -11,5 +11,7 @@ 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: pygit2>=1.19.2
Requires-Dist: pyyaml>=6.0.3
Requires-Dist: requests>=2.32.5 Requires-Dist: requests>=2.32.5
Requires-Dist: sqlalchemy>=2.0.45 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_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_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_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/__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/top_data.py
@ -26,6 +31,8 @@ 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/host_queries.py
pillar_tool/db/queries/pillar_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/__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
@ -40,14 +47,18 @@ pillar_tool/ptcli/cli/hostgroup.py
pillar_tool/ptcli/cli/pillar.py pillar_tool/ptcli/cli/pillar.py
pillar_tool/ptcli/cli/query.py pillar_tool/ptcli/cli/query.py
pillar_tool/ptcli/cli/state.py pillar_tool/ptcli/cli/state.py
pillar_tool/ptcli/cli/top.py
pillar_tool/routers/__init__.py pillar_tool/routers/__init__.py
pillar_tool/routers/environment.py pillar_tool/routers/environment.py
pillar_tool/routers/host.py pillar_tool/routers/host.py
pillar_tool/routers/hostgroup.py pillar_tool/routers/hostgroup.py
pillar_tool/routers/pillar.py pillar_tool/routers/pillar.py
pillar_tool/routers/state.py pillar_tool/routers/state.py
pillar_tool/routers/top.py
pillar_tool/util/__init__.py pillar_tool/util/__init__.py
pillar_tool/util/config.py pillar_tool/util/config.py
pillar_tool/util/files.py
pillar_tool/util/pillar_utilities.py
pillar_tool/util/validation.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

View File

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