implemented environment endpoints

This commit is contained in:
Linus Vogel 2026-02-14 23:39:01 +01:00
parent 855302de1f
commit 02437e9c08
8 changed files with 393 additions and 7 deletions

View File

@ -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 ###

View File

@ -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 ###

View File

@ -1,2 +1,3 @@
from .user import *
from .pillar_data import *
from .pillar_data import *
from .top_data import *

View File

@ -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"),
)

View File

@ -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():

View File

@ -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
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}")

View File

@ -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={})

View File

@ -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:
"""