added some logging

This commit is contained in:
Linus Vogel 2026-05-14 18:07:26 +02:00
parent e088bc47c0
commit a9937380b0
6 changed files with 204 additions and 8 deletions

View File

@ -37,9 +37,12 @@ def environments_get(req: Request):
""" """
db: Session = req.state.db db: Session = req.state.db
print("[DEBUG] environments_get: retrieving all environments")
result = db.execute(select(Environment)).fetchall() result = db.execute(select(Environment)).fetchall()
environments: list[Environment] = list(map(lambda x: x[0], result)) environments: list[Environment] = list(map(lambda x: x[0], result))
print("[DEBUG] environments_get: found {} environment(s) in database".format(len(environments)))
return JSONResponse(status_code=200, content=[env.name for env in environments]) return JSONResponse(status_code=200, content=[env.name for env in environments])
@ -61,24 +64,30 @@ def environment_get(req: Request, name: str):
""" """
db: Session = req.state.db db: Session = req.state.db
print("[DEBUG] environment_get: retrieving environment '{}'".format(name))
# Validate name before query # Validate name before query
if not validate_environment_name(name): if not validate_environment_name(name):
print("[DEBUG] environment_get: ERROR - Invalid environment name '{}'. Environment names must use only alphanumeric, underscore or dash characters.".format(name))
raise HTTPException(status_code=400, detail="Invalid environment name format") raise HTTPException(status_code=400, detail="Invalid environment name format")
stmt = select(Environment).where(Environment.name == name) stmt = select(Environment).where(Environment.name == name)
result = db.execute(stmt).fetchall() result = db.execute(stmt).fetchall()
if len(result) == 0: if len(result) == 0:
print("[DEBUG] environment_get: ERROR - No environment found with name '{}'".format(name))
raise HTTPException(status_code=404, detail="No such environment exists") raise HTTPException(status_code=404, detail="No such environment exists")
assert len(result) == 1 assert len(result) == 1
env: Environment = result[0][0] env: Environment = result[0][0]
print("[DEBUG] environment_get: resolved environment '{}' with id={}".format(env.name, env.id))
# Get assigned hosts count as an example of additional info # Get assigned hosts count as an example of additional info
hosts_stmt = select(Host).join(EnvironmentAssignment, Host.id == EnvironmentAssignment.host_id)\ hosts_stmt = select(Host).join(EnvironmentAssignment, Host.id == EnvironmentAssignment.host_id)\
.where(EnvironmentAssignment.environment_id == env.id) .where(EnvironmentAssignment.environment_id == env.id)
hosts_count = db.execute(hosts_stmt).fetchall().__len__() hosts_count = db.execute(hosts_stmt).fetchall().__len__()
print("[DEBUG] environment_get: environment '{}' has {} host(s) assigned".format(env.name, hosts_count))
return JSONResponse(status_code=200, content={ return JSONResponse(status_code=200, content={
'environment': env.name, 'environment': env.name,
@ -103,8 +112,11 @@ def environment_create(req: Request, name: str):
""" """
db = req.state.db db = req.state.db
print("[DEBUG] environment_create: creating new environment '{}'".format(name))
# Validate name format # Validate name format
if not validate_environment_name(name): if not validate_environment_name(name):
print("[DEBUG] environment_create: ERROR - Invalid environment name '{}'. Environment names must use only alphanumeric, underscore or dash characters.".format(name))
raise HTTPException(status_code=400, raise HTTPException(status_code=400,
detail="Invalid environment name. Use only alphanumeric, underscore or dash characters.") detail="Invalid environment name. Use only alphanumeric, underscore or dash characters.")
@ -113,10 +125,12 @@ def environment_create(req: Request, name: str):
existing = db.execute(stmt_check).fetchall() existing = db.execute(stmt_check).fetchall()
if len(existing) > 0: if len(existing) > 0:
print("[DEBUG] environment_create: ERROR - Environment '{}' already exists in database. Duplicate environment creation rejected.".format(name))
raise HTTPException(status_code=409, detail="Environment already exists") raise HTTPException(status_code=409, detail="Environment already exists")
new_id = uuid.uuid4() new_id = uuid.uuid4()
db.execute(insert(Environment).values(id=new_id, name=name)) db.execute(insert(Environment).values(id=new_id, name=name))
print("[DEBUG] environment_create: created environment '{}' with id={}".format(name, new_id))
return JSONResponse(status_code=201, content={ return JSONResponse(status_code=201, content={
'id': str(new_id), 'id': str(new_id),
@ -143,19 +157,24 @@ def environment_delete(req: Request, name: str):
""" """
db = req.state.db db = req.state.db
print("[DEBUG] environment_delete: deleting environment '{}'".format(name))
# Validate name format # Validate name format
if not validate_environment_name(name): if not validate_environment_name(name):
print("[DEBUG] environment_delete: ERROR - Invalid environment name '{}'. Environment names must use only alphanumeric, underscore or dash characters.".format(name))
raise HTTPException(status_code=400, detail="Invalid environment name format") raise HTTPException(status_code=400, detail="Invalid environment name format")
stmt = select(Environment).where(Environment.name == name) stmt = select(Environment).where(Environment.name == name)
result = db.execute(stmt).fetchall() result = db.execute(stmt).fetchall()
if len(result) == 0: if len(result) == 0:
print("[DEBUG] environment_delete: ERROR - No environment found with name '{}'".format(name))
raise HTTPException(status_code=404, detail="No such environment exists") raise HTTPException(status_code=404, detail="No such environment exists")
assert len(result) == 1 assert len(result) == 1
env: Environment = result[0][0] env: Environment = result[0][0]
print("[DEBUG] environment_delete: resolved environment '{}' with id={}".format(env.name, env.id))
# Check for assigned hosts before deleting # Check for assigned hosts before deleting
assignments_stmt = select(EnvironmentAssignment).where( assignments_stmt = select(EnvironmentAssignment).where(
@ -173,6 +192,7 @@ def environment_delete(req: Request, name: str):
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()))
print("[DEBUG] environment_delete: ERROR - Cannot delete environment '{}' because it has {} host(s) assigned: {}. All hosts must be unassigned before deletion.".format(name, len(assignments), [h.name for h in hosts]))
return JSONResponse(status_code=409, content={ return JSONResponse(status_code=409, content={
'message': "Cannot delete an environment that still has hosts assigned", 'message': "Cannot delete an environment that still has hosts assigned",
'assigned_hosts': [h.name for h in hosts] 'assigned_hosts': [h.name for h in hosts]
@ -180,6 +200,7 @@ def environment_delete(req: Request, name: str):
# Delete the environment # Delete the environment
db.execute(delete(Environment).where(Environment.id == env.id)) db.execute(delete(Environment).where(Environment.id == env.id))
print("[DEBUG] environment_delete: successfully deleted environment '{}' (id={})".format(env.name, env.id))
return JSONResponse(status_code=204, content={}) return JSONResponse(status_code=204, content={})
@ -189,31 +210,35 @@ def environment_patch(req: Request, name: str) -> JSONResponse:
db: Session = req.state.db db: Session = req.state.db
cfg: Config = config() cfg: Config = config()
print("[DEBUG] environment_patch: importing/syncing environment '{}' from git".format(name))
# Attempt to check the requested branch out # Attempt to check the requested branch out
try: try:
checkout_remote_branch(cfg, name) checkout_remote_branch(cfg, name)
print("[DEBUG] environment_patch: successfully checked out git branch '{}'".format(name))
# create the environment if it did not exist already # create the environment if it did not exist already
select_env_res = db.execute(select(Environment).where(Environment.name == name)).fetchall() select_env_res = db.execute(select(Environment).where(Environment.name == name)).fetchall()
print(select_env_res)
if len(select_env_res) == 0: if len(select_env_res) == 0:
insert_env_res = db.execute(insert(Environment).values(id=uuid.uuid4(), name=name).returning(Environment)).fetchall() insert_env_res = db.execute(insert(Environment).values(id=uuid.uuid4(), name=name).returning(Environment)).fetchall()
print(insert_env_res) print("[DEBUG] environment_patch: environment '{}' does not exist in database, creating new record".format(name))
if len(insert_env_res) == 0: if len(insert_env_res) == 0:
print("[DEBUG] environment_patch: ERROR - Failed to create environment '{}' in database after git checkout. Database insert returned no results.".format(name))
raise HTTPException(status_code=404, detail=f"Failed to create non-existent environment '{name}'") raise HTTPException(status_code=404, detail=f"Failed to create non-existent environment '{name}'")
else: else:
env: Environment = insert_env_res[0][0] env: Environment = insert_env_res[0][0]
else: else:
env: Environment = select_env_res[0][0] env: Environment = select_env_res[0][0]
print("[DEBUG] environment_patch: environment '{}' already exists in database (id={})".format(name, env.id))
# Branch has been checked out # Branch has been checked out
print(f"Reading states that are available in '{name}':")
print(f"{cfg.git.state_repo_path}:")
all_files = recursive_list_dir(cfg.git.state_repo_path) all_files = recursive_list_dir(cfg.git.state_repo_path)
sls_files = filter(lambda f: f.endswith(".sls"), all_files) sls_files = filter(lambda f: f.endswith(".sls"), all_files)
state_file_paths = map(lambda x: x.replace("/init.sls", "").replace(".sls", ""), sls_files) state_file_paths = map(lambda x: x.replace("/init.sls", "").replace(".sls", ""), sls_files)
state_names = list(map(lambda x: x.replace(f"{cfg.git.state_repo_path}/", "").replace("/", "."), state_file_paths)) state_names = list(map(lambda x: x.replace(f"{cfg.git.state_repo_path}/", "").replace("/", "."), state_file_paths))
print("[DEBUG] environment_patch: found {} .sls file(s) in git repo '{}', resolving to {} state name(s)".format(len(all_files), cfg.git.state_repo_path, len(state_names)))
# get all the existing states and the to be created ones # get all the existing states and the to be created ones
select_res = db.execute(select(State).where(State.name.in_(state_names))).fetchall() select_res = db.execute(select(State).where(State.name.in_(state_names))).fetchall()
states_known = {} states_known = {}
@ -221,10 +246,14 @@ def environment_patch(req: Request, name: str) -> JSONResponse:
state: State = row[0] state: State = row[0]
states_known[state.name] = state states_known[state.name] = state
print("[DEBUG] environment_patch: {} of {} states already exist in database".format(len(states_known), len(state_names)))
states_new = { states_new = {
state_name: State(name=state_name, id=uuid.uuid4()) for state_name in state_names if state_name not in states_known state_name: State(name=state_name, id=uuid.uuid4()) for state_name in state_names if state_name not in states_known
} }
print("[DEBUG] environment_patch: {} new state(s) to be created".format(len(states_new)))
states = {} states = {}
states.update(states_known) states.update(states_known)
states.update(states_new) states.update(states_new)
@ -242,14 +271,20 @@ def environment_patch(req: Request, name: str) -> JSONResponse:
x[0].state_id x[0].state_id
for x in db.execute(select(StateAssignment).where(StateAssignment.environment_id == env.id)).fetchall() for x in db.execute(select(StateAssignment).where(StateAssignment.environment_id == env.id)).fetchall()
] ]
print("[DEBUG] environment_patch: {} existing state assignment(s) found for environment '{}'".format(len(state_assignments_known), name))
state_assignments_new = [ state_assignments_new = [
state_id state_id
for state_id in map(lambda x: x.id, states.values()) if state_id not in state_assignments_known for state_id in map(lambda x: x.id, states.values()) if state_id not in state_assignments_known
] ]
print("[DEBUG] environment_patch: {} new state assignment(s) to be created".format(len(state_assignments_new)))
state_assignments_to_delete = [ state_assignments_to_delete = [
sid sid
for sid in filter(lambda x: x not in state_ids, state_assignments_known) for sid in filter(lambda x: x not in state_ids, state_assignments_known)
] ]
if len(state_assignments_to_delete) > 0:
print("[DEBUG] environment_patch: {} stale state assignment(s) to be removed (states no longer exist in git)".format(len(state_assignments_to_delete)))
delete_stmt = delete(StateAssignment) delete_stmt = delete(StateAssignment)
for sid in state_assignments_to_delete: for sid in state_assignments_to_delete:
@ -261,5 +296,5 @@ def environment_patch(req: Request, name: str) -> JSONResponse:
except Exception as exc: except Exception as exc:
print(f"Failed to import environment: {exc}") print("[DEBUG] environment_patch: ERROR - Failed to import environment '{}': {}".format(name, str(exc)))
raise HTTPException(status_code=404, detail=str(exc)) raise HTTPException(status_code=404, detail=str(exc))

View File

@ -35,9 +35,12 @@ def hosts_get(req: Request):
""" """
db: Session = req.state.db db: Session = req.state.db
print("[DEBUG] hosts_get: retrieving all hosts (excluding hostgroups)")
result = db.execute(select(Host).where(Host.is_hostgroup == False)).fetchall() result = db.execute(select(Host).where(Host.is_hostgroup == False)).fetchall()
hosts: list[Host] = list(map(lambda x: x[0], result)) hosts: list[Host] = list(map(lambda x: x[0], result))
print("[DEBUG] hosts_get: found {} host(s) in database".format(len(hosts)))
return JSONResponse(status_code=200, content=list(map(lambda x: x.name, hosts))) return JSONResponse(status_code=200, content=list(map(lambda x: x.name, hosts)))
@ -63,16 +66,21 @@ def host_get(req: Request, fqdn: str):
""" """
db: Session = req.state.db db: Session = req.state.db
print("[DEBUG] host_get: retrieving host '{}'".format(fqdn))
if not validate_fqdn(fqdn): if not validate_fqdn(fqdn):
print("[DEBUG] host_get: ERROR - Provided FQDN '{}' is invalid. Host names must be valid identifiers.".format(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) host_stmt = select(Host).where(Host.name == fqdn)
result = db.execute(host_stmt).fetchall() result = db.execute(host_stmt).fetchall()
if len(result) != 1: if len(result) != 1:
print("[DEBUG] host_get: ERROR - Expected exactly one host with name '{}' but got {} results. This may indicate a database integrity violation (duplicate host names)".format(fqdn, len(result)))
raise HTTPException(status_code=404, detail=f"No such host found (length of result was {len(result)})") raise HTTPException(status_code=404, detail=f"No such host found (length of result was {len(result)})")
host: Host = result[0][0] host: Host = result[0][0]
print("[DEBUG] host_get: resolved host '{}' with id={}".format(host.name, host.id))
last_parent = host last_parent = host
path = [] path = []
@ -88,6 +96,7 @@ def host_get(req: Request, fqdn: str):
last_parent = parent last_parent = parent
path.reverse() path.reverse()
print("[DEBUG] host_get: resolved hierarchical path for '{}': {}".format(fqdn, '/'.join(map(lambda x: x.name, path))))
return JSONResponse(status_code=200, content={ return JSONResponse(status_code=200, content={
"host": host.name, "host": host.name,
"path": '/'.join(map(lambda x: x.name, path)) "path": '/'.join(map(lambda x: x.name, path))
@ -116,14 +125,19 @@ async def host_add(request: Request, fqdn: str, params: HostCreateParams):
""" """
db: Session = request.state.db db: Session = request.state.db
print("[DEBUG] host_add: creating new host '{}'".format(fqdn))
# Validate that the provided FQDN is properly formatted # Validate that the provided FQDN is properly formatted
if not validate_fqdn(fqdn): if not validate_fqdn(fqdn):
print("[DEBUG] host_add: ERROR - Provided FQDN '{}' is invalid. Host names must be valid identifiers.".format(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")
# Process parent path if provided # Process parent path if provided
if params.parent is not None: if params.parent is not None:
print("[DEBUG] host_add: resolving parent path '{}'".format(params.parent))
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:
print("[DEBUG] host_add: ERROR - Parent path '{}' could not be parsed or validated.".format(params.parent))
raise HTTPException(status_code=400, detail="Provided parent is not a valid path") raise HTTPException(status_code=400, detail="Provided parent is not a valid path")
else: else:
parent_labels = [] parent_labels = []
@ -135,12 +149,14 @@ async def host_add(request: Request, fqdn: str, params: HostCreateParams):
result = db.execute(stmt_select_respecting_parent).fetchall() result = db.execute(stmt_select_respecting_parent).fetchall()
if len(result) == 0: if len(result) == 0:
print("[DEBUG] host_add: ERROR - Parent '{}' does not exist at level '{}'. Cannot create host without valid parent hierarchy.".format(label, parent_id))
raise HTTPException(status_code=400, detail="Parent does not exist") raise HTTPException(status_code=400, detail="Parent does not exist")
# Note: this should be enforced by the database # Note: this should be enforced by the database
assert len(result) == 1 assert len(result) == 1
parent_id = result[0][0].id parent_id = result[0][0].id
print("[DEBUG] host_add: resolved parent '{}' (id={})".format(label, parent_id))
# Create new host with unique ID and hierarchical structure # Create new host with unique ID and hierarchical structure
new_host = Host( new_host = Host(
@ -152,6 +168,8 @@ async def host_add(request: Request, fqdn: str, params: HostCreateParams):
stmt_create_host_with_parent = insert(Host).values(id=new_host.id, name=new_host.name, parent_id=new_host.parent_id, is_hostgroup=new_host.is_hostgroup) stmt_create_host_with_parent = insert(Host).values(id=new_host.id, name=new_host.name, parent_id=new_host.parent_id, is_hostgroup=new_host.is_hostgroup)
db.execute(stmt_create_host_with_parent) db.execute(stmt_create_host_with_parent)
print("[DEBUG] host_add: created new host '{}' with id={} at parent level {}".format(fqdn, new_host.id, parent_id))
# Prepare response with creation details # Prepare response with creation details
output = { output = {
"message": "Host created", "message": "Host created",
@ -194,15 +212,20 @@ async def host_delete(request: Request, fqdn: str):
db: Session = request.state.db db: Session = request.state.db
print("[DEBUG] host_delete: deleting host '{}'".format(fqdn))
if not validate_fqdn(fqdn): if not validate_fqdn(fqdn):
print("[DEBUG] host_delete: ERROR - Provided FQDN '{}' is invalid. Host names must be valid identifiers.".format(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(and_(Host.name == fqdn, Host.is_hostgroup == False)) host_stmt = select(Host).where(and_(Host.name == fqdn, Host.is_hostgroup == False))
host_res = db.execute(host_stmt).fetchall() host_res = db.execute(host_stmt).fetchall()
if len(host_res) != 1: if len(host_res) != 1:
print("[DEBUG] host_delete: ERROR - Expected exactly one non-hostgroup with name '{}' but got {} results. This may indicate a database integrity violation (duplicate host names)".format(fqdn, len(host_res)))
raise HTTPException(status_code=400, detail="Host not found") raise HTTPException(status_code=400, detail="Host not found")
host: Host = host_res[0][0] host: Host = host_res[0][0]
print("[DEBUG] host_delete: deleting host '{}' with id={}".format(host.name, host.id))
db.execute(delete(Host).where(Host.id == host.id)) db.execute(delete(Host).where(Host.id == host.id))
return JSONResponse(status_code=204, content={"message": "Host deleted"}) return JSONResponse(status_code=204, content={"message": "Host deleted"})

View File

@ -31,9 +31,12 @@ def hostgroups_get(req: Request):
""" """
db: Session = req.state.db db: Session = req.state.db
print("[DEBUG] hostgroups_get: retrieving all host groups")
result = db.execute(select(Host).where(Host.is_hostgroup == True)).fetchall() result = db.execute(select(Host).where(Host.is_hostgroup == True)).fetchall()
hosts: list[Host] = list(map(lambda x: x[0], result)) hosts: list[Host] = list(map(lambda x: x[0], result))
print("[DEBUG] hostgroups_get: found {} host group(s) in database".format(len(hosts)))
all_hostgroups = { x.id: x for x in hosts } all_hostgroups = { x.id: x for x in hosts }
all_hostgroup_names = [] all_hostgroup_names = []
for host in hosts: for host in hosts:
@ -42,6 +45,8 @@ def hostgroups_get(req: Request):
ancestors.append(all_hostgroups[ancestors[-1].parent_id]) ancestors.append(all_hostgroups[ancestors[-1].parent_id])
all_hostgroup_names.append('/'.join(map(lambda x: x.name, reversed(ancestors)))) all_hostgroup_names.append('/'.join(map(lambda x: x.name, reversed(ancestors))))
print("[DEBUG] hostgroups_get: resolved hierarchical names for {} host group(s): {}".format(len(all_hostgroup_names), all_hostgroup_names))
return JSONResponse(status_code=200, content=all_hostgroup_names) return JSONResponse(status_code=200, content=all_hostgroup_names)
@router.get("/{name}") @router.get("/{name}")
@ -63,6 +68,8 @@ def hostgroup_get(req: Request, name: str, params: HostgroupParams):
""" """
db: Session = req.state.db db: Session = req.state.db
print("[DEBUG] hostgroup_get: retrieving hostgroup '{}'".format(name))
# decode the path # decode the path
last = None last = None
ancestors = [] ancestors = []
@ -70,7 +77,8 @@ def hostgroup_get(req: Request, name: str, params: HostgroupParams):
if params: if params:
path = split_and_validate_path(params.path) if params.path else [] path = split_and_validate_path(params.path) if params.path else []
print("test") if len(path) > 0:
print("[DEBUG] hostgroup_get: resolving parent path with {} segment(s): {}".format(len(path), path))
# get the path from the db # get the path from the db
path_stmt = select(Host).where(and_(Host.name == bindparam('name') and Host.parent_id == bindparam('parent_id'))) path_stmt = select(Host).where(and_(Host.name == bindparam('name') and Host.parent_id == bindparam('parent_id')))
@ -79,23 +87,29 @@ def hostgroup_get(req: Request, name: str, params: HostgroupParams):
# error 404 if there is no matching item # error 404 if there is no matching item
if len(result) != 1: if len(result) != 1:
print("[DEBUG] hostgroup_get: ERROR - No hostgroup found with name '{}' at parent level '{}'. Path traversal failed.".format(label, last))
raise HTTPException(status_code=404, detail="No such hostgroup path exists") raise HTTPException(status_code=404, detail="No such hostgroup path exists")
tmp: Host = result[0][0] tmp: Host = result[0][0]
ancestors.append(tmp) ancestors.append(tmp)
last = tmp.id last = tmp.id
if len(ancestors) > 0:
print("[DEBUG] hostgroup_get: resolved parent path '{}'".format('/'.join(x.name for x in ancestors)))
# get the host in question # get the host in question
stmt = select(Host).where(and_(Host.name == name, Host.is_hostgroup == True, Host.parent_id == last)) stmt = select(Host).where(and_(Host.name == name, Host.is_hostgroup == True, Host.parent_id == last))
result = db.execute(stmt).fetchall() result = db.execute(stmt).fetchall()
if len(result) == 0: if len(result) == 0:
print("[DEBUG] hostgroup_get: ERROR - No hostgroup found with name '{}' and parent_id '{}'. This may indicate the hostgroup does not exist or has a different parent.".format(name, last))
raise HTTPException(status_code=404, detail="No such hostgroup exists") raise HTTPException(status_code=404, detail="No such hostgroup exists")
# Note: this should be enforced by the database # Note: this should be enforced by the database
assert len(result) == 1 assert len(result) == 1
hg: Host = result[0][0] hg: Host = result[0][0]
print("[DEBUG] hostgroup_get: resolved hostgroup '{}' with id={}".format(hg.name, hg.id))
return JSONResponse(status_code=200, content={ return JSONResponse(status_code=200, content={
'hostgroup': hg.name, 'hostgroup': hg.name,
@ -124,6 +138,8 @@ def hostgroup_create(req: Request, name: str, params: HostgroupParams):
labels = ( split_and_validate_path(path) if path is not None else [] ) or [] labels = ( split_and_validate_path(path) if path is not None else [] ) or []
labels += [ name ] labels += [ name ]
print("[DEBUG] hostgroup_create: creating hostgroup hierarchy with {} label(s): {}".format(len(labels), labels))
stmt = select(Host).where(and_(Host.name == bindparam('name'), Host.is_hostgroup == True, Host.parent_id == bindparam('last'))) stmt = select(Host).where(and_(Host.name == bindparam('name'), Host.is_hostgroup == True, Host.parent_id == bindparam('last')))
last = None last = None
for label in labels: for label in labels:
@ -132,15 +148,19 @@ def hostgroup_create(req: Request, name: str, params: HostgroupParams):
if len(result) == 1: if len(result) == 1:
# simply step down through the hierarchy # simply step down through the hierarchy
host = result[0][0] host = result[0][0]
print("[DEBUG] hostgroup_create: existing hostgroup '{}' (id={}) found at parent level '{}', reusing".format(label, host.id, last))
last = host.id last = host.id
elif len(result) == 0: elif len(result) == 0:
new_id = uuid.uuid4() new_id = uuid.uuid4()
db.execute(insert(Host).values(id=new_id, name=label, is_hostgroup=True, parent_id=last)) db.execute(insert(Host).values(id=new_id, name=label, is_hostgroup=True, parent_id=last))
print("[DEBUG] hostgroup_create: created new hostgroup '{}' with id={} at parent level {}".format(label, new_id, last))
last = new_id last = new_id
else: else:
# this should not be possible # this should not be possible
print("[DEBUG] hostgroup_create: ERROR - Multiple hostgroups found with name '{}' and parent '{}'. This indicates a database integrity violation (duplicate hostgroup names). Expected unique constraint enforcement.".format(label, last))
assert False assert False
print("[DEBUG] hostgroup_create: successfully created/resolved hostgroup hierarchy ending at '{}'".format(labels[-1]))
# TODO: return the newly created hostgroups # TODO: return the newly created hostgroups
return JSONResponse(status_code=201, content={}) return JSONResponse(status_code=201, content={})
@ -168,26 +188,32 @@ def hostgroup_delete(req: Request, name: str, params: HostgroupParams = Depends(
labels.append(name) labels.append(name)
last = None last = None
print("[DEBUG] hostgroup_delete: deleting hostgroup hierarchy with {} label(s): {}".format(len(labels), labels))
stmt_step = select(Host).where(and_(Host.name == bindparam('name'), Host.parent_id == bindparam('last'), Host.is_hostgroup == True)) stmt_step = select(Host).where(and_(Host.name == bindparam('name'), Host.parent_id == bindparam('last'), Host.is_hostgroup == True))
for label in labels: for label in labels:
result = db.execute(stmt_step, {'name': label, 'last': last}).fetchall() result = db.execute(stmt_step, {'name': label, 'last': last}).fetchall()
if len(result) == 0: if len(result) == 0:
print("[DEBUG] hostgroup_delete: ERROR - No hostgroup found with name '{}' at parent level '{}'. Path traversal failed.".format(label, last))
return JSONResponse(status_code=404, content={}) # TODO: truly define a error format return JSONResponse(status_code=404, content={}) # TODO: truly define a error format
# this should be enforced by the database # this should be enforced by the database
assert len(result) == 1 assert len(result) == 1
host: Host = result[0][0] host: Host = result[0][0]
print("[DEBUG] hostgroup_delete: resolved hostgroup '{}' (id={}) at parent level {}".format(label, host.id, last))
last = host.id last = host.id
children_stmt = select(Host).where(Host.parent_id == last) children_stmt = select(Host).where(Host.parent_id == last)
children: list[Host] = list(map(lambda x: x[0], db.execute(children_stmt).fetchall())) children: list[Host] = list(map(lambda x: x[0], db.execute(children_stmt).fetchall()))
if len(children) != 0: if len(children) != 0:
print("[DEBUG] hostgroup_delete: ERROR - Cannot delete hostgroup '{}' because it has {} child(ren): {}. All children must be deleted first.".format(labels[-1], len(children), [ '/'.join(labels + [x.name]) for x in children ]))
return JSONResponse(status_code=400, content={ return JSONResponse(status_code=400, content={
'message': "Cannot delete a hostgroup that still has children", 'message': "Cannot delete a hostgroup that still has children",
'children': [ '/'.join(labels + [x.name]) for x in children ] 'children': [ '/'.join(labels + [x.name]) for x in children ]
}) })
print("[DEBUG] hostgroup_delete: deleting hostgroup '{}' (id={}) with no remaining children".format(labels[-1], last))
db.execute(delete(Host).where(Host.id == last)) db.execute(delete(Host).where(Host.id == last))
return JSONResponse(status_code=204, content={}) return JSONResponse(status_code=204, content={})

View File

@ -37,9 +37,12 @@ def pillar_get(req: Request, target: str) -> JSONResponse:
# if any error happens, return non-200 status and an empty dictionary so that salt does not shit itself # if any error happens, return non-200 status and an empty dictionary so that salt does not shit itself
db: Session = req.state.db db: Session = req.state.db
print("[DEBUG] pillar_get: retrieving pillar data for target '{}'".format(target))
# if the target is a hostgroup with path, then split the path and get to the target host this way # if the target is a hostgroup with path, then split the path and get to the target host this way
target = target.replace("%%2F", "%%2f") target = target.replace("%%2F", "%%2f")
if "%%2f" in target: if "%%2f" in target:
print("[DEBUG] pillar_get: target '{}' contains path separators (%%2f), resolving hierarchical path".format(target))
path_labels = target.split("%%2f") path_labels = target.split("%%2f")
@ -53,6 +56,7 @@ def pillar_get(req: Request, target: str) -> JSONResponse:
result = db.execute(host_stmt, {"frag": fragment, "parent_id": parent_id}).fetchall() result = db.execute(host_stmt, {"frag": fragment, "parent_id": parent_id}).fetchall()
host_stmt = host_stmt_remain host_stmt = host_stmt_remain
if len(result) == 0: if len(result) == 0:
print("[DEBUG] pillar_get: ERROR - No host found with name '{}' and parent_id '{}'. Path resolution failed at this segment.".format(fragment, parent_id))
return JSONResponse(status_code=404, content={"message": f"No such path fragment: {fragment} with parent_id {parent_id}"}) return JSONResponse(status_code=404, content={"message": f"No such path fragment: {fragment} with parent_id {parent_id}"})
assert len(result) == 1 # Note: that the db should enforce this assert len(result) == 1 # Note: that the db should enforce this
@ -62,13 +66,16 @@ def pillar_get(req: Request, target: str) -> JSONResponse:
else: else:
print("[DEBUG] pillar_get: target '{}' is a single name, resolving host hierarchy".format(target))
# get the host hierarchy from a fqdn or unique hostgroup name # get the host hierarchy from a fqdn or unique hostgroup name
host_stmt = select(Host).where(Host.name == target) host_stmt = select(Host).where(Host.name == target)
result = db.execute(host_stmt).fetchall() result = db.execute(host_stmt).fetchall()
if len(result) == 0: if len(result) == 0:
print("[DEBUG] pillar_get: ERROR - No host found with name '{}'".format(target))
return JSONResponse(status_code=404, content={'message': f'No such target: {target}'}) return JSONResponse(status_code=404, content={'message': f'No such target: {target}'})
# NOTE: should be enforced by the database # NOTE: should be enforced by the database
if len(result) > 1: if len(result) > 1:
print("[DEBUG] pillar_get: ERROR - Multiple hosts found with name '{}'. This indicates a database integrity violation (duplicate host names).".format(target))
return JSONResponse(status_code=400, content={'message': f'Multiple targets: {target}'}) return JSONResponse(status_code=400, content={'message': f'Multiple targets: {target}'})
host: Host = result[0][0] host: Host = result[0][0]
@ -83,8 +90,10 @@ def pillar_get(req: Request, target: str) -> JSONResponse:
path.reverse() path.reverse()
print("[DEBUG] pillar_get: resolved host hierarchy with {} hosts: {}".format(len(path), [h.name for h in path]))
out = merge([get_pillar_for_target(db, host.id) for host in path]) out = merge([get_pillar_for_target(db, host.id) for host in path])
print("[DEBUG] pillar_get: merged pillar data contains {} top-level key(s)".format(len(out)))
return JSONResponse(status_code=200, content=out) return JSONResponse(status_code=200, content=out)
@ -93,8 +102,11 @@ def pillar_get(req: Request, target: str) -> JSONResponse:
def pillar_create(req: Request, name: str, params: PillarParams): def pillar_create(req: Request, name: str, params: PillarParams):
db: Session = req.state.db db: Session = req.state.db
print("[DEBUG] pillar_create: creating pillar '{}' for host/hostgroup={}/hostgroup={}".format(name, params.host, params.hostgroup))
# ensure that value and type have been set in the request parameters # ensure that value and type have been set in the request parameters
if params.type is None or params.value is None: if params.type is None or params.value is None:
print("[DEBUG] pillar_create: ERROR - Both parameter type and value must be set. Received type={}, value={}".format(params.type, params.value))
return JSONResponse(status_code=400, content={ return JSONResponse(status_code=400, content={
'message': "Both parameter type and value need to be set!" 'message': "Both parameter type and value need to be set!"
}) })
@ -103,18 +115,22 @@ def pillar_create(req: Request, name: str, params: PillarParams):
pillar_data = validate_pillar_input_data(params.value, params.type) pillar_data = validate_pillar_input_data(params.value, params.type)
if params.host is not None: if params.host is not None:
print("[DEBUG] pillar_create: targeting host '{}'".format(params.host))
target_stmt = select(Host).where(Host.name == params.host) target_stmt = select(Host).where(Host.name == params.host)
result = db.execute(target_stmt).fetchall() result = db.execute(target_stmt).fetchall()
if len(result) == 0: if len(result) == 0:
print("[DEBUG] pillar_create: ERROR - Host '{}' not found in database".format(params.host))
return JSONResponse(status_code=404, content={}) return JSONResponse(status_code=404, content={})
# this should be enforced by the database # this should be enforced by the database
assert len(result) == 1 assert len(result) == 1
target: Host = result[0][0] target: Host = result[0][0]
elif params.hostgroup is not None: elif params.hostgroup is not None:
print("[DEBUG] pillar_create: targeting hostgroup '{}'".format(params.hostgroup))
path = split_and_validate_path(params.hostgroup) path = split_and_validate_path(params.hostgroup)
if not path: if not path:
print("[DEBUG] pillar_create: ERROR - Hostgroup path '{}' could not be parsed or validated.".format(params.hostgroup))
return JSONResponse(status_code=400, content={'message': "No target specified"}) return JSONResponse(status_code=400, content={'message': "No target specified"})
last = None last = None
current = None current = None
@ -124,6 +140,7 @@ def pillar_create(req: Request, name: str, params: PillarParams):
for label in path: for label in path:
result = db.execute(group_stmt if last is not None else group_stmt_none, {'name': label, 'parent': last}).fetchall() result = db.execute(group_stmt if last is not None else group_stmt_none, {'name': label, 'parent': last}).fetchall()
if len(result) == 0: if len(result) == 0:
print("[DEBUG] pillar_create: ERROR - No hostgroup found with name '{}' at parent level '{}'. Path traversal failed.".format(label, last))
return JSONResponse(status_code=404, content={'message': f"No hostgroup named: {params.hostgroup}"}) return JSONResponse(status_code=404, content={'message': f"No hostgroup named: {params.hostgroup}"})
# Note: this should be enforced by the database # Note: this should be enforced by the database
assert len(result) == 1, f"Result: {[x[0].name for x in result]}" assert len(result) == 1, f"Result: {[x[0].name for x in result]}"
@ -131,6 +148,7 @@ def pillar_create(req: Request, name: str, params: PillarParams):
last = current.id last = current.id
target: Host = current target: Host = current
else: else:
print("[DEBUG] pillar_create: ERROR - Neither host nor hostgroup specified in request parameters.")
return JSONResponse(status_code=400, content={'message': "Neither host nor hostgroup set"}) return JSONResponse(status_code=400, content={'message': "Neither host nor hostgroup set"})
# if this is a dictionary value, parse it and create a separate entry for all the sub-pillars # if this is a dictionary value, parse it and create a separate entry for all the sub-pillars
@ -150,6 +168,7 @@ def pillar_create(req: Request, name: str, params: PillarParams):
return out return out
pillars_to_store = aux(name, pillar_data) pillars_to_store = aux(name, pillar_data)
print("[DEBUG] pillar_create: dictionary value expanded into {} sub-pillar entries".format(len(pillars_to_store)))
else: else:
# build the pillar package # build the pillar package
@ -157,6 +176,8 @@ def pillar_create(req: Request, name: str, params: PillarParams):
{ 'name': name, 'value': params.value, 'type': params.type } { 'name': name, 'value': params.value, 'type': params.type }
] ]
print("[DEBUG] pillar_create: storing {} pillar entry/entries for target '{}' (id={})".format(len(pillars_to_store), target.name, target.id))
# store pillar data # store pillar data
insert_stmt = insert(Pillar).values(id=bindparam('new_id'), host_id=target.id, pillar_name=bindparam('name'), parameter_type=bindparam('type'), value=bindparam('value')) insert_stmt = insert(Pillar).values(id=bindparam('new_id'), host_id=target.id, pillar_name=bindparam('name'), parameter_type=bindparam('type'), value=bindparam('value'))
upsert_stmt = insert_stmt.on_conflict_do_update(constraint='pillar_unique_pillar_name', set_={'parameter_type': bindparam('type'), 'value': bindparam('value')} ) upsert_stmt = insert_stmt.on_conflict_do_update(constraint='pillar_unique_pillar_name', set_={'parameter_type': bindparam('type'), 'value': bindparam('value')} )
@ -165,6 +186,7 @@ def pillar_create(req: Request, name: str, params: PillarParams):
instance['new_id'] = uuid4() instance['new_id'] = uuid4()
result = db.execute(upsert_stmt, instance) result = db.execute(upsert_stmt, instance)
print("[DEBUG] pillar_create: successfully stored pillar '{}'".format(name))
return JSONResponse(status_code=200, content={'message': 'ok'}) return JSONResponse(status_code=200, content={'message': 'ok'})
@ -175,12 +197,16 @@ def pillar_create(req: Request, name: str, params: PillarParams):
def pillar_delete(req: Request, name: str, params: PillarParams): def pillar_delete(req: Request, name: str, params: PillarParams):
db = req.state.db db = req.state.db
print("[DEBUG] pillar_delete: deleting pillar '{}' for host/hostgroup={}/hostgroup={}".format(name, params.host, params.hostgroup))
if params.host is not None: if params.host is not None:
# delete a pillar at the host level # delete a pillar at the host level
print("[DEBUG] pillar_delete: targeting host '{}'".format(params.host))
target_stmt = select(Host).where(and_(Host.name == params.host, Host.is_hostgroup == False)) target_stmt = select(Host).where(and_(Host.name == params.host, Host.is_hostgroup == False))
result = db.execute(target_stmt).fetchall() result = db.execute(target_stmt).fetchall()
if len(result) == 0: if len(result) == 0:
print("[DEBUG] pillar_delete: ERROR - Host '{}' not found in database".format(params.host))
return JSONResponse(status_code=404, content={}) return JSONResponse(status_code=404, content={})
# this should be enforced by the database # this should be enforced by the database
@ -188,8 +214,10 @@ def pillar_delete(req: Request, name: str, params: PillarParams):
target: Host = result[0][0] target: Host = result[0][0]
elif params.hostgroup is not None: elif params.hostgroup is not None:
# delete a pillar at the hostgroup level # delete a pillar at the hostgroup level
print("[DEBUG] pillar_delete: targeting hostgroup '{}'".format(params.hostgroup))
path = split_and_validate_path(params.hostgroup) path = split_and_validate_path(params.hostgroup)
if not path: if not path:
print("[DEBUG] pillar_delete: ERROR - Hostgroup path '{}' could not be parsed or validated.".format(params.hostgroup))
return JSONResponse(status_code=400, content={'message': "No target specified"}) return JSONResponse(status_code=400, content={'message': "No target specified"})
last = None last = None
current = None current = None
@ -199,6 +227,7 @@ def pillar_delete(req: Request, name: str, params: PillarParams):
for label in path: for label in path:
result = db.execute(group_stmt if last is not None else group_stmt_none, {'name': label, 'parent': last}).fetchall() result = db.execute(group_stmt if last is not None else group_stmt_none, {'name': label, 'parent': last}).fetchall()
if len(result) == 0: if len(result) == 0:
print("[DEBUG] pillar_delete: ERROR - No hostgroup found with name '{}' at parent level '{}'. Path traversal failed.".format(label, last))
return JSONResponse(status_code=404, content={'message': f"No hostgroup named: {params.hostgroup}"}) return JSONResponse(status_code=404, content={'message': f"No hostgroup named: {params.hostgroup}"})
# Note: this should be enforced by the database # Note: this should be enforced by the database
assert len(result) == 1, f"Result: {[x[0].name for x in result]}" assert len(result) == 1, f"Result: {[x[0].name for x in result]}"
@ -206,11 +235,15 @@ def pillar_delete(req: Request, name: str, params: PillarParams):
last = current.id last = current.id
target: Host = current target: Host = current
else: else:
print("[DEBUG] pillar_delete: ERROR - Neither host nor hostgroup specified in request parameters.")
return JSONResponse(status_code=400, content={ return JSONResponse(status_code=400, content={
'message': "Either Host or Hostgroup needs to be set!" 'message': "Either Host or Hostgroup needs to be set!"
}) })
print("[DEBUG] pillar_delete: deleting pillar '{}' (and sub-pillars '{}.*') from target '{}' (id={})".format(name, name, target.name, target.id))
delete_stmt = delete(Pillar).where(and_(Pillar.host_id == target.id, or_(Pillar.pillar_name == name, Pillar.pillar_name.like(f"{name}:%")))) delete_stmt = delete(Pillar).where(and_(Pillar.host_id == target.id, or_(Pillar.pillar_name == name, Pillar.pillar_name.like(f"{name}:%"))))
result = db.execute(delete_stmt) result = db.execute(delete_stmt)
print("[DEBUG] pillar_delete: successfully deleted pillar '{}' from target '{}'".format(name, target.name))
return JSONResponse(status_code=200, content={'message': 'ok'}) return JSONResponse(status_code=200, content={'message': 'ok'})

View File

@ -30,9 +30,12 @@ def states_get(req: Request):
""" """
db: Session = req.state.db db: Session = req.state.db
print("[DEBUG] states_get: retrieving all states")
result = db.execute(select(State)).fetchall() result = db.execute(select(State)).fetchall()
states: list[State] = list(map(lambda x: x[0], result)) states: list[State] = list(map(lambda x: x[0], result))
print("[DEBUG] states_get: found {} state(s) in database".format(len(states)))
return JSONResponse(status_code=200, content=[state.name for state in states]) return JSONResponse(status_code=200, content=[state.name for state in states])
@ -54,25 +57,31 @@ def state_get(req: Request, name: str):
""" """
db: Session = req.state.db db: Session = req.state.db
print("[DEBUG] state_get: retrieving state '{}'".format(name))
# Validate name before query # Validate name before query
if not validate_state_name(name): if not validate_state_name(name):
print("[DEBUG] state_get: ERROR - Invalid state name format '{}'. State names must start with a letter or underscore and contain only alphanumeric characters, underscores, or dashes.".format(name))
raise HTTPException(status_code=400, detail="Invalid state name format") raise HTTPException(status_code=400, detail="Invalid state name format")
stmt = select(State).where(State.name == name) stmt = select(State).where(State.name == name)
result = db.execute(stmt).fetchall() result = db.execute(stmt).fetchall()
if len(result) == 0: if len(result) == 0:
print("[DEBUG] state_get: ERROR - No state found with name '{}'".format(name))
raise HTTPException(status_code=404, detail="No such state exists") raise HTTPException(status_code=404, detail="No such state exists")
assert len(result) == 1 assert len(result) == 1
state: State = result[0][0] state: State = result[0][0]
print("[DEBUG] state_get: resolved state '{}' with id={}".format(state.name, state.id))
# Get assigned hosts count as an example of additional info # Get assigned hosts count as an example of additional info
assignments_stmt = select(StateAssignment).where( assignments_stmt = select(StateAssignment).where(
StateAssignment.state_id == state.id StateAssignment.state_id == state.id
) )
assignments_count = db.execute(assignments_stmt).fetchall().__len__() assignments_count = db.execute(assignments_stmt).fetchall().__len__()
print("[DEBUG] state_get: state '{}' has {} environment assignment(s)".format(state.name, assignments_count))
return JSONResponse(status_code=200, content={ return JSONResponse(status_code=200, content={
'state': state.name, 'state': state.name,
@ -97,8 +106,11 @@ def state_create(req: Request, name: str, patch_params: StateParams):
""" """
db = req.state.db db = req.state.db
print("[DEBUG] state_create: creating new state '{}'".format(name))
# Validate name format # Validate name format
if not validate_state_name(name): if not validate_state_name(name):
print("[DEBUG] state_create: ERROR - Invalid state name '{}'. State names must start with a letter or underscore and contain only alphanumeric characters, underscores, or dashes.".format(name))
raise HTTPException(status_code=400, raise HTTPException(status_code=400,
detail="Invalid state name. State names must start with a letter or underscore and contain only alphanumeric characters, underscores, or dashes.") detail="Invalid state name. State names must start with a letter or underscore and contain only alphanumeric characters, underscores, or dashes.")
@ -107,20 +119,24 @@ def state_create(req: Request, name: str, patch_params: StateParams):
existing = db.execute(stmt_check).fetchall() existing = db.execute(stmt_check).fetchall()
if len(existing) > 0: if len(existing) > 0:
print("[DEBUG] state_create: ERROR - State '{}' already exists in database. Duplicate state creation rejected.".format(name))
raise HTTPException(status_code=409, detail="State already exists") raise HTTPException(status_code=409, detail="State already exists")
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))
print("[DEBUG] state_create: created state '{}' with id={}".format(name, new_id))
stmt_set_env = insert(StateAssignment).values(state_id=new_id, environment_id=bindparam('env_id')) 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')) stmt_get_env_id = select(Environment).where(Environment.name == bindparam('env_name'))
for env in patch_params.addenv: for env in patch_params.addenv:
env_id_res = db.execute(stmt_get_env_id, {'env_name': env}).fetchall() env_id_res = db.execute(stmt_get_env_id, {'env_name': env}).fetchall()
if len(env_id_res) < 1: if len(env_id_res) < 1:
print("[DEBUG] state_create: ERROR - Environment '{}' does not exist. Cannot assign non-existent environment to new state.".format(env))
raise HTTPException(status_code=404, detail="No such environment exists") raise HTTPException(status_code=404, detail="No such environment exists")
env_id = env_id_res[0][0].id env_id = env_id_res[0][0].id
db.execute(stmt_set_env, {'env_id': env_id}) db.execute(stmt_set_env, {'env_id': env_id})
print("[DEBUG] state_create: assigned state '{}' to environment '{}' (id={})".format(name, env, env_id))
return JSONResponse(status_code=201, content={ return JSONResponse(status_code=201, content={
'id': str(new_id), 'id': str(new_id),
@ -147,19 +163,24 @@ def state_delete(req: Request, name: str):
""" """
db = req.state.db db = req.state.db
print("[DEBUG] state_delete: deleting state '{}'".format(name))
# Validate name format # Validate name format
if not validate_state_name(name): if not validate_state_name(name):
print("[DEBUG] state_delete: ERROR - Invalid state name '{}'. State names must start with a letter or underscore and contain only alphanumeric characters, underscores, or dashes.".format(name))
raise HTTPException(status_code=400, detail="Invalid state name format") raise HTTPException(status_code=400, detail="Invalid state name format")
stmt = select(State).where(State.name == name) stmt = select(State).where(State.name == name)
result = db.execute(stmt).fetchall() result = db.execute(stmt).fetchall()
if len(result) == 0: if len(result) == 0:
print("[DEBUG] state_delete: ERROR - No state found with name '{}'".format(name))
raise HTTPException(status_code=404, detail="No such state exists") raise HTTPException(status_code=404, detail="No such state exists")
assert len(result) == 1 assert len(result) == 1
state: State = result[0][0] state: State = result[0][0]
print("[DEBUG] state_delete: resolved state '{}' with id={}".format(state.name, state.id))
# Check for assigned environments before deleting # Check for assigned environments before deleting
assignments_stmt = select(StateAssignment).where( assignments_stmt = select(StateAssignment).where(
@ -177,6 +198,7 @@ def state_delete(req: Request, name: str):
envs_stmt = select(Environment).where(Environment.id.in_(env_ids)) envs_stmt = select(Environment).where(Environment.id.in_(env_ids))
envs: list[Environment] = list(map(lambda x: x[0], db.execute(envs_stmt).fetchall())) envs: list[Environment] = list(map(lambda x: x[0], db.execute(envs_stmt).fetchall()))
print("[DEBUG] state_delete: ERROR - Cannot delete state '{}' because it has {} environment assignment(s): {}. State must be unassigned from all environments before deletion.".format(name, len(assignments), [e.name for e in envs]))
return JSONResponse(status_code=409, content={ return JSONResponse(status_code=409, content={
'message': "Cannot delete a state that still has environment assignments", 'message': "Cannot delete a state that still has environment assignments",
'assigned_envs': [e.name for e in envs] 'assigned_envs': [e.name for e in envs]
@ -196,6 +218,7 @@ def state_delete(req: Request, name: str):
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()))
print("[DEBUG] state_delete: ERROR - Cannot delete state '{}' because it has {} host assignment(s): {}. State must be unassigned from all hosts before deletion.".format(name, len(top), [h.name for h in hosts]))
return JSONResponse(status_code=409, content={ return JSONResponse(status_code=409, content={
'message': "Cannot delete a state that still has host assignments", 'message': "Cannot delete a state that still has host assignments",
'assigned_hosts': [h.name for h in hosts] 'assigned_hosts': [h.name for h in hosts]
@ -203,6 +226,7 @@ def state_delete(req: Request, name: str):
# Delete the state # Delete the state
db.execute(delete(State).where(State.id == state.id)) db.execute(delete(State).where(State.id == state.id))
print("[DEBUG] state_delete: successfully deleted state '{}' (id={})".format(state.name, state.id))
return JSONResponse(status_code=204, content={}) return JSONResponse(status_code=204, content={})
@ -211,11 +235,15 @@ def state_patch(req: Request, name: str, patch_params: StateParams):
db: Session = req.state.db db: Session = req.state.db
print("[DEBUG] state_patch: patching state '{}' with addenv={}, delenv={}".format(name, patch_params.addenv, patch_params.delenv))
stmt_state_id = select(State).where(State.name == name) stmt_state_id = select(State).where(State.name == name)
selected_state_res = db.execute(stmt_state_id).fetchall() selected_state_res = db.execute(stmt_state_id).fetchall()
if len(selected_state_res) != 1: if len(selected_state_res) != 1:
print("[DEBUG] state_patch: ERROR - No state found with name '{}'".format(name))
raise HTTPException(status_code=404, detail="No such state exists") raise HTTPException(status_code=404, detail="No such state exists")
state: State = selected_state_res[0][0] state: State = selected_state_res[0][0]
print("[DEBUG] state_patch: resolved state '{}' with id={}".format(state.name, state.id))
# Statement for getting the # Statement for getting the
stmt_get_env_id = select(Environment).where(Environment.name == bindparam('env_name')) stmt_get_env_id = select(Environment).where(Environment.name == bindparam('env_name'))
@ -225,18 +253,22 @@ def state_patch(req: Request, name: str, patch_params: StateParams):
for env in patch_params.addenv: for env in patch_params.addenv:
env_id_res = db.execute(stmt_get_env_id, {'env_name': env}).fetchall() env_id_res = db.execute(stmt_get_env_id, {'env_name': env}).fetchall()
if len(env_id_res) < 1: if len(env_id_res) < 1:
print("[DEBUG] state_patch: ERROR - Environment '{}' does not exist. Cannot add non-existent environment to state.".format(env))
raise HTTPException(status_code=404, detail="No such environment exists") raise HTTPException(status_code=404, detail="No such environment exists")
env_id = env_id_res[0][0].id env_id = env_id_res[0][0].id
db.execute(stmt_set_env, {'env_id': env_id}) db.execute(stmt_set_env, {'env_id': env_id})
print("[DEBUG] state_patch: added state '{}' to environment '{}' (id={})".format(name, env, env_id))
stmt_del_env = delete(StateAssignment).where(and_(StateAssignment.state_id == state.id, StateAssignment.environment_id == bindparam('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: for env in patch_params.delenv:
env_id_res = db.execute(stmt_get_env_id, {'env_name': env}).fetchall() env_id_res = db.execute(stmt_get_env_id, {'env_name': env}).fetchall()
if len(env_id_res) < 1: if len(env_id_res) < 1:
print("[DEBUG] state_patch: ERROR - Environment '{}' does not exist. Cannot remove non-existent environment from state.".format(env))
raise HTTPException(status_code=404, detail="No such environment exists") raise HTTPException(status_code=404, detail="No such environment exists")
env_id = env_id_res[0][0].id env_id = env_id_res[0][0].id
db.execute(stmt_del_env, {'env_id': env_id}) db.execute(stmt_del_env, {'env_id': env_id})
print("[DEBUG] state_patch: removed state '{}' from environment '{}' (id={})".format(name, env, env_id))
return JSONResponse(status_code=204, content={}) return JSONResponse(status_code=204, content={})

View File

@ -22,15 +22,20 @@ router = APIRouter(
def top_get(req: Request, host: str): def top_get(req: Request, host: str):
db: Session = req.state.db db: Session = req.state.db
print("[DEBUG] top_get: querying top file assignments for host '{}'".format(host))
# build the hierarchy # build the hierarchy
host_stmt = select(Host).where(Host.name == host) host_stmt = select(Host).where(Host.name == host)
result = db.execute(host_stmt).fetchall() result = db.execute(host_stmt).fetchall()
if len(result) == 0: 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)}) return JSONResponse(status_code=404, content={"message": "Host '{}' not found".format(host)})
elif len(result) > 1: 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"}) return JSONResponse(status_code=500, content={"message": "More than one host found"})
else: else:
target_host: Host = result[0][0] 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")) parent_stmt = select(Host).where(Host.id == bindparam("parent_id"))
parents = [] parents = []
@ -39,14 +44,18 @@ def top_get(req: Request, host: str):
parents.append(current) parents.append(current)
result = db.execute(parent_stmt, {'parent_id': current.parent_id}).fetchall() result = db.execute(parent_stmt, {'parent_id': current.parent_id}).fetchall()
if len(result) == 0: 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 current = None
elif len(result) > 1: 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"}) return JSONResponse(status_code=500, content={"message": "More than one parent host found"})
else: else:
current = result[0][0] current = result[0][0]
if current is not None: if current is not None:
parents.append(current) 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) env_stmt = (select(Environment)
.join(EnvironmentAssignment, EnvironmentAssignment.environment_id == Environment.id) .join(EnvironmentAssignment, EnvironmentAssignment.environment_id == Environment.id)
.where(EnvironmentAssignment.host_id == bindparam("host_id")) .where(EnvironmentAssignment.host_id == bindparam("host_id"))
@ -57,6 +66,9 @@ def top_get(req: Request, host: str):
if len(env_res) == 1: if len(env_res) == 1:
env: Environment = env_res[0][0] env: Environment = 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) state_stmt = (select(State)
.join(TopFile, State.id == TopFile.state_id) .join(TopFile, State.id == TopFile.state_id)
.join(StateAssignment, State.id == StateAssignment.state_id) .join(StateAssignment, State.id == StateAssignment.state_id)
@ -70,6 +82,8 @@ def top_get(req: Request, host: str):
all_assigned_states = set(s for states in assigned_states for s in states) all_assigned_states = set(s for states in assigned_states for s in states)
env_name = env.name env_name = env.name
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]))
return JSONResponse(status_code=200, content={ return JSONResponse(status_code=200, content={
env.name: list(map(lambda state: state.name, all_assigned_states)), env.name: list(map(lambda state: state.name, all_assigned_states)),
}) })
@ -79,31 +93,36 @@ def top_get(req: Request, host: str):
def top_setenv(req: Request, host: str, environment: str): def top_setenv(req: Request, host: str, environment: str):
db: Session = req.state.db db: Session = req.state.db
print("[DEBUG] top_setenv: assigning environment '{}' to host '{}'".format(environment, host))
# get the target host id # get the target host id
host_stmt = select(Host).where(Host.name == host) host_stmt = select(Host).where(Host.name == host)
host_res = db.execute(host_stmt).fetchall() host_res = db.execute(host_stmt).fetchall()
if len(host_res) == 0: 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"}) return JSONResponse(status_code=404, content={"error": "No host found"})
elif len(host_res) == 1: elif len(host_res) == 1:
host_res = host_res[0][0] host_res = host_res[0][0]
else: else:
# Note that this should be prevented by the database 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"}) return JSONResponse(status_code=404, content={"error": "Too many hosts found??? This should not happen"})
# get the environment id # get the environment id
env_stmt = select(Environment).where(Environment.name == environment) env_stmt = select(Environment).where(Environment.name == environment)
env_res = db.execute(env_stmt).fetchall() env_res = db.execute(env_stmt).fetchall()
if len(env_res) == 0: 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"}) return JSONResponse(status_code=404, content={"error": "No environment found"})
elif len(env_res) == 1: elif len(env_res) == 1:
env_res = env_res[0][0] env_res = env_res[0][0]
else: else:
# Note that this should be prevented by the database 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"}) 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) insert_stmt = insert(EnvironmentAssignment).values(environment_id=env_res.id, host_id=host_res.id)
result = db.execute(insert_stmt) 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={}) return JSONResponse(status_code=200, content={})
@ -111,6 +130,8 @@ 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("[DEBUG] top_state_assign: assigning state '{}' to host '{}'".format(state_name, host_name))
# get the host in question # get the host in question
path_labels = host_name.replace("%%2F", "%%2f").split("%%2f") path_labels = host_name.replace("%%2F", "%%2f").split("%%2f")
parent_id = None parent_id = None
@ -119,12 +140,14 @@ def top_state_assign(req: Request, host_name: str, state_name: str):
host_res = db.execute(host_stmt).fetchall() host_res = db.execute(host_stmt).fetchall()
if len(host_res) != 1: 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"}) return JSONResponse(status_code=404, content={"error": f"Host '{host_name} not found"})
current: Host = host_res[0][0] current: Host = host_res[0][0]
parent_id = current.id parent_id = current.id
host: Host = current host: Host = current
print("[DEBUG] top_state_assign: resolved target host '{}' with id={}".format(host.name, 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] = []
@ -136,10 +159,13 @@ def top_state_assign(req: Request, host_name: str, state_name: str):
else: else:
parent = db.execute(parent_stmt, {'parent_id': current.parent_id}).fetchall() parent = db.execute(parent_stmt, {'parent_id': current.parent_id}).fetchall()
if len(parent) == 0: 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"}) 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 # Note: more than one result is impossible, since the id is a primary key
current: Host = parent[0][0] current: Host = parent[0][0]
print("[DEBUG] top_state_assign: resolved parent hierarchy with {} hosts".format(len(parents)))
# get the hosts environment # get the hosts environment
env_assign_stmt = select(EnvironmentAssignment).where(EnvironmentAssignment.host_id == bindparam("host_id")) env_assign_stmt = select(EnvironmentAssignment).where(EnvironmentAssignment.host_id == bindparam("host_id"))
env_assign: EnvironmentAssignment | None = None env_assign: EnvironmentAssignment | None = None
@ -149,9 +175,14 @@ def top_state_assign(req: Request, host_name: str, state_name: str):
env_assign: EnvironmentAssignment = env_res[0][0] env_assign: EnvironmentAssignment = env_res[0][0]
break 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_stmt = select(Environment).where(Environment.id == env_assign.environment_id)
env_res = db.execute(env_stmt).fetchall() env_res = db.execute(env_stmt).fetchall()
if len(env_res) != 1: 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"}) 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]
@ -161,11 +192,13 @@ def top_state_assign(req: Request, host_name: str, state_name: str):
) )
state_res = db.execute(state_stmt).fetchall() state_res = db.execute(state_stmt).fetchall()
if len(state_res) != 1: 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}'"}) 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]
# 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("[DEBUG] top_state_assign: successfully assigned state '{}' to host '{}' in environment '{}'".format(state_name, host_name, env.name))
return JSONResponse(status_code=200, content={}) return JSONResponse(status_code=200, content={})
@ -174,13 +207,17 @@ def top_state_assign(req: Request, host_name: str, state_name: str):
def top_state_unassign(req: Request, host_name: str, state_name: str): def top_state_unassign(req: Request, host_name: str, state_name: str):
db: Session = req.state.db 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 # 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()
if len(host_res) != 1: 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"}) 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("[DEBUG] top_state_unassign: resolved target host '{}' with id={}".format(host.name, 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] = []
@ -192,10 +229,13 @@ def top_state_unassign(req: Request, host_name: str, state_name: str):
else: else:
parent = db.execute(parent_stmt, {'parent_id': current.parent_id}).fetchall() parent = db.execute(parent_stmt, {'parent_id': current.parent_id}).fetchall()
if len(parent) == 0: 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"}) 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 # Note: more than one result is impossible, since the id is a primary key
current: Host = parent[0][0] current: Host = parent[0][0]
print("[DEBUG] top_state_unassign: resolved parent hierarchy with {} hosts".format(len(parents)))
# get the hosts environment # get the hosts environment
env_assign_stmt = select(EnvironmentAssignment).where(EnvironmentAssignment.host_id == bindparam("host_id")) env_assign_stmt = select(EnvironmentAssignment).where(EnvironmentAssignment.host_id == bindparam("host_id"))
env_assign: EnvironmentAssignment | None = None env_assign: EnvironmentAssignment | None = None
@ -205,9 +245,14 @@ def top_state_unassign(req: Request, host_name: str, state_name: str):
env_assign: EnvironmentAssignment = env_res[0][0] env_assign: EnvironmentAssignment = env_res[0][0]
break 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_stmt = select(Environment).where(Environment.id == env_assign.environment_id)
env_res = db.execute(env_stmt).fetchall() env_res = db.execute(env_stmt).fetchall()
if len(env_res) != 1: 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"}) 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]
@ -217,11 +262,13 @@ def top_state_unassign(req: Request, host_name: str, state_name: str):
) )
state_res = db.execute(state_stmt).fetchall() state_res = db.execute(state_stmt).fetchall()
if len(state_res) != 1: 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}'"}) 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]
# delete the relation from the database # delete the relation from the database
db.execute(delete(TopFile).where(and_(TopFile.state_id == state.id, TopFile.host_id == host.id))) 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={}) return JSONResponse(status_code=200, content={})