diff --git a/pillar_tool/db/migrations/versions/2026_02_14_2335-a5848dcca950_fixed_errors_in_database_schema.py b/pillar_tool/db/migrations/versions/2026_02_14_2335-a5848dcca950_fixed_errors_in_database_schema.py new file mode 100644 index 0000000..f25a9f8 --- /dev/null +++ b/pillar_tool/db/migrations/versions/2026_02_14_2335-a5848dcca950_fixed_errors_in_database_schema.py @@ -0,0 +1,32 @@ +"""fixed errors in database schema + +Revision ID: a5848dcca950 +Revises: dd573f631ee4 +Create Date: 2026-02-14 23:35:07.882896 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a5848dcca950' +down_revision: Union[str, Sequence[str], None] = 'dd573f631ee4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/pillar_tool/db/migrations/versions/2026_02_14_2337-58c2a8e7c302_import_top_data.py b/pillar_tool/db/migrations/versions/2026_02_14_2337-58c2a8e7c302_import_top_data.py new file mode 100644 index 0000000..f55ef4f --- /dev/null +++ b/pillar_tool/db/migrations/versions/2026_02_14_2337-58c2a8e7c302_import_top_data.py @@ -0,0 +1,65 @@ +"""import top_data + +Revision ID: 58c2a8e7c302 +Revises: a5848dcca950 +Create Date: 2026-02-14 23:37:42.194003 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '58c2a8e7c302' +down_revision: Union[str, Sequence[str], None] = 'a5848dcca950' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('pillar_tool_environment', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name', name='pillar_tool_unique_environment_unique_name') + ) + op.create_table('pillar_tool_state', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name', name='pillar_tool_unique_state_unique_name') + ) + op.create_table('pillar_tool_environment_assignment', + sa.Column('environment_id', sa.UUID(), nullable=False), + sa.Column('host_id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['environment_id'], ['pillar_tool_environment.id'], ), + sa.ForeignKeyConstraint(['host_id'], ['pillar_tool_host.id'], ), + sa.PrimaryKeyConstraint('environment_id', 'host_id'), + sa.UniqueConstraint('environment_id', 'host_id', name='pillar_tool_unique_environment_assignment') + ) + op.create_table('pillar_tool_state_assignment', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('environment_id', sa.UUID(), nullable=False), + sa.Column('state_id', sa.UUID(), nullable=False), + sa.Column('host_id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['environment_id'], ['pillar_tool_environment.id'], ), + sa.ForeignKeyConstraint(['host_id'], ['pillar_tool_host.id'], ), + sa.ForeignKeyConstraint(['state_id'], ['pillar_tool_state.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('environment_id', 'state_id', 'host_id', name='pillar_tool_state_assignment_unique_env_state_host') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('pillar_tool_state_assignment') + op.drop_table('pillar_tool_environment_assignment') + op.drop_table('pillar_tool_state') + op.drop_table('pillar_tool_environment') + # ### end Alembic commands ### diff --git a/pillar_tool/db/models/__init__.py b/pillar_tool/db/models/__init__.py index 95ad4eb..68112e7 100644 --- a/pillar_tool/db/models/__init__.py +++ b/pillar_tool/db/models/__init__.py @@ -1,2 +1,3 @@ from .user import * -from .pillar_data import * \ No newline at end of file +from .pillar_data import * +from .top_data import * \ No newline at end of file diff --git a/pillar_tool/db/models/top_data.py b/pillar_tool/db/models/top_data.py index 88174b6..d9d67cf 100644 --- a/pillar_tool/db/models/top_data.py +++ b/pillar_tool/db/models/top_data.py @@ -11,18 +11,18 @@ class Environment(Base): name = Column(String, nullable=False) __table_args__ = ( - UniqueConstraint('name', name="pillar_tool_unique_environment_unique_name") + UniqueConstraint('name', name="pillar_tool_unique_environment_unique_name"), ) class EnvironmentAssignment(Base): __tablename__ = "pillar_tool_environment_assignment" - environment_id = Column(UUID, ForeignKey("pillar_tool_environment.id"), nullable=False) - host_id = Column(UUID, ForeignKey("pillar_tool_host.id"), nullable=False) + environment_id = Column(UUID, ForeignKey("pillar_tool_environment.id"), nullable=False, primary_key=True) + host_id = Column(UUID, ForeignKey("pillar_tool_host.id"), nullable=False, primary_key=True) __table_args__ = ( - UniqueConstraint('environment_id', 'host_id', name="pillar_tool_unique_environment_assignment") + UniqueConstraint('environment_id', 'host_id', name="pillar_tool_unique_environment_assignment"), ) @@ -33,7 +33,7 @@ class State(Base): name = Column(String, nullable=False) __table_args__ = ( - UniqueConstraint('name', name="pillar_tool_unique_state_unique_name") + UniqueConstraint('name', name="pillar_tool_unique_state_unique_name"), ) diff --git a/pillar_tool/main.py b/pillar_tool/main.py index f12b4f7..2cbd018 100644 --- a/pillar_tool/main.py +++ b/pillar_tool/main.py @@ -25,6 +25,7 @@ from pillar_tool.db.database import run_db_migrations # import all the routers from pillar_tool.routers.host import router as host_router from pillar_tool.routers.hostgroup import router as hostgroup_router +from pillar_tool.routers.environment import router as environment_router # run any pending migrations run_db_migrations() @@ -70,6 +71,7 @@ app.exception_handler(Exception)(on_general_error) # Setup the api router app.include_router(host_router) app.include_router(hostgroup_router) +app.include_router(environment_router) @app.get("/") async def root(): diff --git a/pillar_tool/ptcli/cli/environment.py b/pillar_tool/ptcli/cli/environment.py index f839b60..b3f0c97 100644 --- a/pillar_tool/ptcli/cli/environment.py +++ b/pillar_tool/ptcli/cli/environment.py @@ -2,8 +2,95 @@ import click import requests from .cli_main import main, auth_header, base_url +from pillar_tool.util.validation import split_and_validate_path @main.group("environment") def environment(): - pass \ No newline at end of file + pass + + +@environment.command("list") +def environment_list(): + """List all environments.""" + click.echo("Listing known environments...") + try: + response = requests.get(f'{base_url()}/environment', headers=auth_header()) + response.raise_for_status() + + click.echo("Environments:") + for env in response.json(): + click.echo(f" - {env}") + except requests.exceptions.HTTPError as e: + raise click.ClickException(f"Failed to list environments:\n{e}") + + +@environment.command("show") +@click.argument("name") +def environment_show(name: str): + """Show details of a specific environment by name.""" + click.echo(f"Showing environment '{name}'...") + try: + # Validate name format before making request + if not split_and_validate_path.__module__ or True: # Using environment-specific validation + from pillar_tool.util.validation import validate_environment_name + if not validate_environment_name(name): + raise click.ClickException( + "Invalid environment name. Use only alphanumeric, underscore or dash characters.") + + response = requests.get(f'{base_url()}/environment/{name}', headers=auth_header()) + response.raise_for_status() + + env_data = response.json() + click.echo(f"Environment: {env_data['environment']}") + click.echo(f"Hosts assigned: {env_data['host_count']}") + except requests.exceptions.HTTPError as e: + raise click.ClickException(f"Failed to show environment:\n{e}") + + +@environment.command("create") +@click.argument("name") +def environment_create(name: str): + """Create a new environment.""" + click.echo(f"Creating environment '{name}'...") + try: + from pillar_tool.util.validation import validate_environment_name + if not validate_environment_name(name): + raise click.ClickException( + "Invalid environment name. Use only alphanumeric, underscore or dash characters.") + + # No body data needed for environment creation + response = requests.post(f'{base_url()}/environment/{name}', headers=auth_header()) + response.raise_for_status() + + click.echo(f"Environment '{name}' created successfully.") + except requests.exceptions.HTTPError as e: + raise click.ClickException(f"Failed to create environment:\n{e}") + + +@environment.command("delete") +@click.argument("name") +def environment_delete(name: str): + """Delete an environment by name.""" + click.echo(f"Deleting environment '{name}'...") + try: + from pillar_tool.util.validation import validate_environment_name + if not validate_environment_name(name): + raise click.ClickException( + "Invalid environment name. Use only alphanumeric, underscore or dash characters.") + + response = requests.delete(f'{base_url()}/environment/{name}', headers=auth_header()) + response.raise_for_status() + + click.echo(f"Environment '{name}' deleted successfully.") + except requests.exceptions.HTTPError as e: + if e.response is not None and e.response.status_code == 409: + conflict_data = e.response.json() if e.response.content else {} + hosts_list = conflict_data.get('assigned_hosts', []) + raise click.ClickException( + f"Failed to delete environment:\n" + f"{conflict_data.get('message', 'Environment has assigned hosts')}\n" + f"Assigned hosts: {', '.join(hosts_list) if hosts_list else 'none'}" + ) + else: + raise click.ClickException(f"Failed to delete environment:\n{e}") \ No newline at end of file diff --git a/pillar_tool/routers/environment.py b/pillar_tool/routers/environment.py index e69de29..9825b36 100644 --- a/pillar_tool/routers/environment.py +++ b/pillar_tool/routers/environment.py @@ -0,0 +1,179 @@ +import uuid + + +from sqlalchemy import select, insert, bindparam, delete +from sqlalchemy.orm import Session +from starlette.exceptions import HTTPException +from starlette.requests import Request +from fastapi import APIRouter, Query, Depends +from starlette.responses import JSONResponse + +from pillar_tool.db import Host +from pillar_tool.db.models.top_data import Environment, EnvironmentAssignment +from pillar_tool.schemas import HostgroupParams, get_hostgroup_params_from_query, get_model_from_query +from pillar_tool.util.validation import split_and_validate_path, validate_environment_name + +router = APIRouter( + prefix="/environment", + tags=["environment"], +) + + +@router.get("") +def environments_get(req: Request): + """ + Retrieve all environments. + + Fetches and returns a list of environment names from the database. + + Returns: + JSONResponse: A JSON response with status code 200 containing a list of environment names (strings). + """ + db: Session = req.state.db + + result = db.execute(select(Environment)).fetchall() + environments: list[Environment] = list(map(lambda x: x[0], result)) + + return JSONResponse(status_code=200, content=[env.name for env in environments]) + + +@router.get("/{name}") +def environment_get(req: Request, name: str): + """ + Retrieve a specific environment by name. + + Fetches and returns details of the specified environment. + Returns 404 if no such environment exists. + + Args: + req (Request): The incoming request object. + name (str): The name of the environment to retrieve. + + Returns: + JSONResponse: A JSON response with status code 200 and the environment details on success, + or 404 if not found. + """ + db: Session = req.state.db + + # Validate name before query + if not validate_environment_name(name): + raise HTTPException(status_code=400, detail="Invalid environment name format") + + stmt = select(Environment).where(Environment.name == name) + result = db.execute(stmt).fetchall() + + if len(result) == 0: + raise HTTPException(status_code=404, detail="No such environment exists") + + assert len(result) == 1 + + env: Environment = result[0][0] + + # Get assigned hosts count as an example of additional info + hosts_stmt = select(Host).join(EnvironmentAssignment, Host.id == EnvironmentAssignment.host_id)\ + .where(EnvironmentAssignment.environment_id == env.id) + hosts_count = db.execute(hosts_stmt).fetchall().__len__() + + return JSONResponse(status_code=200, content={ + 'environment': env.name, + 'host_count': hosts_count + }) + + +@router.post("/{name}") +def environment_create(req: Request, name: str): + """ + Create a new environment. + + Creates a new environment record in the database with the provided parameters. + + Args: + req (Request): The incoming request object. + name (str): The name of the environment (must be unique). + + Returns: + JSONResponse: A JSON response with status code 201 on success, + or appropriate error codes (e.g., 409 if already exists, 400 for invalid format). + """ + db = req.state.db + + # Validate name format + if not validate_environment_name(name): + raise HTTPException(status_code=400, + detail="Invalid environment name. Use only alphanumeric, underscore or dash characters.") + + # Check if environment already exists + stmt_check = select(Environment).where(Environment.name == name) + existing = db.execute(stmt_check).fetchall() + + if len(existing) > 0: + raise HTTPException(status_code=409, detail="Environment already exists") + + new_id = uuid.uuid4() + db.execute(insert(Environment).values(id=new_id, name=name)) + + return JSONResponse(status_code=201, content={ + 'id': str(new_id), + 'name': name + }) + + +@router.delete("/{name}") +def environment_delete(req: Request, name: str): + """ + Delete an environment by name. + + Deletes the specified environment from the database. + Returns 409 if hosts are still assigned to this environment. + Returns 404 if no such environment exists. + + Args: + req (Request): The incoming request object. + name (str): The name of the environment to delete. + + Returns: + JSONResponse: A JSON response with status code 204 on successful deletion, + or appropriate error codes for conflicts or not found. + """ + db = req.state.db + + # Validate name format + if not validate_environment_name(name): + raise HTTPException(status_code=400, detail="Invalid environment name format") + + stmt = select(Environment).where(Environment.name == name) + result = db.execute(stmt).fetchall() + + if len(result) == 0: + raise HTTPException(status_code=404, detail="No such environment exists") + + assert len(result) == 1 + + env: Environment = result[0][0] + + # Check for assigned hosts before deleting + assignments_stmt = select(EnvironmentAssignment).where( + EnvironmentAssignment.environment_id == env.id + ) + assignments = db.execute(assignments_stmt).fetchall() + + if len(assignments) > 0: + host_ids_stmt = select(EnvironmentAssignment.host_id).where( + EnvironmentAssignment.environment_id == env.id + ) + host_ids = [row[0] for row in db.execute(host_ids_stmt).fetchall()] + + # Get host names (could optimize this) + hosts_stmt = select(Host).where(Host.id.in_(host_ids)) + hosts: list[Host] = list(map(lambda x: x[0], db.execute(hosts_stmt).fetchall())) + + return JSONResponse(status_code=409, content={ + 'message': "Cannot delete an environment that still has hosts assigned", + 'assigned_hosts': [h.name for h in hosts] + }) + + # Delete the environment + db.execute(delete(Environment).where(Environment.id == env.id)) + + return JSONResponse(status_code=204, content={}) + diff --git a/pillar_tool/util/validation.py b/pillar_tool/util/validation.py index a152351..af5e4cb 100644 --- a/pillar_tool/util/validation.py +++ b/pillar_tool/util/validation.py @@ -4,6 +4,26 @@ import re PATH_REGEX = re.compile(r'^[a-zA-Z_-][a-zA-Z0-9_-]*$') FQDN_REGEX = re.compile(r'^([a-zA-Z0-9.-]+\.)+[a-zA-Z]{2,}$') +ENV_NAME_REGEX = re.compile(r'^[a-zA-Z0-9_-]+$') + + +# TODO: improve doc comment for this function +def validate_environment_name(name: str) -> bool: + """ + Validates an environment name. + + Args: + name: The environment name to validate (e.g., "production", "dev_env") + + Returns: + True if the name contains only alphanumeric characters, underscores, or dashes. + False otherwise. + + Note: + Environment names cannot be empty and must match the pattern [a-zA-Z0-9_-]+ + """ + return bool(ENV_NAME_REGEX.match(name)) + def validate_fqdn(fqdn: str) -> bool: """