implemented environment endpoints
This commit is contained in:
parent
855302de1f
commit
02437e9c08
@ -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 ###
|
||||||
@ -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 ###
|
||||||
@ -1,2 +1,3 @@
|
|||||||
from .user import *
|
from .user import *
|
||||||
from .pillar_data import *
|
from .pillar_data import *
|
||||||
|
from .top_data import *
|
||||||
@ -11,18 +11,18 @@ class Environment(Base):
|
|||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint('name', name="pillar_tool_unique_environment_unique_name")
|
UniqueConstraint('name', name="pillar_tool_unique_environment_unique_name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class EnvironmentAssignment(Base):
|
class EnvironmentAssignment(Base):
|
||||||
__tablename__ = "pillar_tool_environment_assignment"
|
__tablename__ = "pillar_tool_environment_assignment"
|
||||||
|
|
||||||
environment_id = Column(UUID, ForeignKey("pillar_tool_environment.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)
|
host_id = Column(UUID, ForeignKey("pillar_tool_host.id"), nullable=False, primary_key=True)
|
||||||
|
|
||||||
__table_args__ = (
|
__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)
|
name = Column(String, nullable=False)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint('name', name="pillar_tool_unique_state_unique_name")
|
UniqueConstraint('name', name="pillar_tool_unique_state_unique_name"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,7 @@ from pillar_tool.db.database import run_db_migrations
|
|||||||
# import all the routers
|
# import all the routers
|
||||||
from pillar_tool.routers.host import router as host_router
|
from pillar_tool.routers.host import router as host_router
|
||||||
from pillar_tool.routers.hostgroup import router as hostgroup_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 any pending migrations
|
||||||
run_db_migrations()
|
run_db_migrations()
|
||||||
@ -70,6 +71,7 @@ app.exception_handler(Exception)(on_general_error)
|
|||||||
# Setup the api router
|
# Setup the api router
|
||||||
app.include_router(host_router)
|
app.include_router(host_router)
|
||||||
app.include_router(hostgroup_router)
|
app.include_router(hostgroup_router)
|
||||||
|
app.include_router(environment_router)
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
|
|||||||
@ -2,8 +2,95 @@ import click
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from .cli_main import main, auth_header, base_url
|
from .cli_main import main, auth_header, base_url
|
||||||
|
from pillar_tool.util.validation import split_and_validate_path
|
||||||
|
|
||||||
|
|
||||||
@main.group("environment")
|
@main.group("environment")
|
||||||
def 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}")
|
||||||
@ -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={})
|
||||||
|
|
||||||
@ -4,6 +4,26 @@ import re
|
|||||||
PATH_REGEX = re.compile(r'^[a-zA-Z_-][a-zA-Z0-9_-]*$')
|
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,}$')
|
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:
|
def validate_fqdn(fqdn: str) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user