275 lines
14 KiB
Python

import uuid
from sqlalchemy import select, delete, and_, bindparam
from sqlalchemy.dialects.postgresql import insert
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 import Host, Environment, EnvironmentAssignment
from pillar_tool.db.models.top_data import State, StateAssignment, TopFile
from pillar_tool.util.validation import validate_state_name
router = APIRouter(
prefix="/top",
tags=["top"],
)
@router.get("/query/{host}")
def top_get(req: Request, host: str):
db: Session = req.state.db
print("[DEBUG] top_get: querying top file assignments for host '{}'".format(host))
# build the hierarchy
host_stmt = select(Host).where(Host.name == host)
result = db.execute(host_stmt).fetchall()
if len(result) == 0:
print("[DEBUG] top_get: ERROR - Host '{}' not found in database".format(host))
return JSONResponse(status_code=404, content={"message": "Host '{}' not found".format(host)})
elif len(result) > 1:
print("[DEBUG] top_get: ERROR - More than one host found with name '{}'. This indicates a database integrity violation (duplicate host names). Expected unique constraint enforcement.".format(host))
return JSONResponse(status_code=500, content={"message": "More than one host found"})
else:
target_host: Host = result[0][0]
print("[DEBUG] top_get: resolved host '{}' with id={}".format(host, target_host.id))
parent_stmt = select(Host).where(Host.id == bindparam("parent_id"))
parents: list[Host] = []
current: Host | None = target_host
while current is not None and current.parent_id is not None:
parents.append(current)
result = db.execute(parent_stmt, {'parent_id': current.parent_id}).fetchall()
if len(result) == 0:
print("[DEBUG] top_get: WARNING - parent_id '{}' for host '{}' does not exist in database. Hierarchy traversal stopped.".format(current.parent_id, current.name))
current = None
elif len(result) > 1:
print("[DEBUG] top_get: ERROR - More than one parent found for host '{}'. This indicates a database integrity violation (multiple parents for single host).".format(current.name))
return JSONResponse(status_code=500, content={"message": "More than one parent host found"})
else:
current = result[0][0]
if current is not None:
parents.append(current)
print("[DEBUG] top_get: resolved parent hierarchy with {} hosts: {}".format(len(parents), [p.name for p in parents]))
env_stmt = (select(Environment)
.join(EnvironmentAssignment, EnvironmentAssignment.environment_id == Environment.id)
.where(EnvironmentAssignment.host_id == bindparam("host_id"))
)
env: Environment | None = None
for host in reversed(parents):
env_res = db.execute(env_stmt, {'host_id': host.id}).fetchall()
if len(env_res) == 1:
env: Environment | None = env_res[0][0]
if env is None:
print("[DEBUG] top_get: WARNING - No environment assigned to host '{}' or any of its ancestors in the hierarchy".format(host))
state_stmt = (select(State)
.join(TopFile, State.id == TopFile.state_id)
.join(StateAssignment, State.id == StateAssignment.state_id)
.where(and_(StateAssignment.environment_id == env.id, TopFile.host_id == bindparam("host_id"))) # type: ignore
)
assigned_states = [
[ row[0] for row in db.execute(state_stmt, {'host_id': host.id}).fetchall() ]
for host in parents
]
all_assigned_states = set(s for states in assigned_states for s in states)
env_name = env.name # type: ignore
print("[DEBUG] top_get: found {} assigned state(s) for host '{}' in environment '{}': {}".format(len(all_assigned_states), host, env.name, [s.name for s in all_assigned_states])) # type: ignore
return JSONResponse(status_code=200, content={
env.name: list(map(lambda state: state.name, all_assigned_states)), # type: ignore
})
@router.post("/setenv/{host}/{environment}")
def top_setenv(req: Request, host: str, environment: str):
db: Session = req.state.db
print("[DEBUG] top_setenv: assigning environment '{}' to host '{}'".format(environment, host))
# get the target host id
host_stmt = select(Host).where(Host.name == host)
host_res = db.execute(host_stmt).fetchall()
if len(host_res) == 0:
print("[DEBUG] top_setenv: ERROR - Host '{}' not found in database".format(host))
return JSONResponse(status_code=404, content={"error": "No host found"})
elif len(host_res) == 1:
host_res = host_res[0][0]
else:
print("[DEBUG] top_setenv: ERROR - Too many hosts found with name '{}'. This should be prevented by a unique constraint on the database.".format(host))
return JSONResponse(status_code=404, content={"error": "Too many hosts found??? This should not happen"})
# get the environment id
env_stmt = select(Environment).where(Environment.name == environment)
env_res = db.execute(env_stmt).fetchall()
if len(env_res) == 0:
print("[DEBUG] top_setenv: ERROR - Environment '{}' not found in database".format(environment))
return JSONResponse(status_code=404, content={"error": "No environment found"})
elif len(env_res) == 1:
env_res = env_res[0][0]
else:
print("[DEBUG] top_setenv: ERROR - Too many environments found with name '{}'. This should be prevented by a unique constraint on the database.".format(environment))
return JSONResponse(status_code=404, content={"error": "Too many environments found??? This should not happen"})
insert_stmt = insert(EnvironmentAssignment).values(environment_id=env_res.id, host_id=host_res.id)
result = db.execute(insert_stmt)
print("[DEBUG] top_setenv: successfully assigned environment '{}' (id={}) to host '{}' (id={})".format(environment, env_res.id, host, host_res.id))
return JSONResponse(status_code=200, content={})
@router.post("/assign/{host_name}/{state_name}")
def top_state_assign(req: Request, host_name: str, state_name: str):
db: Session = req.state.db
print("[DEBUG] top_state_assign: assigning state '{}' to host '{}'".format(state_name, host_name))
# get the host in question
path_labels = host_name.replace("%%2F", "%%2f").split("%%2f")
parent_id = None
for path in path_labels:
host_stmt = select(Host).where(and_(Host.name == path, Host.parent_id == parent_id))
host_res = db.execute(host_stmt).fetchall()
if len(host_res) != 1:
print("[DEBUG] top_state_assign: ERROR - Host '{}' not found at path level '{}'. Expected exactly one match but got {} results.".format(host_name, path, len(host_res)))
return JSONResponse(status_code=404, content={"error": f"Host '{host_name} not found"})
current: Host | None = host_res[0][0] # NOTE: this result cannot be none, so the next line is safe
parent_id = current.id # type: ignore
host: Host = current
print("[DEBUG] top_state_assign: resolved target host '{}' with id={}".format(host.name, host.id)) # type: ignore
parent_stmt = select(Host).where(Host.id == bindparam("parent_id"))
parents: list[Host] = []
current: Host | None = 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:
print("[DEBUG] top_state_assign: ERROR - Host Hierarchy seems broken: host '{}' has parent_id '{}' which does not exist in database. This indicates a foreign key constraint violation or orphaned record.".format(current.name, current.parent_id))
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 | None = parent[0][0]
print("[DEBUG] top_state_assign: resolved parent hierarchy with {} hosts".format(len(parents)))
# 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 | None = env_res[0][0]
break
if env_assign is None:
print("[DEBUG] top_state_assign: ERROR - Host '{}' has no environment assigned. A state assignment requires an environment context.".format(host_name))
return JSONResponse(status_code=404, content={"error": f"Host '{host_name}' has no environment assigned"})
env_stmt = select(Environment).where(Environment.id == env_assign.environment_id)
env_res = db.execute(env_stmt).fetchall()
if len(env_res) != 1:
print("[DEBUG] top_state_assign: ERROR - Environment id '{}' referenced by assignment does not exist in database. This indicates a foreign key constraint violation.".format(env_assign.environment_id))
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:
print("[DEBUG] top_state_assign: ERROR - No state '{}' found in environment '{}'. The state must exist and be assigned to the target environment.".format(state_name, 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]
# 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)) # type: ignore
print("[DEBUG] top_state_assign: successfully assigned state '{}' to host '{}' in environment '{}'".format(state_name, host_name, env.name))
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
print("[DEBUG] top_state_unassign: removing assignment of state '{}' from host '{}'".format(state_name, host_name))
# 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:
print("[DEBUG] top_state_unassign: ERROR - Host '{}' not found. Expected exactly one match but got {} results.".format(host_name, len(host_res)))
return JSONResponse(status_code=404, content={"error": f"Host '{host_name} not found"})
host: Host = host_res[0][0]
print("[DEBUG] top_state_unassign: resolved target host '{}' with id={}".format(host.name, host.id))
parent_stmt = select(Host).where(Host.id == bindparam("parent_id"))
parents: list[Host] = []
current: Host | None = 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:
print("[DEBUG] top_state_unassign: ERROR - Host Hierarchy seems broken: host '{}' has parent_id '{}' which does not exist in database. This indicates a foreign key constraint violation or orphaned record.".format(current.name, current.parent_id))
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 | None = parent[0][0]
print("[DEBUG] top_state_unassign: resolved parent hierarchy with {} hosts".format(len(parents)))
# 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 | None = env_res[0][0]
break
if env_assign is None:
print("[DEBUG] top_state_unassign: ERROR - Host '{}' has no environment assigned. Cannot unassign state without an environment context.".format(host_name))
return JSONResponse(status_code=404, content={"error": f"Host '{host_name}' has no environment assigned"})
env_stmt = select(Environment).where(Environment.id == env_assign.environment_id)
env_res = db.execute(env_stmt).fetchall()
if len(env_res) != 1:
print("[DEBUG] top_state_unassign: ERROR - Environment id '{}' referenced by assignment does not exist in database. This indicates a foreign key constraint violation.".format(env_assign.environment_id))
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:
print("[DEBUG] top_state_unassign: ERROR - No state '{}' found in environment '{}'. The state must exist and be assigned to the target environment.".format(state_name, 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]
# delete the relation from the database
db.execute(delete(TopFile).where(and_(TopFile.state_id == state.id, TopFile.host_id == host.id)))
print("[DEBUG] top_state_unassign: successfully removed assignment of state '{}' from host '{}' in environment '{}'".format(state_name, host_name, env.name))
return JSONResponse(status_code=200, content={})