Add ENV and FILE management APIs with tests
Features:
- POST /api/v1/agents/{agent_id}/env/inspect - Create ENV_INSPECT tasks
- POST /api/v1/agents/{agent_id}/env/update - Create ENV_UPDATE tasks
- POST /api/v1/agents/{agent_id}/files/inspect - Create FILE_INSPECT tasks
Changes:
- Add EnvInspectRequest, EnvUpdateRequest, FileInspectRequest schemas
- Add env and files route modules
- Fix JSONB to use JSON variant for SQLite test compatibility
- Add pytest, pytest-asyncio, aiosqlite for testing
- Add tests for all new endpoints (17 tests passing)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
dd8a53e657
commit
f40c5fcc69
|
|
@ -13,6 +13,8 @@ from app.config import settings
|
||||||
from app.db import engine
|
from app.db import engine
|
||||||
from app.routes import (
|
from app.routes import (
|
||||||
agents_router,
|
agents_router,
|
||||||
|
env_router,
|
||||||
|
files_router,
|
||||||
health_router,
|
health_router,
|
||||||
playbooks_router,
|
playbooks_router,
|
||||||
tasks_router,
|
tasks_router,
|
||||||
|
|
@ -83,6 +85,8 @@ app.include_router(tenants_router, prefix="/api/v1")
|
||||||
app.include_router(tasks_router, prefix="/api/v1")
|
app.include_router(tasks_router, prefix="/api/v1")
|
||||||
app.include_router(agents_router, prefix="/api/v1")
|
app.include_router(agents_router, prefix="/api/v1")
|
||||||
app.include_router(playbooks_router, prefix="/api/v1")
|
app.include_router(playbooks_router, prefix="/api/v1")
|
||||||
|
app.include_router(env_router, prefix="/api/v1")
|
||||||
|
app.include_router(files_router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
# --- Root endpoint ---
|
# --- Root endpoint ---
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,13 @@ import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, String
|
from sqlalchemy import DateTime, ForeignKey, JSON, String
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
# Use JSONB on PostgreSQL, JSON on other databases (SQLite for tests)
|
||||||
|
JSONType = JSON().with_variant(JSONB, "postgresql")
|
||||||
|
|
||||||
from app.models.base import Base, UUIDMixin
|
from app.models.base import Base, UUIDMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -46,7 +49,7 @@ class Event(UUIDMixin, Base):
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
payload: Mapped[dict[str, Any]] = mapped_column(
|
payload: Mapped[dict[str, Any]] = mapped_column(
|
||||||
JSONB,
|
JSONType,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=dict,
|
default=dict,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,13 @@ import uuid
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, String
|
from sqlalchemy import ForeignKey, JSON, String
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
# Use JSONB on PostgreSQL, JSON on other databases (SQLite for tests)
|
||||||
|
JSONType = JSON().with_variant(JSONB, "postgresql")
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -51,7 +54,7 @@ class Task(UUIDMixin, TimestampMixin, Base):
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
payload: Mapped[dict[str, Any]] = mapped_column(
|
payload: Mapped[dict[str, Any]] = mapped_column(
|
||||||
JSONB,
|
JSONType,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=dict,
|
default=dict,
|
||||||
)
|
)
|
||||||
|
|
@ -62,7 +65,7 @@ class Task(UUIDMixin, TimestampMixin, Base):
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
result: Mapped[dict[str, Any] | None] = mapped_column(
|
result: Mapped[dict[str, Any] | None] = mapped_column(
|
||||||
JSONB,
|
JSONType,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ from app.routes.tasks import router as tasks_router
|
||||||
from app.routes.tenants import router as tenants_router
|
from app.routes.tenants import router as tenants_router
|
||||||
from app.routes.agents import router as agents_router
|
from app.routes.agents import router as agents_router
|
||||||
from app.routes.playbooks import router as playbooks_router
|
from app.routes.playbooks import router as playbooks_router
|
||||||
|
from app.routes.env import router as env_router
|
||||||
|
from app.routes.files import router as files_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"health_router",
|
"health_router",
|
||||||
|
|
@ -12,4 +14,6 @@ __all__ = [
|
||||||
"tasks_router",
|
"tasks_router",
|
||||||
"agents_router",
|
"agents_router",
|
||||||
"playbooks_router",
|
"playbooks_router",
|
||||||
|
"env_router",
|
||||||
|
"files_router",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
"""Env management endpoints for creating ENV_INSPECT and ENV_UPDATE tasks."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import AsyncSessionDep
|
||||||
|
from app.models.agent import Agent
|
||||||
|
from app.models.task import Task, TaskStatus
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.schemas.env import EnvInspectRequest, EnvUpdateRequest
|
||||||
|
from app.schemas.task import TaskResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/agents/{agent_id}/env", tags=["Env Management"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helper functions ---
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tenant_by_id(db: AsyncSessionDep, tenant_id: uuid.UUID) -> Tenant | None:
|
||||||
|
"""Retrieve a tenant by ID."""
|
||||||
|
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_agent_by_id(db: AsyncSessionDep, agent_id: uuid.UUID) -> Agent | None:
|
||||||
|
"""Retrieve an agent by ID."""
|
||||||
|
result = await db.execute(select(Agent).where(Agent.id == agent_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Route handlers ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/inspect",
|
||||||
|
response_model=TaskResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def inspect_env(
|
||||||
|
agent_id: uuid.UUID,
|
||||||
|
request: EnvInspectRequest,
|
||||||
|
db: AsyncSessionDep,
|
||||||
|
) -> Task:
|
||||||
|
"""
|
||||||
|
Create an ENV_INSPECT task to read env file contents.
|
||||||
|
|
||||||
|
The SysAdmin Agent will execute this task and return the env file
|
||||||
|
key-value pairs in the task result.
|
||||||
|
|
||||||
|
## Request Body
|
||||||
|
- **tenant_id**: UUID of the tenant
|
||||||
|
- **path**: Path to the env file (e.g., `/opt/letsbe/env/chatwoot.env`)
|
||||||
|
- **keys**: Optional list of specific keys to inspect (returns all if omitted)
|
||||||
|
|
||||||
|
## Response
|
||||||
|
Returns the created Task with type="ENV_INSPECT" and status="pending".
|
||||||
|
"""
|
||||||
|
# Validate tenant exists
|
||||||
|
tenant = await get_tenant_by_id(db, request.tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tenant {request.tenant_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate agent exists
|
||||||
|
agent = await get_agent_by_id(db, agent_id)
|
||||||
|
if agent is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Agent {agent_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build payload
|
||||||
|
payload: dict = {"path": request.path}
|
||||||
|
if request.keys is not None:
|
||||||
|
payload["keys"] = request.keys
|
||||||
|
|
||||||
|
# Create the task
|
||||||
|
task = Task(
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
type="ENV_INSPECT",
|
||||||
|
payload=payload,
|
||||||
|
status=TaskStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(task)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(task)
|
||||||
|
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/update",
|
||||||
|
response_model=TaskResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def update_env(
|
||||||
|
agent_id: uuid.UUID,
|
||||||
|
request: EnvUpdateRequest,
|
||||||
|
db: AsyncSessionDep,
|
||||||
|
) -> Task:
|
||||||
|
"""
|
||||||
|
Create an ENV_UPDATE task to modify env file contents.
|
||||||
|
|
||||||
|
The SysAdmin Agent will execute this task to update or remove
|
||||||
|
key-value pairs in the specified env file.
|
||||||
|
|
||||||
|
## Request Body
|
||||||
|
- **tenant_id**: UUID of the tenant
|
||||||
|
- **path**: Path to the env file (e.g., `/opt/letsbe/env/chatwoot.env`)
|
||||||
|
- **updates**: Optional dict of key-value pairs to set or update
|
||||||
|
- **remove_keys**: Optional list of keys to remove from the env file
|
||||||
|
|
||||||
|
## Response
|
||||||
|
Returns the created Task with type="ENV_UPDATE" and status="pending".
|
||||||
|
"""
|
||||||
|
# Validate tenant exists
|
||||||
|
tenant = await get_tenant_by_id(db, request.tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tenant {request.tenant_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate agent exists
|
||||||
|
agent = await get_agent_by_id(db, agent_id)
|
||||||
|
if agent is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Agent {agent_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build payload
|
||||||
|
payload: dict = {"path": request.path}
|
||||||
|
if request.updates is not None:
|
||||||
|
payload["updates"] = request.updates
|
||||||
|
if request.remove_keys is not None:
|
||||||
|
payload["remove_keys"] = request.remove_keys
|
||||||
|
|
||||||
|
# Create the task
|
||||||
|
task = Task(
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
type="ENV_UPDATE",
|
||||||
|
payload=payload,
|
||||||
|
status=TaskStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(task)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(task)
|
||||||
|
|
||||||
|
return task
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""File management endpoints for creating FILE_INSPECT tasks."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db import AsyncSessionDep
|
||||||
|
from app.models.agent import Agent
|
||||||
|
from app.models.task import Task, TaskStatus
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.schemas.file import FileInspectRequest
|
||||||
|
from app.schemas.task import TaskResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/agents/{agent_id}/files", tags=["File Management"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Helper functions ---
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tenant_by_id(db: AsyncSessionDep, tenant_id: uuid.UUID) -> Tenant | None:
|
||||||
|
"""Retrieve a tenant by ID."""
|
||||||
|
result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_agent_by_id(db: AsyncSessionDep, agent_id: uuid.UUID) -> Agent | None:
|
||||||
|
"""Retrieve an agent by ID."""
|
||||||
|
result = await db.execute(select(Agent).where(Agent.id == agent_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Route handlers ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/inspect",
|
||||||
|
response_model=TaskResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def inspect_file(
|
||||||
|
agent_id: uuid.UUID,
|
||||||
|
request: FileInspectRequest,
|
||||||
|
db: AsyncSessionDep,
|
||||||
|
) -> Task:
|
||||||
|
"""
|
||||||
|
Create a FILE_INSPECT task to read file contents.
|
||||||
|
|
||||||
|
The SysAdmin Agent will execute this task and return the file
|
||||||
|
contents (up to max_bytes) in the task result.
|
||||||
|
|
||||||
|
## Request Body
|
||||||
|
- **tenant_id**: UUID of the tenant
|
||||||
|
- **path**: Absolute path to the file to inspect
|
||||||
|
- **max_bytes**: Optional max bytes to read (default 4096, max 1MB)
|
||||||
|
|
||||||
|
## Response
|
||||||
|
Returns the created Task with type="FILE_INSPECT" and status="pending".
|
||||||
|
"""
|
||||||
|
# Validate tenant exists
|
||||||
|
tenant = await get_tenant_by_id(db, request.tenant_id)
|
||||||
|
if tenant is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Tenant {request.tenant_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate agent exists
|
||||||
|
agent = await get_agent_by_id(db, agent_id)
|
||||||
|
if agent is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Agent {agent_id} not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build payload
|
||||||
|
payload: dict = {"path": request.path}
|
||||||
|
if request.max_bytes is not None:
|
||||||
|
payload["max_bytes"] = request.max_bytes
|
||||||
|
|
||||||
|
# Create the task
|
||||||
|
task = Task(
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
type="FILE_INSPECT",
|
||||||
|
payload=payload,
|
||||||
|
status=TaskStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(task)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(task)
|
||||||
|
|
||||||
|
return task
|
||||||
|
|
@ -20,6 +20,11 @@ from app.schemas.tasks_extended import (
|
||||||
CompositeSubTask,
|
CompositeSubTask,
|
||||||
CompositePayload,
|
CompositePayload,
|
||||||
)
|
)
|
||||||
|
from app.schemas.env import (
|
||||||
|
EnvInspectRequest,
|
||||||
|
EnvUpdateRequest,
|
||||||
|
)
|
||||||
|
from app.schemas.file import FileInspectRequest
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Common
|
# Common
|
||||||
|
|
@ -42,4 +47,9 @@ __all__ = [
|
||||||
"AgentRegisterResponse",
|
"AgentRegisterResponse",
|
||||||
"AgentHeartbeatResponse",
|
"AgentHeartbeatResponse",
|
||||||
"AgentResponse",
|
"AgentResponse",
|
||||||
|
# Env Management
|
||||||
|
"EnvInspectRequest",
|
||||||
|
"EnvUpdateRequest",
|
||||||
|
# File Management
|
||||||
|
"FileInspectRequest",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Pydantic schemas for env management endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class EnvInspectRequest(BaseModel):
|
||||||
|
"""Request body for env inspect endpoint."""
|
||||||
|
|
||||||
|
tenant_id: uuid.UUID = Field(..., description="UUID of the tenant")
|
||||||
|
path: str = Field(
|
||||||
|
..., min_length=1, description="Path to .env file (e.g., /opt/letsbe/env/chatwoot.env)"
|
||||||
|
)
|
||||||
|
keys: list[str] | None = Field(
|
||||||
|
None, description="Optional list of specific keys to inspect"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EnvUpdateRequest(BaseModel):
|
||||||
|
"""Request body for env update endpoint."""
|
||||||
|
|
||||||
|
tenant_id: uuid.UUID = Field(..., description="UUID of the tenant")
|
||||||
|
path: str = Field(
|
||||||
|
..., min_length=1, description="Path to .env file (e.g., /opt/letsbe/env/chatwoot.env)"
|
||||||
|
)
|
||||||
|
updates: dict[str, str] | None = Field(
|
||||||
|
None, description="Key-value pairs to set or update"
|
||||||
|
)
|
||||||
|
remove_keys: list[str] | None = Field(
|
||||||
|
None, description="Keys to remove from the env file"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
"""Pydantic schemas for file management endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class FileInspectRequest(BaseModel):
|
||||||
|
"""Request body for FILE_INSPECT tasks."""
|
||||||
|
|
||||||
|
tenant_id: uuid.UUID = Field(..., description="UUID of the tenant")
|
||||||
|
path: str = Field(
|
||||||
|
..., min_length=1, description="Absolute path to the file to inspect"
|
||||||
|
)
|
||||||
|
max_bytes: int | None = Field(
|
||||||
|
4096,
|
||||||
|
ge=1,
|
||||||
|
le=1048576,
|
||||||
|
description="Max bytes to read from file (default 4096, max 1MB)",
|
||||||
|
)
|
||||||
|
|
@ -31,6 +31,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/app/app
|
- ./app:/app/app
|
||||||
- ./alembic:/app/alembic
|
- ./alembic:/app/alembic
|
||||||
|
- ./tests:/app/tests
|
||||||
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,8 @@ pydantic-settings>=2.1.0
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest>=8.0.0
|
||||||
|
pytest-asyncio>=0.23.0
|
||||||
|
aiosqlite>=0.19.0
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
||||||
|
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
|
from app.models.agent import Agent
|
||||||
|
|
||||||
# Use in-memory SQLite for testing
|
# Use in-memory SQLite for testing
|
||||||
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
@ -55,9 +56,24 @@ async def test_tenant(db: AsyncSession) -> Tenant:
|
||||||
tenant = Tenant(
|
tenant = Tenant(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
name="Test Tenant",
|
name="Test Tenant",
|
||||||
slug="test-tenant",
|
|
||||||
)
|
)
|
||||||
db.add(tenant)
|
db.add(tenant)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(tenant)
|
await db.refresh(tenant)
|
||||||
return tenant
|
return tenant
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="function")
|
||||||
|
async def test_agent(db: AsyncSession) -> Agent:
|
||||||
|
"""Create a test agent."""
|
||||||
|
agent = Agent(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
name="test-agent-host",
|
||||||
|
version="1.0.0",
|
||||||
|
status="online",
|
||||||
|
token="test-token-12345",
|
||||||
|
)
|
||||||
|
db.add(agent)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(agent)
|
||||||
|
return agent
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for route modules."""
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
"""Tests for env management routes."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.agent import Agent
|
||||||
|
from app.models.task import Task
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.routes.env import inspect_env, update_env
|
||||||
|
from app.schemas.env import EnvInspectRequest, EnvUpdateRequest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestInspectEnv:
|
||||||
|
"""Tests for the inspect_env endpoint."""
|
||||||
|
|
||||||
|
async def test_happy_path(
|
||||||
|
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||||
|
):
|
||||||
|
"""Valid request creates an ENV_INSPECT task."""
|
||||||
|
request = EnvInspectRequest(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
path="/opt/letsbe/env/chatwoot.env",
|
||||||
|
keys=["FRONTEND_URL", "BACKEND_URL"],
|
||||||
|
)
|
||||||
|
|
||||||
|
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||||
|
|
||||||
|
assert task.id is not None
|
||||||
|
assert task.tenant_id == test_tenant.id
|
||||||
|
assert task.agent_id == test_agent.id
|
||||||
|
assert task.type == "ENV_INSPECT"
|
||||||
|
assert task.status == "pending"
|
||||||
|
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||||
|
assert task.payload["keys"] == ["FRONTEND_URL", "BACKEND_URL"]
|
||||||
|
|
||||||
|
async def test_without_keys(
|
||||||
|
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||||
|
):
|
||||||
|
"""Request without keys omits keys from payload."""
|
||||||
|
request = EnvInspectRequest(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
path="/opt/letsbe/env/chatwoot.env",
|
||||||
|
keys=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||||
|
|
||||||
|
assert task.type == "ENV_INSPECT"
|
||||||
|
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||||
|
assert "keys" not in task.payload
|
||||||
|
|
||||||
|
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||||
|
"""Returns 404 when tenant doesn't exist."""
|
||||||
|
fake_tenant_id = uuid.uuid4()
|
||||||
|
request = EnvInspectRequest(
|
||||||
|
tenant_id=fake_tenant_id,
|
||||||
|
path="/opt/letsbe/env/chatwoot.env",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||||
|
|
||||||
|
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||||
|
"""Returns 404 when agent doesn't exist."""
|
||||||
|
fake_agent_id = uuid.uuid4()
|
||||||
|
request = EnvInspectRequest(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
path="/opt/letsbe/env/chatwoot.env",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await inspect_env(agent_id=fake_agent_id, request=request, db=db)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||||
|
|
||||||
|
async def test_task_persisted_to_database(
|
||||||
|
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||||
|
):
|
||||||
|
"""Verify the task is actually persisted and can be retrieved."""
|
||||||
|
request = EnvInspectRequest(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
path="/opt/letsbe/env/chatwoot.env",
|
||||||
|
)
|
||||||
|
|
||||||
|
task = await inspect_env(agent_id=test_agent.id, request=request, db=db)
|
||||||
|
|
||||||
|
# Query the task back from the database
|
||||||
|
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||||
|
retrieved_task = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
assert retrieved_task is not None
|
||||||
|
assert retrieved_task.type == "ENV_INSPECT"
|
||||||
|
assert retrieved_task.tenant_id == test_tenant.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestUpdateEnv:
|
||||||
|
"""Tests for the update_env endpoint."""
|
||||||
|
|
||||||
|
async def test_happy_path(
|
||||||
|
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||||
|
):
|
||||||
|
"""Valid request creates an ENV_UPDATE task."""
|
||||||
|
request = EnvUpdateRequest(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
path="/opt/letsbe/env/chatwoot.env",
|
||||||
|
updates={"FRONTEND_URL": "https://new.domain.com"},
|
||||||
|
remove_keys=["OLD_KEY"],
|
||||||
|
)
|
||||||
|
|
||||||
|
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||||
|
|
||||||
|
assert task.id is not None
|
||||||
|
assert task.tenant_id == test_tenant.id
|
||||||
|
assert task.agent_id == test_agent.id
|
||||||
|
assert task.type == "ENV_UPDATE"
|
||||||
|
assert task.status == "pending"
|
||||||
|
assert task.payload["path"] == "/opt/letsbe/env/chatwoot.env"
|
||||||
|
assert task.payload["updates"] == {"FRONTEND_URL": "https://new.domain.com"}
|
||||||
|
assert task.payload["remove_keys"] == ["OLD_KEY"]
|
||||||
|
|
||||||
|
async def test_updates_only(
|
||||||
|
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||||
|
):
|
||||||
|
"""Request with only updates omits remove_keys from payload."""
|
||||||
|
request = EnvUpdateRequest(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
path="/opt/letsbe/env/chatwoot.env",
|
||||||
|
updates={"KEY": "VALUE"},
|
||||||
|
remove_keys=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||||
|
|
||||||
|
assert task.type == "ENV_UPDATE"
|
||||||
|
assert task.payload["updates"] == {"KEY": "VALUE"}
|
||||||
|
assert "remove_keys" not in task.payload
|
||||||
|
|
||||||
|
async def test_remove_keys_only(
|
||||||
|
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||||
|
):
|
||||||
|
"""Request with only remove_keys omits updates from payload."""
|
||||||
|
request = EnvUpdateRequest(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
path="/opt/letsbe/env/chatwoot.env",
|
||||||
|
updates=None,
|
||||||
|
remove_keys=["OLD_KEY"],
|
||||||
|
)
|
||||||
|
|
||||||
|
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||||
|
|
||||||
|
assert task.type == "ENV_UPDATE"
|
||||||
|
assert "updates" not in task.payload
|
||||||
|
assert task.payload["remove_keys"] == ["OLD_KEY"]
|
||||||
|
|
||||||
|
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||||
|
"""Returns 404 when tenant doesn't exist."""
|
||||||
|
fake_tenant_id = uuid.uuid4()
|
||||||
|
request = EnvUpdateRequest(
|
||||||
|
tenant_id=fake_tenant_id,
|
||||||
|
path="/opt/letsbe/env/chatwoot.env",
|
||||||
|
updates={"KEY": "VALUE"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||||
|
|
||||||
|
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||||
|
"""Returns 404 when agent doesn't exist."""
|
||||||
|
fake_agent_id = uuid.uuid4()
|
||||||
|
request = EnvUpdateRequest(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
path="/opt/letsbe/env/chatwoot.env",
|
||||||
|
updates={"KEY": "VALUE"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await update_env(agent_id=fake_agent_id, request=request, db=db)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||||
|
|
||||||
|
async def test_task_persisted_to_database(
|
||||||
|
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||||
|
):
|
||||||
|
"""Verify the task is actually persisted and can be retrieved."""
|
||||||
|
request = EnvUpdateRequest(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
path="/opt/letsbe/env/chatwoot.env",
|
||||||
|
updates={"KEY": "VALUE"},
|
||||||
|
)
|
||||||
|
|
||||||
|
task = await update_env(agent_id=test_agent.id, request=request, db=db)
|
||||||
|
|
||||||
|
# Query the task back from the database
|
||||||
|
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||||
|
retrieved_task = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
assert retrieved_task is not None
|
||||||
|
assert retrieved_task.type == "ENV_UPDATE"
|
||||||
|
assert retrieved_task.tenant_id == test_tenant.id
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""Tests for file management routes."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.agent import Agent
|
||||||
|
from app.models.task import Task
|
||||||
|
from app.models.tenant import Tenant
|
||||||
|
from app.routes.files import inspect_file
|
||||||
|
from app.schemas.file import FileInspectRequest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestInspectFile:
|
||||||
|
"""Tests for the inspect_file endpoint."""
|
||||||
|
|
||||||
|
async def test_happy_path(
|
||||||
|
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||||
|
):
|
||||||
|
"""Valid request creates a FILE_INSPECT task."""
|
||||||
|
request = FileInspectRequest(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
path="/opt/letsbe/data/config.json",
|
||||||
|
max_bytes=4096,
|
||||||
|
)
|
||||||
|
|
||||||
|
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||||
|
|
||||||
|
assert task.id is not None
|
||||||
|
assert task.tenant_id == test_tenant.id
|
||||||
|
assert task.agent_id == test_agent.id
|
||||||
|
assert task.type == "FILE_INSPECT"
|
||||||
|
assert task.status == "pending"
|
||||||
|
assert task.payload["path"] == "/opt/letsbe/data/config.json"
|
||||||
|
assert task.payload["max_bytes"] == 4096
|
||||||
|
|
||||||
|
async def test_with_default_max_bytes(
|
||||||
|
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||||
|
):
|
||||||
|
"""Request uses default max_bytes of 4096."""
|
||||||
|
request = FileInspectRequest(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
path="/opt/letsbe/data/config.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||||
|
|
||||||
|
assert task.type == "FILE_INSPECT"
|
||||||
|
assert task.payload["path"] == "/opt/letsbe/data/config.json"
|
||||||
|
assert task.payload["max_bytes"] == 4096
|
||||||
|
|
||||||
|
async def test_with_custom_max_bytes(
|
||||||
|
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||||
|
):
|
||||||
|
"""Request with custom max_bytes is respected."""
|
||||||
|
request = FileInspectRequest(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
path="/opt/letsbe/data/large_file.txt",
|
||||||
|
max_bytes=1048576, # 1MB
|
||||||
|
)
|
||||||
|
|
||||||
|
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||||
|
|
||||||
|
assert task.type == "FILE_INSPECT"
|
||||||
|
assert task.payload["max_bytes"] == 1048576
|
||||||
|
|
||||||
|
async def test_tenant_not_found(self, db: AsyncSession, test_agent: Agent):
|
||||||
|
"""Returns 404 when tenant doesn't exist."""
|
||||||
|
fake_tenant_id = uuid.uuid4()
|
||||||
|
request = FileInspectRequest(
|
||||||
|
tenant_id=fake_tenant_id,
|
||||||
|
path="/opt/letsbe/data/config.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
assert f"Tenant {fake_tenant_id} not found" in str(exc_info.value.detail)
|
||||||
|
|
||||||
|
async def test_agent_not_found(self, db: AsyncSession, test_tenant: Tenant):
|
||||||
|
"""Returns 404 when agent doesn't exist."""
|
||||||
|
fake_agent_id = uuid.uuid4()
|
||||||
|
request = FileInspectRequest(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
path="/opt/letsbe/data/config.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await inspect_file(agent_id=fake_agent_id, request=request, db=db)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
assert f"Agent {fake_agent_id} not found" in str(exc_info.value.detail)
|
||||||
|
|
||||||
|
async def test_task_persisted_to_database(
|
||||||
|
self, db: AsyncSession, test_tenant: Tenant, test_agent: Agent
|
||||||
|
):
|
||||||
|
"""Verify the task is actually persisted and can be retrieved."""
|
||||||
|
request = FileInspectRequest(
|
||||||
|
tenant_id=test_tenant.id,
|
||||||
|
path="/opt/letsbe/data/config.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
task = await inspect_file(agent_id=test_agent.id, request=request, db=db)
|
||||||
|
|
||||||
|
# Query the task back from the database
|
||||||
|
result = await db.execute(select(Task).where(Task.id == task.id))
|
||||||
|
retrieved_task = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
assert retrieved_task is not None
|
||||||
|
assert retrieved_task.type == "FILE_INSPECT"
|
||||||
|
assert retrieved_task.tenant_id == test_tenant.id
|
||||||
Loading…
Reference in New Issue