feat: add tenant_id to agent registration and task filtering

- Add tenant_id field to AgentRegisterRequest schema
- Validate tenant exists during agent registration (returns 404 if not found)
- Update /tasks/next to filter tasks by agent's tenant_id
- Tenant-specific agents only see their tenant's tasks
- Shared agents (no tenant_id) can still claim any task
- Add ROADMAP.md tracking project progress

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Matt 2025-12-05 20:09:58 +01:00
parent 124a91af5a
commit 0975d208ef
4 changed files with 149 additions and 13 deletions

103
ROADMAP.md Normal file
View File

@ -0,0 +1,103 @@
# Orchestrator Roadmap
This document tracks Orchestrator-specific work for the AI SysAdmin system.
## Completed Work
### Core Infrastructure
- [x] Task model, statuses, DB tables
- [x] Agent model + registration flow
- [x] Task dispatching (`/tasks/next` polling)
- [x] Heartbeat system
- [x] Result ingestion
- [x] Tenant validation, permission flow
- [x] FastAPI app with complete routing structure
- [x] Deployment on live server (nginx + SSL)
### API Routes
- [x] `/api/v1/agents/*` - Agent registration, heartbeat
- [x] `/api/v1/tasks/*` - Task creation, polling, results
- [x] `/api/v1/tenants/*` - Tenant management
- [x] `/api/v1/env/*` - ENV inspection and update
- [x] `/api/v1/agents/{id}/files/inspect` - File inspection
### Playbooks
- [x] Chatwoot playbook (`/api/v1/tenants/{id}/chatwoot/setup`)
---
## Remaining Work
### Phase 1: Tool-Specific Playbooks
Create a playbook module for each tool in `app/playbooks/`:
| Tool | Module | API Endpoint | Status |
|------|--------|--------------|--------|
| Chatwoot | `chatwoot.py` | `/tenants/{id}/chatwoot/setup` | ✅ Done |
| NocoDB | `nocodb.py` | `/tenants/{id}/nocodb/setup` | ⬚ Todo |
| Directus | `directus.py` | `/tenants/{id}/directus/setup` | ⬚ Todo |
| Ghost CMS | `ghost.py` | `/tenants/{id}/ghost/setup` | ⬚ Todo |
| MinIO | `minio.py` | `/tenants/{id}/minio/setup` | ⬚ Todo |
| Keycloak | `keycloak.py` | `/tenants/{id}/keycloak/setup` | ⬚ Todo |
| Nextcloud | `nextcloud.py` | `/tenants/{id}/nextcloud/setup` | ⬚ Todo |
| Activepieces | `activepieces.py` | `/tenants/{id}/activepieces/setup` | ⬚ Todo |
| Listmonk | `listmonk.py` | `/tenants/{id}/listmonk/setup` | ⬚ Todo |
| Odoo | `odoo.py` | `/tenants/{id}/odoo/setup` | ⬚ Todo |
| Mixpost | `mixpost.py` | `/tenants/{id}/mixpost/setup` | ⬚ Todo |
**Each playbook creates a COMPOSITE task with:**
1. `ENV_INSPECT` - Read current configuration
2. `ENV_UPDATE` - Update URLs, domains, settings
3. `DOCKER_RELOAD` - Restart the stack
**Each playbook module needs:**
- Pydantic request/response schemas
- Route handler in `app/routes/playbooks.py`
- Tests in `tests/test_playbooks/`
---
### Phase 2: Introspection APIs
- [ ] `/api/v1/servers/{id}/scan` - Discover all services and their state
- [ ] `/api/v1/servers/{id}/diagnose` - Find configuration issues
- [ ] `/api/v1/servers/{id}/health` - Aggregate health check
---
### Phase 3: New Task Types
Support for new executor types from the agent:
| Task Type | Purpose | Status |
|-----------|---------|--------|
| NGINX_RELOAD | Reload nginx after config changes | ⬚ Todo |
| HEALTHCHECK | Check service status | ⬚ Todo |
| STACK_HEALTH | Verify docker compose stack | ⬚ Todo |
---
### Phase 4: Autonomous Operations
- [ ] LLM integration for natural language commands
- [ ] Task chaining based on results
- [ ] Automatic remediation workflows
---
### Phase 5: Dashboard & UI
- [ ] Task history and logs viewer
- [ ] Agent status dashboard
- [ ] Playbook marketplace
- [ ] RBAC and multi-tenant UI
---
## Next Steps
1. Create `app/playbooks/nocodb.py`
2. Add route: `POST /api/v1/tenants/{tenant_id}/nocodb/setup`
3. Create COMPOSITE task with ENV_INSPECT, ENV_UPDATE, DOCKER_RELOAD
4. Write tests in `tests/test_playbooks/test_nocodb.py`

View File

@ -9,6 +9,7 @@ from sqlalchemy import select
from app.db import AsyncSessionDep
from app.models.agent import Agent, AgentStatus
from app.models.base import utc_now
from app.models.tenant import Tenant
from app.schemas.agent import (
AgentHeartbeatResponse,
AgentRegisterRequest,
@ -27,6 +28,12 @@ async def get_agent_by_id(db: AsyncSessionDep, agent_id: uuid.UUID) -> Agent | N
return result.scalar_one_or_none()
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 validate_agent_token(
db: AsyncSessionDep,
agent_id: uuid.UUID,
@ -98,9 +105,21 @@ async def register_agent(
- **hostname**: Agent hostname (will be used as name)
- **version**: Agent software version
- **metadata**: Optional JSON metadata
- **tenant_id**: Optional tenant UUID to associate the agent with
Returns agent_id and token for subsequent API calls.
If tenant_id is provided but invalid, returns 404 Not Found.
"""
# Validate tenant exists if provided
if request.tenant_id is not None:
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",
)
agent_id = uuid.uuid4()
token = secrets.token_hex(32)
@ -111,7 +130,7 @@ async def register_agent(
status=AgentStatus.ONLINE.value,
last_heartbeat=utc_now(),
token=token,
tenant_id=None, # Agents register without tenant initially
tenant_id=request.tenant_id,
)
db.add(agent)

View File

@ -6,6 +6,7 @@ from fastapi import APIRouter, Header, HTTPException, Query, 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.routes.agents import validate_agent_token
from app.schemas.task import TaskCreate, TaskResponse, TaskUpdate
@ -162,14 +163,19 @@ async def list_tasks_endpoint(
# NOTE: /next must be defined BEFORE /{task_id} to avoid path matching issues
async def get_next_pending_task(db: AsyncSessionDep) -> Task | None:
"""Get the oldest pending task."""
query = (
select(Task)
.where(Task.status == TaskStatus.PENDING.value)
.order_by(Task.created_at.asc())
.limit(1)
)
async def get_next_pending_task(db: AsyncSessionDep, agent: Agent) -> Task | None:
"""Get the oldest pending task for the agent's tenant.
If the agent has a tenant_id, only returns tasks for that tenant.
If the agent has no tenant_id (shared agent), returns any pending task.
"""
query = select(Task).where(Task.status == TaskStatus.PENDING.value)
# Filter by agent's tenant if agent is tenant-specific
if agent.tenant_id is not None:
query = query.where(Task.tenant_id == agent.tenant_id)
query = query.order_by(Task.created_at.asc()).limit(1)
result = await db.execute(query)
return result.scalar_one_or_none()
@ -188,13 +194,17 @@ async def get_next_task_endpoint(
- Setting status to 'running'
- Assigning agent_id to the requesting agent
Tasks are filtered by the agent's tenant_id:
- If agent has a tenant_id, only returns tasks for that tenant
- If agent has no tenant_id (shared agent), can claim any task
Returns null (200) if no pending tasks are available.
"""
# Validate agent credentials
await validate_agent_token(db, agent_id, authorization)
# Validate agent credentials and get agent object
agent = await validate_agent_token(db, agent_id, authorization)
# Get next pending task
task = await get_next_pending_task(db)
# Get next pending task for this agent's tenant
task = await get_next_pending_task(db, agent)
if task is None:
return None

View File

@ -13,6 +13,10 @@ class AgentRegisterRequest(BaseModel):
hostname: str = Field(..., min_length=1, max_length=255)
version: str = Field(..., min_length=1, max_length=50)
metadata: dict[str, Any] | None = None
tenant_id: uuid.UUID | None = Field(
default=None,
description="Tenant UUID to associate the agent with"
)
class AgentRegisterResponse(BaseModel):