254 lines
13 KiB
Python
254 lines
13 KiB
Python
import json
|
|
import uuid
|
|
from uuid import uuid4
|
|
|
|
from sqlalchemy.dialects import postgresql
|
|
from sqlalchemy.dialects.postgresql import insert
|
|
from sqlalchemy import select, delete, bindparam, and_, or_
|
|
from sqlalchemy.orm import Session
|
|
from starlette.exceptions import HTTPException
|
|
from starlette.requests import Request
|
|
from fastapi import APIRouter, Depends
|
|
from starlette.responses import JSONResponse
|
|
|
|
from pillar_tool.db import Host, Pillar, TopFile
|
|
from pillar_tool.db.models.top_data import State, StateAssignment
|
|
from pillar_tool.db.queries.pillar_queries import get_pillar_for_target
|
|
from pillar_tool.schemas import PillarParams, get_model_from_query
|
|
from pillar_tool.util.pillar_utilities import merge
|
|
from pillar_tool.util.validation import validate_state_name, validate_fqdn, validate_pillar_input_data, \
|
|
split_and_validate_path
|
|
|
|
router = APIRouter(
|
|
prefix="/pillar",
|
|
tags=["pillar"],
|
|
)
|
|
|
|
# Note: there is no list of all pillars, as this would not be helpful
|
|
|
|
@router.get("/{target}")
|
|
def pillar_get(req: Request, target: str) -> JSONResponse:
|
|
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
|
|
target = target.replace("%%2F", "%%2f")
|
|
if "%%2f" in target:
|
|
print("[DEBUG] pillar_get: target '{}' contains path separators (%%2f), resolving hierarchical path".format(target))
|
|
path_labels = target.split("%%2f")
|
|
|
|
|
|
host_stmt_remain = select(Host).where(and_(Host.name == bindparam('frag'), Host.parent_id == bindparam('parent_id')))
|
|
host_stmt_first = select(Host).where(and_(Host.name == bindparam('frag'), Host.parent_id == None))
|
|
host_stmt = host_stmt_first
|
|
|
|
parent_id = None
|
|
path: list[Host] = []
|
|
for fragment in path_labels:
|
|
result = db.execute(host_stmt, {"frag": fragment, "parent_id": parent_id}).fetchall()
|
|
host_stmt = host_stmt_remain
|
|
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}"})
|
|
assert len(result) == 1 # Note: that the db should enforce this
|
|
|
|
current: Host = result[0][0]
|
|
parent_id = current.id
|
|
path.append(current)
|
|
|
|
|
|
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
|
|
host_stmt = select(Host).where(Host.name == target)
|
|
result = db.execute(host_stmt).fetchall()
|
|
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}'})
|
|
# NOTE: should be enforced by the database
|
|
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}'})
|
|
|
|
host: Host = result[0][0]
|
|
path: list[Host] = [host]
|
|
parent_stmt = select(Host).where(Host.id == bindparam('parent'))
|
|
while path[-1].parent_id is not None:
|
|
result = db.execute(parent_stmt, {'parent': path[-1].parent_id}).fetchall()
|
|
# NOTE: should be enforced by the database
|
|
assert len(result) == 1
|
|
tmp: Host = result[0][0]
|
|
path.append(tmp)
|
|
|
|
path.reverse()
|
|
|
|
# create 'virtual' pillar for states
|
|
stmt_states = select(State).join(TopFile, TopFile.state_id == State.id).where(TopFile.host_id == host.id)
|
|
states_list: list[str] = [ x[0].name for x in db.execute(stmt_states).fetchall() ]
|
|
|
|
# create path name
|
|
hostgroup_name = '/'.join(x.name for x in path[:-1])
|
|
|
|
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]) # type: ignore
|
|
out.update({
|
|
"states": states_list,
|
|
"hostgroup": hostgroup_name
|
|
})
|
|
print("[DEBUG] pillar_get: merged pillar data contains {} top-level key(s)".format(len(out)))
|
|
return JSONResponse(status_code=200, content=out)
|
|
|
|
|
|
|
|
@router.post("/{name}")
|
|
def pillar_create(req: Request, name: str, params: PillarParams):
|
|
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
|
|
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={
|
|
'message': "Both parameter type and value need to be set!"
|
|
})
|
|
|
|
# validate pillar data
|
|
pillar_data = validate_pillar_input_data(params.value, params.type)
|
|
|
|
if params.host is not None:
|
|
print("[DEBUG] pillar_create: targeting host '{}'".format(params.host))
|
|
target_stmt = select(Host).where(Host.name == params.host)
|
|
result = db.execute(target_stmt).fetchall()
|
|
|
|
if len(result) == 0:
|
|
print("[DEBUG] pillar_create: ERROR - Host '{}' not found in database".format(params.host))
|
|
return JSONResponse(status_code=404, content={})
|
|
|
|
# this should be enforced by the database
|
|
assert len(result) == 1
|
|
target: Host = result[0][0]
|
|
elif params.hostgroup is not None:
|
|
print("[DEBUG] pillar_create: targeting hostgroup '{}'".format(params.hostgroup))
|
|
path = split_and_validate_path(params.hostgroup)
|
|
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"})
|
|
last = None
|
|
current: Host | None = None
|
|
# Note: both statements need to be present, since '==' will not work for None and 'is' will not work for a UUID
|
|
group_stmt = select(Host).where(and_(Host.is_hostgroup == True, Host.parent_id == bindparam('parent'), Host.name == bindparam('name')))
|
|
group_stmt_none = select(Host).where(and_(Host.is_hostgroup == True, Host.parent_id.is_(None), Host.name == bindparam('name')))
|
|
for label in path:
|
|
result = db.execute(group_stmt if last is not None else group_stmt_none, {'name': label, 'parent': last}).fetchall()
|
|
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}"})
|
|
# Note: this should be enforced by the database
|
|
assert len(result) == 1, f"Result: {[x[0].name for x in result]}"
|
|
current: Host | None = result[0][0]
|
|
last = current.id # type: ignore
|
|
target: Host = current
|
|
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"})
|
|
|
|
# if this is a dictionary value, parse it and create a separate entry for all the sub-pillars
|
|
if type(pillar_data) == dict:
|
|
def aux(prefix: str, input_value: dict) -> list[dict[str, str]]:
|
|
out = []
|
|
for key, value in input_value.items():
|
|
if type(value) is dict:
|
|
out += aux(f"{prefix}:{key}", value)
|
|
else:
|
|
out += [{
|
|
'name': f"{prefix}:{key}",
|
|
'type': type(value).__name__,
|
|
'value': json.dumps(value)
|
|
}]
|
|
|
|
return out
|
|
|
|
pillars_to_store = aux(name, pillar_data)
|
|
print("[DEBUG] pillar_create: dictionary value expanded into {} sub-pillar entries".format(len(pillars_to_store)))
|
|
|
|
else:
|
|
# build the pillar package
|
|
pillars_to_store = [
|
|
{ '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)) # type: ignore
|
|
|
|
# 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')) # type: ignore
|
|
upsert_stmt = insert_stmt.on_conflict_do_update(constraint='pillar_unique_pillar_name', set_={'parameter_type': bindparam('type'), 'value': bindparam('value')} )
|
|
|
|
for instance in pillars_to_store:
|
|
instance['new_id'] = uuid4() # type: ignore
|
|
result = db.execute(upsert_stmt, instance)
|
|
|
|
print("[DEBUG] pillar_create: successfully stored pillar '{}'".format(name))
|
|
return JSONResponse(status_code=200, content={'message': 'ok'})
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/{name}")
|
|
def pillar_delete(req: Request, name: str, params: PillarParams):
|
|
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:
|
|
# 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))
|
|
result = db.execute(target_stmt).fetchall()
|
|
|
|
if len(result) == 0:
|
|
print("[DEBUG] pillar_delete: ERROR - Host '{}' not found in database".format(params.host))
|
|
return JSONResponse(status_code=404, content={})
|
|
|
|
# this should be enforced by the database
|
|
assert len(result) == 1
|
|
target: Host = result[0][0]
|
|
elif params.hostgroup is not None:
|
|
# delete a pillar at the hostgroup level
|
|
print("[DEBUG] pillar_delete: targeting hostgroup '{}'".format(params.hostgroup))
|
|
path = split_and_validate_path(params.hostgroup)
|
|
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"})
|
|
last = None
|
|
current: Host | None = None
|
|
# Note: both statements need to be present, since '==' will not work for None and 'is' will not work for a UUID
|
|
group_stmt = select(Host).where(and_(Host.is_hostgroup == True, Host.parent_id == bindparam('parent'), Host.name == bindparam('name')))
|
|
group_stmt_none = select(Host).where(and_(Host.is_hostgroup == True, Host.parent_id.is_(None), Host.name == bindparam('name')))
|
|
for label in path:
|
|
result = db.execute(group_stmt if last is not None else group_stmt_none, {'name': label, 'parent': last}).fetchall()
|
|
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}"})
|
|
# Note: this should be enforced by the database
|
|
assert len(result) == 1, f"Result: {[x[0].name for x in result]}"
|
|
current: Host | None = result[0][0]
|
|
last = current.id # type: ignore
|
|
target: Host = current
|
|
else:
|
|
print("[DEBUG] pillar_delete: ERROR - Neither host nor hostgroup specified in request parameters.")
|
|
return JSONResponse(status_code=400, content={
|
|
'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)) # type: ignore
|
|
|
|
delete_stmt = delete(Pillar).where(and_(Pillar.host_id == target.id, or_(Pillar.pillar_name == name, Pillar.pillar_name.like(f"{name}:%")))) # type: ignore
|
|
result = db.execute(delete_stmt)
|
|
|
|
print("[DEBUG] pillar_delete: successfully deleted pillar '{}' from target '{}'".format(name, target.name)) # type: ignore
|
|
return JSONResponse(status_code=200, content={'message': 'ok'})
|