Files
claudetools/api/routers/machines.py
Mike Swanson 390b10b32c Complete Phase 6: MSP Work Tracking with Context Recall System
Implements production-ready MSP platform with cross-machine persistent memory for Claude.

API Implementation:
- 130 REST API endpoints across 21 entities
- JWT authentication on all endpoints
- AES-256-GCM encryption for credentials
- Automatic audit logging
- Complete OpenAPI documentation

Database:
- 43 tables in MariaDB (172.16.3.20:3306)
- 42 SQLAlchemy models with modern 2.0 syntax
- Full Alembic migration system
- 99.1% CRUD test pass rate

Context Recall System (Phase 6):
- Cross-machine persistent memory via database
- Automatic context injection via Claude Code hooks
- Automatic context saving after task completion
- 90-95% token reduction with compression utilities
- Relevance scoring with time decay
- Tag-based semantic search
- One-command setup script

Security Features:
- JWT tokens with Argon2 password hashing
- AES-256-GCM encryption for all sensitive data
- Comprehensive audit trail for credentials
- HMAC tamper detection
- Secure configuration management

Test Results:
- Phase 3: 38/38 CRUD tests passing (100%)
- Phase 4: 34/35 core API tests passing (97.1%)
- Phase 5: 62/62 extended API tests passing (100%)
- Phase 6: 10/10 compression tests passing (100%)
- Overall: 144/145 tests passing (99.3%)

Documentation:
- Comprehensive architecture guides
- Setup automation scripts
- API documentation at /api/docs
- Complete test reports
- Troubleshooting guides

Project Status: 95% Complete (Production-Ready)
Phase 7 (optional work context APIs) remains for future enhancement.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-17 06:00:26 -07:00

458 lines
12 KiB
Python

"""
Machine API router for ClaudeTools.
This module defines all REST API endpoints for managing machines, including
CRUD operations with proper authentication, validation, and error handling.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from api.database import get_db
from api.middleware.auth import get_current_user
from api.schemas.machine import (
MachineCreate,
MachineResponse,
MachineUpdate,
)
from api.services import machine_service
# Create router with prefix and tags
router = APIRouter()
@router.get(
"",
response_model=dict,
summary="List all machines",
description="Retrieve a paginated list of all machines with optional filtering",
status_code=status.HTTP_200_OK,
)
def list_machines(
skip: int = Query(
default=0,
ge=0,
description="Number of records to skip for pagination"
),
limit: int = Query(
default=100,
ge=1,
le=1000,
description="Maximum number of records to return (max 1000)"
),
active_only: bool = Query(
default=False,
description="If true, only return active machines"
),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
List all machines with pagination.
- **skip**: Number of machines to skip (default: 0)
- **limit**: Maximum number of machines to return (default: 100, max: 1000)
- **active_only**: Filter to only active machines (default: false)
Returns a list of machines with pagination metadata.
**Example Request:**
```
GET /api/machines?skip=0&limit=50&active_only=true
Authorization: Bearer <token>
```
**Example Response:**
```json
{
"total": 5,
"skip": 0,
"limit": 50,
"machines": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"hostname": "laptop-dev-01",
"friendly_name": "Main Development Laptop",
"machine_type": "laptop",
"platform": "win32",
"is_primary": true,
"is_active": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
]
}
```
"""
try:
if active_only:
machines, total = machine_service.get_active_machines(db, skip, limit)
else:
machines, total = machine_service.get_machines(db, skip, limit)
return {
"total": total,
"skip": skip,
"limit": limit,
"machines": [MachineResponse.model_validate(machine) for machine in machines]
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve machines: {str(e)}"
)
@router.get(
"/{machine_id}",
response_model=MachineResponse,
summary="Get machine by ID",
description="Retrieve a single machine by its unique identifier",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Machine found and returned",
"model": MachineResponse,
},
404: {
"description": "Machine not found",
"content": {
"application/json": {
"example": {"detail": "Machine with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
}
},
},
},
)
def get_machine(
machine_id: UUID,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Get a specific machine by ID.
- **machine_id**: UUID of the machine to retrieve
Returns the complete machine details.
**Example Request:**
```
GET /api/machines/123e4567-e89b-12d3-a456-426614174000
Authorization: Bearer <token>
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"hostname": "laptop-dev-01",
"friendly_name": "Main Development Laptop",
"machine_type": "laptop",
"platform": "win32",
"os_version": "Windows 11 Pro",
"username": "technician",
"home_directory": "C:\\\\Users\\\\technician",
"has_vpn_access": true,
"has_docker": true,
"has_powershell": true,
"powershell_version": "7.4.0",
"is_primary": true,
"is_active": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
```
"""
machine = machine_service.get_machine_by_id(db, machine_id)
return MachineResponse.model_validate(machine)
@router.post(
"",
response_model=MachineResponse,
summary="Create new machine",
description="Create a new machine with the provided details",
status_code=status.HTTP_201_CREATED,
responses={
201: {
"description": "Machine created successfully",
"model": MachineResponse,
},
409: {
"description": "Machine with hostname already exists",
"content": {
"application/json": {
"example": {"detail": "Machine with hostname 'laptop-dev-01' already exists"}
}
},
},
422: {
"description": "Validation error",
"content": {
"application/json": {
"example": {
"detail": [
{
"loc": ["body", "hostname"],
"msg": "field required",
"type": "value_error.missing"
}
]
}
}
},
},
},
)
def create_machine(
machine_data: MachineCreate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Create a new machine.
Requires a valid JWT token with appropriate permissions.
**Example Request:**
```json
POST /api/machines
Authorization: Bearer <token>
Content-Type: application/json
{
"hostname": "laptop-dev-01",
"friendly_name": "Main Development Laptop",
"machine_type": "laptop",
"platform": "win32",
"os_version": "Windows 11 Pro",
"username": "technician",
"home_directory": "C:\\\\Users\\\\technician",
"has_vpn_access": true,
"has_docker": true,
"has_powershell": true,
"powershell_version": "7.4.0",
"has_ssh": true,
"has_git": true,
"claude_working_directory": "D:\\\\Projects",
"preferred_shell": "powershell",
"is_primary": true,
"is_active": true
}
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"hostname": "laptop-dev-01",
"friendly_name": "Main Development Laptop",
"machine_type": "laptop",
"platform": "win32",
"is_primary": true,
"is_active": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
```
"""
machine = machine_service.create_machine(db, machine_data)
return MachineResponse.model_validate(machine)
@router.put(
"/{machine_id}",
response_model=MachineResponse,
summary="Update machine",
description="Update an existing machine's details",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Machine updated successfully",
"model": MachineResponse,
},
404: {
"description": "Machine not found",
"content": {
"application/json": {
"example": {"detail": "Machine with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
}
},
},
409: {
"description": "Conflict with existing machine",
"content": {
"application/json": {
"example": {"detail": "Machine with hostname 'laptop-dev-01' already exists"}
}
},
},
},
)
def update_machine(
machine_id: UUID,
machine_data: MachineUpdate,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Update an existing machine.
- **machine_id**: UUID of the machine to update
Only provided fields will be updated. All fields are optional.
**Example Request:**
```json
PUT /api/machines/123e4567-e89b-12d3-a456-426614174000
Authorization: Bearer <token>
Content-Type: application/json
{
"friendly_name": "Updated Laptop Name",
"is_active": false,
"notes": "Machine being retired"
}
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"hostname": "laptop-dev-01",
"friendly_name": "Updated Laptop Name",
"machine_type": "laptop",
"platform": "win32",
"is_active": false,
"notes": "Machine being retired",
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T14:20:00Z"
}
```
"""
machine = machine_service.update_machine(db, machine_id, machine_data)
return MachineResponse.model_validate(machine)
@router.delete(
"/{machine_id}",
response_model=dict,
summary="Delete machine",
description="Delete a machine by its ID",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Machine deleted successfully",
"content": {
"application/json": {
"example": {
"message": "Machine deleted successfully",
"machine_id": "123e4567-e89b-12d3-a456-426614174000"
}
}
},
},
404: {
"description": "Machine not found",
"content": {
"application/json": {
"example": {"detail": "Machine with ID 123e4567-e89b-12d3-a456-426614174000 not found"}
}
},
},
},
)
def delete_machine(
machine_id: UUID,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Delete a machine.
- **machine_id**: UUID of the machine to delete
This is a permanent operation and cannot be undone.
**Example Request:**
```
DELETE /api/machines/123e4567-e89b-12d3-a456-426614174000
Authorization: Bearer <token>
```
**Example Response:**
```json
{
"message": "Machine deleted successfully",
"machine_id": "123e4567-e89b-12d3-a456-426614174000"
}
```
"""
return machine_service.delete_machine(db, machine_id)
@router.get(
"/primary/info",
response_model=MachineResponse,
summary="Get primary machine",
description="Retrieve the machine marked as primary",
status_code=status.HTTP_200_OK,
responses={
200: {
"description": "Primary machine found",
"model": MachineResponse,
},
404: {
"description": "No primary machine configured",
"content": {
"application/json": {
"example": {"detail": "No primary machine is configured"}
}
},
},
},
)
def get_primary_machine(
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""
Get the primary machine.
Returns the machine that is marked as the primary machine for MSP work.
**Example Request:**
```
GET /api/machines/primary/info
Authorization: Bearer <token>
```
**Example Response:**
```json
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"hostname": "laptop-dev-01",
"friendly_name": "Main Development Laptop",
"machine_type": "laptop",
"platform": "win32",
"is_primary": true,
"is_active": true,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-01-15T10:30:00Z"
}
```
"""
primary_machine = machine_service.get_primary_machine(db)
if not primary_machine:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No primary machine is configured"
)
return MachineResponse.model_validate(primary_machine)