diff --git a/pillar_tool.service b/pillar_tool.service new file mode 100644 index 0000000..2283020 --- /dev/null +++ b/pillar_tool.service @@ -0,0 +1,13 @@ +[Unit] +Description=The PillarTool backend Service +Documentation= +After=network.target postgresql.service + +[Service] +Type=simple +User=pillar_tool +Group=pillar_tool +EnvironmentFile=/usr/local/homelab/apps/PillarTool/.venv/bin/activate +WorkingDirectory=/usr/local/homelab/apps/PillarTool +ExecStart=/usr/bin/uv run --project . --module hypercorn pillar_tool.main:app --workers 2 + diff --git a/pillar_tool/ptcli/cli/state.py b/pillar_tool/ptcli/cli/state.py index a1b38ae..f4e8212 100644 --- a/pillar_tool/ptcli/cli/state.py +++ b/pillar_tool/ptcli/cli/state.py @@ -1,3 +1,5 @@ +from multiprocessing.connection import default_family + import click import requests @@ -47,10 +49,14 @@ def state_show(name: str): @state.command("create") @click.argument("name") -def state_create(name: str): +@click.option("--addenv", nargs=1, default=None, required=False, multiple=True) +def state_create(name: str, addenv: list[str] | None): click.echo(f"Creating state '{name}'...") try: - data = StateParams() + data = StateParams( + addenv=addenv, + delenv=None + ) response = requests.post(f'{base_url()}/state/{name}', headers=auth_header(), json=data.model_dump()) response.raise_for_status() @@ -70,5 +76,25 @@ def state_delete(name: str): response.raise_for_status() click.echo("State deleted successfully.") + except requests.exceptions.HTTPError: + raise click.ClickException(f"Failed to delete state:\n{response.text}") + + +@state.command("update") +@click.argument("state") +@click.option("--addenv", nargs=1, default=None, required=False, multiple=True) +@click.option("--delenv", nargs=1, default=None, required=False, multiple=True) +def state_update(state: str, addenv: list[str], delenv: list[str]): + click.echo(f"Updating state '{state}'...") + + try: + params = StateParams( + addenv=list(addenv), + delenv=list(delenv) + ) + response = requests.patch(f'{base_url()}/state/{state}', headers=auth_header(), json=params.model_dump()) + response.raise_for_status() + + click.echo("State updated successfully.") except requests.exceptions.HTTPError as e: - raise click.ClickException(f"Failed to delete state:\n{e}") \ No newline at end of file + raise click.ClickException(f"Failed to update state:\n{e}") \ No newline at end of file diff --git a/pillar_tool/ptcli/cli/top.py b/pillar_tool/ptcli/cli/top.py index 25aab41..2e42d0e 100644 --- a/pillar_tool/ptcli/cli/top.py +++ b/pillar_tool/ptcli/cli/top.py @@ -50,4 +50,19 @@ def top_assign(host: str, state: str): click.echo("Assigned state") except requests.exceptions.HTTPError as e: - raise click.ClickException(f"Failed to assign state:\n{e}") \ No newline at end of file + raise click.ClickException(f"Failed to assign state:\n{e}") + +@top.command("unassign") +@click.argument("host") +@click.argument("state") +def top_assign(host: str, state: str): + click.echo("Assigning state to host...") + + try: + response = requests.delete(f'{base_url()}/top/assign/{host}/{state}', headers=auth_header()) + response.raise_for_status() + + click.echo("Assigned state") + except requests.exceptions.HTTPError as e: + click.echo(f"Failed to assign state:\n{response.text}") + diff --git a/pillar_tool/routers/state.py b/pillar_tool/routers/state.py index 767c165..99c9bba 100644 --- a/pillar_tool/routers/state.py +++ b/pillar_tool/routers/state.py @@ -1,13 +1,15 @@ import uuid -from sqlalchemy import select, insert, delete +from sqlalchemy import select, insert, delete, bindparam, and_ from sqlalchemy.orm import Session from starlette.exceptions import HTTPException from starlette.requests import Request from fastapi import APIRouter from starlette.responses import JSONResponse -from pillar_tool.db.models.top_data import State, StateAssignment +from pillar_tool.db import TopFile, Host +from pillar_tool.db.models.top_data import State, StateAssignment, Environment +from pillar_tool.schemas import StateParams from pillar_tool.util.validation import validate_state_name router = APIRouter( @@ -79,7 +81,7 @@ def state_get(req: Request, name: str): @router.post("/{name}") -def state_create(req: Request, name: str): +def state_create(req: Request, name: str, patch_params: StateParams): """ Create a new state. @@ -110,6 +112,16 @@ def state_create(req: Request, name: str): new_id = uuid.uuid4() db.execute(insert(State).values(id=new_id, name=name)) + stmt_set_env = insert(StateAssignment).values(state_id=new_id, environment_id=bindparam('env_id')) + stmt_get_env_id = select(Environment).where(Environment.name == bindparam('env_name')) + for env in patch_params.addenv: + env_id_res = db.execute(stmt_get_env_id, {'env_name': env}).fetchall() + if len(env_id_res) < 1: + raise HTTPException(status_code=404, detail="No such environment exists") + env_id = env_id_res[0][0].id + + db.execute(stmt_set_env, {'env_id': env_id}) + return JSONResponse(status_code=201, content={ 'id': str(new_id), 'name': name @@ -149,20 +161,38 @@ def state_delete(req: Request, name: str): state: State = result[0][0] - # Check for assigned hosts before deleting + # Check for assigned environments before deleting assignments_stmt = select(StateAssignment).where( StateAssignment.state_id == state.id ) assignments = db.execute(assignments_stmt).fetchall() if len(assignments) > 0: - host_ids_stmt = select(StateAssignment.host_id).where( + env_ids_stmt = select(StateAssignment.environment_id).where( StateAssignment.state_id == state.id ) + env_ids = [row[0] for row in db.execute(env_ids_stmt).fetchall()] + + # Get host names (could optimize this) + envs_stmt = select(Environment).where(Environment.id.in_(env_ids)) + envs: list[Environment] = list(map(lambda x: x[0], db.execute(envs_stmt).fetchall())) + + return JSONResponse(status_code=409, content={ + 'message': "Cannot delete a state that still has environment assignments", + 'assigned_envs': [e.name for e in envs] + }) + + # Check for assigned top files before deleting + top_stmt = select(TopFile).where(TopFile.state_id == state.id) + top = db.execute(top_stmt).fetchall() + + if len(top) > 0: + host_ids_stmt = select(TopFile.host_id).where( + TopFile.state_id == state.id + ) host_ids = [row[0] for row in db.execute(host_ids_stmt).fetchall()] # Get host names (could optimize this) - from pillar_tool.db import Host hosts_stmt = select(Host).where(Host.id.in_(host_ids)) hosts: list[Host] = list(map(lambda x: x[0], db.execute(hosts_stmt).fetchall())) @@ -174,4 +204,39 @@ def state_delete(req: Request, name: str): # Delete the state db.execute(delete(State).where(State.id == state.id)) + return JSONResponse(status_code=204, content={}) + +@router.patch("/{name}") +def state_patch(req: Request, name: str, patch_params: StateParams): + + db: Session = req.state.db + + stmt_state_id = select(State).where(State.name == name) + selected_state_res = db.execute(stmt_state_id).fetchall() + if len(selected_state_res) != 1: + raise HTTPException(status_code=404, detail="No such state exists") + state: State = selected_state_res[0][0] + + # Statement for getting the + stmt_get_env_id = select(Environment).where(Environment.name == bindparam('env_name')) + + # add any requested environments to the state in question + stmt_set_env = insert(StateAssignment).values(state_id=state.id, environment_id=bindparam('env_id')) + for env in patch_params.addenv: + env_id_res = db.execute(stmt_get_env_id, {'env_name': env}).fetchall() + if len(env_id_res) < 1: + raise HTTPException(status_code=404, detail="No such environment exists") + env_id = env_id_res[0][0].id + + db.execute(stmt_set_env, {'env_id': env_id}) + + stmt_del_env = delete(StateAssignment).where(and_(StateAssignment.state_id == state.id, StateAssignment.environment_id == bindparam('env_id'))) + for env in patch_params.delenv: + env_id_res = db.execute(stmt_get_env_id, {'env_name': env}).fetchall() + if len(env_id_res) < 1: + raise HTTPException(status_code=404, detail="No such environment exists") + env_id = env_id_res[0][0].id + + db.execute(stmt_del_env, {'env_id': env_id}) + return JSONResponse(status_code=204, content={}) \ No newline at end of file diff --git a/pillar_tool/routers/top.py b/pillar_tool/routers/top.py index 7df2f64..454ea75 100644 --- a/pillar_tool/routers/top.py +++ b/pillar_tool/routers/top.py @@ -111,8 +111,6 @@ def top_setenv(req: Request, host: str, environment: str): def top_state_assign(req: Request, host_name: str, state_name: str): db: Session = req.state.db - print(f"Assigning {state_name} to {host_name}") - # get the host in question host_stmt = select(Host).where(Host.name == host_name) host_res = db.execute(host_stmt).fetchall() @@ -120,7 +118,6 @@ def top_state_assign(req: Request, host_name: str, state_name: str): return JSONResponse(status_code=404, content={"error": f"Host '{host_name} not found"}) host: Host = host_res[0][0] - print(f"Found host: {host.id}") parent_stmt = select(Host).where(Host.id == bindparam("parent_id")) parents: list[Host] = [] @@ -141,10 +138,8 @@ def top_state_assign(req: Request, host_name: str, state_name: str): env_assign: EnvironmentAssignment | None = None for current_host in parents: env_res = db.execute(env_assign_stmt, {'host_id': current_host.id}).fetchall() - print(f"Looking at host: {current_host.name}") if len(env_res) == 1: env_assign: EnvironmentAssignment = env_res[0][0] - print(f"Found host with assigned environment: {current_host.name} with environment: {env_assign.environment_id}") break env_stmt = select(Environment).where(Environment.id == env_assign.environment_id) @@ -152,23 +147,74 @@ def top_state_assign(req: Request, host_name: str, state_name: str): if len(env_res) != 1: return JSONResponse(status_code=404, content={"error": f"Host '{host_name}' has no environment assigned"}) env: Environment = env_res[0][0] - print(f"Environment found: {env.name if env else 'None'}") # get the state in question state_stmt = (select(State).join(StateAssignment, State.id == StateAssignment.state_id) .where(and_(State.name == state_name, StateAssignment.environment_id == env.id)) ) - print(f"Check 1: {state_stmt}") state_res = db.execute(state_stmt).fetchall() - print("Check 2") if len(state_res) != 1: - print(f"Check 3: State '{state_name}' not found in environment '{env.name}'") return JSONResponse(status_code=404, content={"error": f"No state '{state_name}' found in environment '{env.name}'"}) state: State = state_res[0][0] - print("Check 4") # insert the relation into the database db.execute(insert(TopFile).on_conflict_do_nothing('pillar_tool_top_file_unique_state_host').values(state_id=state.id, host_id=host.id)) - print("Check 5") return JSONResponse(status_code=200, content={}) + + +@router.delete("/assign/{host_name}/{state_name}") +def top_state_unassign(req: Request, host_name: str, state_name: str): + db: Session = req.state.db + + # get the host in question + host_stmt = select(Host).where(Host.name == host_name) + host_res = db.execute(host_stmt).fetchall() + if len(host_res) != 1: + return JSONResponse(status_code=404, content={"error": f"Host '{host_name} not found"}) + + host: Host = host_res[0][0] + + parent_stmt = select(Host).where(Host.id == bindparam("parent_id")) + parents: list[Host] = [] + current: Host = host + while current is not None: + parents.append(current) + if current.parent_id is None: + current = None + else: + parent = db.execute(parent_stmt, {'parent_id': current.parent_id}).fetchall() + if len(parent) == 0: + return JSONResponse(status_code=500, content={"error": f"Host Hierarchy seems broken: parent_id '{current.parent_id}' does not exist"}) + # Note: more than one result is impossible, since the id is a primary key + current: Host = parent[0][0] + + # get the hosts environment + env_assign_stmt = select(EnvironmentAssignment).where(EnvironmentAssignment.host_id == bindparam("host_id")) + env_assign: EnvironmentAssignment | None = None + for current_host in parents: + env_res = db.execute(env_assign_stmt, {'host_id': current_host.id}).fetchall() + if len(env_res) == 1: + env_assign: EnvironmentAssignment = env_res[0][0] + break + + env_stmt = select(Environment).where(Environment.id == env_assign.environment_id) + env_res = db.execute(env_stmt).fetchall() + if len(env_res) != 1: + return JSONResponse(status_code=404, content={"error": f"Host '{host_name}' has no environment assigned"}) + env: Environment = env_res[0][0] + + # get the state in question + state_stmt = (select(State).join(StateAssignment, State.id == StateAssignment.state_id) + .where(and_(State.name == state_name, StateAssignment.environment_id == env.id)) + ) + state_res = db.execute(state_stmt).fetchall() + if len(state_res) != 1: + return JSONResponse(status_code=404, content={"error": f"No state '{state_name}' found in environment '{env.name}'"}) + state: State = state_res[0][0] + + # delete the relation from the database + db.execute(delete(TopFile).where(and_(TopFile.state_id == state.id, TopFile.host_id == host.id))) + + return JSONResponse(status_code=200, content={}) + diff --git a/pillar_tool/schemas.py b/pillar_tool/schemas.py index fe9e31a..4d6ee0c 100644 --- a/pillar_tool/schemas.py +++ b/pillar_tool/schemas.py @@ -48,7 +48,8 @@ class HostgroupParams(BaseModel): # State operations class StateParams(BaseModel): - pass # No parameters needed for state operations currently + addenv: list[str] | None + delenv: list[str] | None # Pillar operations class PillarParams(BaseModel): diff --git a/pillartool.egg-info/PKG-INFO b/pillartool.egg-info/PKG-INFO index 125a0bd..36ec567 100644 --- a/pillartool.egg-info/PKG-INFO +++ b/pillartool.egg-info/PKG-INFO @@ -11,5 +11,7 @@ Requires-Dist: jinja2>=3.1.6 Requires-Dist: psycopg2>=2.9.11 Requires-Dist: pycryptodome>=3.23.0 Requires-Dist: pydantic>=2.12.5 +Requires-Dist: pygit2>=1.19.2 +Requires-Dist: pyyaml>=6.0.3 Requires-Dist: requests>=2.32.5 Requires-Dist: sqlalchemy>=2.0.45 diff --git a/pillartool.egg-info/SOURCES.txt b/pillartool.egg-info/SOURCES.txt index 38f4141..eb04d9c 100644 --- a/pillartool.egg-info/SOURCES.txt +++ b/pillartool.egg-info/SOURCES.txt @@ -18,6 +18,11 @@ pillar_tool/db/migrations/versions/2026_02_08_2034-7eb66922e256_pillars_are_dire pillar_tool/db/migrations/versions/2026_02_08_2051-0a912926be8b_added_environments_and_states_to_the_db_.py pillar_tool/db/migrations/versions/2026_02_08_2220-e33744090598_mark_hosts_as_hostgroups.py pillar_tool/db/migrations/versions/2026_02_10_2147-dd573f631ee4_added_environment_assignments_to_.py +pillar_tool/db/migrations/versions/2026_02_14_2335-a5848dcca950_fixed_errors_in_database_schema.py +pillar_tool/db/migrations/versions/2026_02_14_2337-58c2a8e7c302_import_top_data.py +pillar_tool/db/migrations/versions/2026_02_21_2338-ec7c818f92b5_renamed_bad_parameter.py +pillar_tool/db/migrations/versions/2026_04_25_0004-9ebc4cadee1c_separate_top_file_for_environments_and_.py +pillar_tool/db/migrations/versions/2026_04_25_1133-d3406fae0ecf_remove_id_from_env_assign.py pillar_tool/db/models/__init__.py pillar_tool/db/models/pillar_data.py pillar_tool/db/models/top_data.py @@ -26,6 +31,8 @@ pillar_tool/db/queries/__init__.py pillar_tool/db/queries/auth_queries.py pillar_tool/db/queries/host_queries.py pillar_tool/db/queries/pillar_queries.py +pillar_tool/git/__init__.py +pillar_tool/git/repository.py pillar_tool/middleware/__init__.py pillar_tool/middleware/basicauth_backend.py pillar_tool/middleware/db_connection.py @@ -40,14 +47,18 @@ pillar_tool/ptcli/cli/hostgroup.py pillar_tool/ptcli/cli/pillar.py pillar_tool/ptcli/cli/query.py pillar_tool/ptcli/cli/state.py +pillar_tool/ptcli/cli/top.py pillar_tool/routers/__init__.py pillar_tool/routers/environment.py pillar_tool/routers/host.py pillar_tool/routers/hostgroup.py pillar_tool/routers/pillar.py pillar_tool/routers/state.py +pillar_tool/routers/top.py pillar_tool/util/__init__.py pillar_tool/util/config.py +pillar_tool/util/files.py +pillar_tool/util/pillar_utilities.py pillar_tool/util/validation.py pillartool.egg-info/PKG-INFO pillartool.egg-info/SOURCES.txt diff --git a/pillartool.egg-info/requires.txt b/pillartool.egg-info/requires.txt index 6d088d8..cb52c58 100644 --- a/pillartool.egg-info/requires.txt +++ b/pillartool.egg-info/requires.txt @@ -6,5 +6,7 @@ jinja2>=3.1.6 psycopg2>=2.9.11 pycryptodome>=3.23.0 pydantic>=2.12.5 +pygit2>=1.19.2 +pyyaml>=6.0.3 requests>=2.32.5 sqlalchemy>=2.0.45