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:
parent
124a91af5a
commit
0975d208ef
|
|
@ -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`
|
||||||
|
|
@ -9,6 +9,7 @@ from sqlalchemy import select
|
||||||
from app.db import AsyncSessionDep
|
from app.db import AsyncSessionDep
|
||||||
from app.models.agent import Agent, AgentStatus
|
from app.models.agent import Agent, AgentStatus
|
||||||
from app.models.base import utc_now
|
from app.models.base import utc_now
|
||||||
|
from app.models.tenant import Tenant
|
||||||
from app.schemas.agent import (
|
from app.schemas.agent import (
|
||||||
AgentHeartbeatResponse,
|
AgentHeartbeatResponse,
|
||||||
AgentRegisterRequest,
|
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()
|
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(
|
async def validate_agent_token(
|
||||||
db: AsyncSessionDep,
|
db: AsyncSessionDep,
|
||||||
agent_id: uuid.UUID,
|
agent_id: uuid.UUID,
|
||||||
|
|
@ -98,9 +105,21 @@ async def register_agent(
|
||||||
- **hostname**: Agent hostname (will be used as name)
|
- **hostname**: Agent hostname (will be used as name)
|
||||||
- **version**: Agent software version
|
- **version**: Agent software version
|
||||||
- **metadata**: Optional JSON metadata
|
- **metadata**: Optional JSON metadata
|
||||||
|
- **tenant_id**: Optional tenant UUID to associate the agent with
|
||||||
|
|
||||||
Returns agent_id and token for subsequent API calls.
|
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()
|
agent_id = uuid.uuid4()
|
||||||
token = secrets.token_hex(32)
|
token = secrets.token_hex(32)
|
||||||
|
|
||||||
|
|
@ -111,7 +130,7 @@ async def register_agent(
|
||||||
status=AgentStatus.ONLINE.value,
|
status=AgentStatus.ONLINE.value,
|
||||||
last_heartbeat=utc_now(),
|
last_heartbeat=utc_now(),
|
||||||
token=token,
|
token=token,
|
||||||
tenant_id=None, # Agents register without tenant initially
|
tenant_id=request.tenant_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(agent)
|
db.add(agent)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from fastapi import APIRouter, Header, HTTPException, Query, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.db import AsyncSessionDep
|
from app.db import AsyncSessionDep
|
||||||
|
from app.models.agent import Agent
|
||||||
from app.models.task import Task, TaskStatus
|
from app.models.task import Task, TaskStatus
|
||||||
from app.routes.agents import validate_agent_token
|
from app.routes.agents import validate_agent_token
|
||||||
from app.schemas.task import TaskCreate, TaskResponse, TaskUpdate
|
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
|
# NOTE: /next must be defined BEFORE /{task_id} to avoid path matching issues
|
||||||
|
|
||||||
|
|
||||||
async def get_next_pending_task(db: AsyncSessionDep) -> Task | None:
|
async def get_next_pending_task(db: AsyncSessionDep, agent: Agent) -> Task | None:
|
||||||
"""Get the oldest pending task."""
|
"""Get the oldest pending task for the agent's tenant.
|
||||||
query = (
|
|
||||||
select(Task)
|
If the agent has a tenant_id, only returns tasks for that tenant.
|
||||||
.where(Task.status == TaskStatus.PENDING.value)
|
If the agent has no tenant_id (shared agent), returns any pending task.
|
||||||
.order_by(Task.created_at.asc())
|
"""
|
||||||
.limit(1)
|
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)
|
result = await db.execute(query)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
@ -188,13 +194,17 @@ async def get_next_task_endpoint(
|
||||||
- Setting status to 'running'
|
- Setting status to 'running'
|
||||||
- Assigning agent_id to the requesting agent
|
- 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.
|
Returns null (200) if no pending tasks are available.
|
||||||
"""
|
"""
|
||||||
# Validate agent credentials
|
# Validate agent credentials and get agent object
|
||||||
await validate_agent_token(db, agent_id, authorization)
|
agent = await validate_agent_token(db, agent_id, authorization)
|
||||||
|
|
||||||
# Get next pending task
|
# Get next pending task for this agent's tenant
|
||||||
task = await get_next_pending_task(db)
|
task = await get_next_pending_task(db, agent)
|
||||||
|
|
||||||
if task is None:
|
if task is None:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ class AgentRegisterRequest(BaseModel):
|
||||||
hostname: str = Field(..., min_length=1, max_length=255)
|
hostname: str = Field(..., min_length=1, max_length=255)
|
||||||
version: str = Field(..., min_length=1, max_length=50)
|
version: str = Field(..., min_length=1, max_length=50)
|
||||||
metadata: dict[str, Any] | None = None
|
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):
|
class AgentRegisterResponse(BaseModel):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue