From 812b8341befcba510b80e96d6d5071318dcc3886 Mon Sep 17 00:00:00 2001 From: jordan Date: Sun, 25 Jan 2026 23:02:31 -0700 Subject: [PATCH] refactor: Split large files to comply with 500-line limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmd/rdev-api/main.go: Extract OpenAPI spec to openapi.go (1073→386 lines) - internal/adapter/deployer/deployer.go: Extract K8s resources to resources.go (502→264 lines) - internal/handlers/infrastructure.go: Extract deploy handlers to infrastructure_deploy.go (592→342 lines) Co-Authored-By: Claude Opus 4.5 --- cmd/rdev-api/main.go | 687 --------------------- cmd/rdev-api/openapi.go | 596 ++++++++++++++++++ internal/adapter/deployer/deployer.go | 238 ------- internal/adapter/deployer/resources.go | 260 ++++++++ internal/handlers/infrastructure.go | 250 -------- internal/handlers/infrastructure_deploy.go | 263 ++++++++ 6 files changed, 1119 insertions(+), 1175 deletions(-) create mode 100644 cmd/rdev-api/openapi.go create mode 100644 internal/adapter/deployer/resources.go create mode 100644 internal/handlers/infrastructure_deploy.go diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index 0cfece0..88db368 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -384,690 +384,3 @@ func getEnv(key, defaultVal string) string { } return defaultVal } - -// buildOpenAPISpec creates the OpenAPI specification for the rdev API. -func buildOpenAPISpec() *api.OpenAPISpec { - spec := api.NewOpenAPISpec("rdev API", "0.5.0"). - WithDescription(`Remote Developer API for controlling Claude Code instances in Kubernetes. - -rdev runs Claude Code CLI in isolated pods, controlled via this REST API with SSE streaming. -External clients (Discord bots, Slack bots, CLI tools) connect here to interact with projects. - -## Authentication - -All endpoints except /health, /ready, and /docs require authentication via API key. - -**Header**: `+"`X-API-Key: rdev_sk_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`"+` - -Or: `+"`Authorization: Bearer rdev_sk_...`"+` - -### Getting Started - -1. Set RDEV_ADMIN_KEY environment variable with your super admin key -2. Use the admin key to create additional keys via POST /keys -3. Use created keys for normal operations - -### Scopes - -| Scope | Description | -|-------|-------------| -| projects:read | List and view projects | -| projects:execute | Run commands (claude, shell, git) | -| keys:read | List API keys (metadata only) | -| keys:write | Create and revoke keys | -| audit:read | View audit logs for command executions | -| admin | Full access (all scopes) | - -## Architecture - -- **rdev-api**: This Go service -- **claudebox pods**: Isolated pods running Claude Code CLI -- **postgres**: API key storage (auto-migrating) - -## Streaming - -Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events. -`). - WithServer("http://localhost:8080", "Local development"). - WithServer("http://rdev-api.rdev.svc:8080", "Kubernetes internal") - - // Tags - spec.WithTag("Authentication", "API key management") - spec.WithTag("Projects", "Project management and discovery") - spec.WithTag("Commands", "Command execution (claude, shell, git)") - spec.WithTag("Events", "Server-Sent Events for real-time output") - spec.WithTag("Claude Config", "Manage commands, skills, and agents in /workspace/.claude/") - spec.WithTag("Audit", "Command execution audit logs") - spec.WithTag("System", "Health and readiness endpoints") - - // System endpoints - spec.AddPath("/health", "get", api.Op( - "Health check", - "Returns health status. No authentication required.", - "System", - )) - spec.AddPath("/ready", "get", api.Op( - "Readiness check", - "Returns readiness status. No authentication required.", - "System", - )) - - // Key management endpoints - spec.AddPath("/keys", "get", withAuth( - "List API keys", - "Returns all API keys with metadata (not secrets). Requires keys:read scope.", - "Authentication", - "keys:read", - `[ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "discord-bot", - "key_prefix": "a1b2c3d4", - "scopes": ["projects:read", "projects:execute"], - "created_at": "2026-01-24T20:00:00Z", - "active": true - } -]`, - )) - - spec.AddPath("/keys", "post", withAuthAndBody( - "Create API key", - `Creates a new API key. The secret is returned only once - save it securely! - -**Expiration options**: 30d, 60d, 90d, 1y, never (default: never)`, - "Authentication", - "keys:write", - `{ - "name": "discord-bot", - "scopes": ["projects:read", "projects:execute"], - "project_ids": ["pantheon"], - "expires_in": "90d" -}`, - `{ - "key": { - "id": "550e8400-e29b-41d4-a716-446655440000", - "name": "discord-bot", - "key_prefix": "a1b2c3d4", - "scopes": ["projects:read", "projects:execute"], - "project_ids": ["pantheon"], - "created_at": "2026-01-24T20:00:00Z", - "expires_at": "2026-04-24T20:00:00Z", - "active": true - }, - "secret": "rdev_sk_a1b2c3d4_e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0" -}`, - )) - - spec.AddPath("/keys/{id}", "get", withAuthAndParams( - "Get API key", - "Returns details for a specific API key. Requires keys:read scope.", - "Authentication", - "keys:read", - []param{{Name: "id", In: "path", Description: "Key ID (UUID)", Required: true}}, - )) - - spec.AddPath("/keys/{id}", "delete", withAuthAndParams( - "Revoke API key", - "Revokes an API key immediately. The key cannot be used after revocation. Requires keys:write scope.", - "Authentication", - "keys:write", - []param{{Name: "id", In: "path", Description: "Key ID (UUID)", Required: true}}, - )) - - // Projects - List and Get - spec.AddPath("/projects", "get", withAuth( - "List projects", - "Returns all available projects (claudebox pods). Requires projects:read scope.", - "Projects", - "projects:read", - `[ - { - "id": "pantheon", - "name": "Pantheon", - "description": "Go API backend", - "pod": "claudebox-pantheon-0", - "status": "running" - } -]`, - )) - - spec.AddPath("/projects/{id}", "get", withAuthAndParams( - "Get project", - "Returns details for a specific project. Requires projects:read scope.", - "Projects", - "projects:read", - []param{{Name: "id", In: "path", Description: "Project ID (e.g., 'pantheon')", Required: true}}, - )) - - // Commands - spec.AddPath("/projects/{id}/claude", "post", withAuthBodyAndParams( - "Run Claude command", - "Executes a Claude Code prompt in the project's claudebox pod. Requires projects:execute scope.", - "Commands", - "projects:execute", - []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, - `{ - "prompt": "fix the bug in auth handler", - "stream_id": "optional-correlation-id" -}`, - `{ - "id": "cmd-pantheon-001", - "project": "pantheon", - "type": "claude", - "status": "queued", - "stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-001" -}`, - )) - - spec.AddPath("/projects/{id}/shell", "post", withAuthBodyAndParams( - "Run shell command", - "Executes a shell command in the project's claudebox pod. Requires projects:execute scope.", - "Commands", - "projects:execute", - []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, - `{ - "command": "go test ./...", - "stream_id": "optional-correlation-id" -}`, - `{ - "id": "cmd-pantheon-002", - "project": "pantheon", - "type": "shell", - "status": "queued", - "stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-002" -}`, - )) - - spec.AddPath("/projects/{id}/git", "post", withAuthBodyAndParams( - "Run git command", - "Executes a git command in the project's claudebox pod. Requires projects:execute scope.", - "Commands", - "projects:execute", - []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, - `{ - "args": ["status"], - "stream_id": "optional-correlation-id" -}`, - `{ - "id": "cmd-pantheon-003", - "project": "pantheon", - "type": "git", - "status": "queued", - "stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-003" -}`, - )) - - // SSE Events - spec.AddPath("/projects/{id}/events", "get", map[string]any{ - "summary": "Stream events", - "description": `Server-Sent Events stream for real-time command output. - -Requires projects:read scope. - -## Event Types - -- **connected**: Initial connection confirmation -- **output**: Command output (stdout or stderr) -- **error**: Error occurred during execution -- **complete**: Command finished (includes exit_code and duration_ms) -- **heartbeat**: Keep-alive (sent every 30s) - -## Example - -` + "```javascript" + ` -const events = new EventSource('/projects/pantheon/events?stream_id=cmd-001', { - headers: { 'X-API-Key': 'rdev_sk_...' } -}); - -events.addEventListener('output', (e) => { - const data = JSON.parse(e.data); - console.log(data.line); -}); - -events.addEventListener('complete', (e) => { - const data = JSON.parse(e.data); - console.log('Done:', data.exit_code); - events.close(); -}); -` + "```", - "tags": []string{"Events"}, - "security": []map[string]any{ - {"ApiKeyAuth": []string{}}, - }, - "parameters": []map[string]any{ - { - "name": "id", - "in": "path", - "description": "Project ID", - "required": true, - "schema": map[string]any{"type": "string"}, - }, - { - "name": "stream_id", - "in": "query", - "description": "Command ID to filter events (optional)", - "required": false, - "schema": map[string]any{"type": "string"}, - }, - }, - "responses": map[string]any{ - "200": map[string]any{ - "description": "SSE stream", - "content": map[string]any{ - "text/event-stream": map[string]any{ - "schema": map[string]any{ - "type": "string", - "example": "event: output\ndata: {\"line\": \"Building...\", \"stream\": \"stdout\"}\n\n", - }, - }, - }, - }, - }, - }) - - // Claude Config - Overview - spec.AddPath("/projects/{id}/claude-config", "get", withAuthAndParams( - "Get config overview", - `Returns an overview of the project's Claude config (/workspace/.claude/). - -Lists available commands, skills, and agents. Requires projects:read scope.`, - "Claude Config", - "projects:read", - []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, - )) - - // Claude Config - Commands - spec.AddPath("/projects/{id}/claude-config/commands", "get", withAuthAndParams( - "List commands", - "Lists all custom commands in /workspace/.claude/commands/. Requires projects:read scope.", - "Claude Config", - "projects:read", - []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, - )) - - spec.AddPath("/projects/{id}/claude-config/commands", "post", withAuthBodyAndParams( - "Create command", - `Creates a new custom command in /workspace/.claude/commands/{name}.md. - -Commands are markdown files with frontmatter. Requires projects:execute scope.`, - "Claude Config", - "projects:execute", - []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, - `{ - "name": "deploy", - "content": "---\ndescription: Deploy to production\n---\n\nRun the deployment..." -}`, - `{ - "name": "deploy", - "type": "commands", - "content": "---\ndescription: Deploy to production\n---\n\nRun the deployment..." -}`, - )) - - spec.AddPath("/projects/{id}/claude-config/commands/{name}", "get", withAuthAndParams( - "Get command", - "Returns a specific command's content. Requires projects:read scope.", - "Claude Config", - "projects:read", - []param{ - {Name: "id", In: "path", Description: "Project ID", Required: true}, - {Name: "name", In: "path", Description: "Command name", Required: true}, - }, - )) - - spec.AddPath("/projects/{id}/claude-config/commands/{name}", "put", withAuthBodyAndParams( - "Update command", - "Updates a command's content. Requires projects:execute scope.", - "Claude Config", - "projects:execute", - []param{ - {Name: "id", In: "path", Description: "Project ID", Required: true}, - {Name: "name", In: "path", Description: "Command name", Required: true}, - }, - `{ - "content": "---\ndescription: Updated description\n---\n\nUpdated content..." -}`, - `{ - "name": "deploy", - "type": "commands", - "content": "---\ndescription: Updated description\n---\n\nUpdated content..." -}`, - )) - - spec.AddPath("/projects/{id}/claude-config/commands/{name}", "delete", withAuthAndParams( - "Delete command", - "Deletes a command. Requires projects:execute scope.", - "Claude Config", - "projects:execute", - []param{ - {Name: "id", In: "path", Description: "Project ID", Required: true}, - {Name: "name", In: "path", Description: "Command name", Required: true}, - }, - )) - - // Claude Config - Skills (same pattern as commands) - spec.AddPath("/projects/{id}/claude-config/skills", "get", withAuthAndParams( - "List skills", - "Lists all skills in /workspace/.claude/skills/. Requires projects:read scope.", - "Claude Config", - "projects:read", - []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, - )) - - spec.AddPath("/projects/{id}/claude-config/skills", "post", withAuthBodyAndParams( - "Create skill", - "Creates a new skill. Requires projects:execute scope.", - "Claude Config", - "projects:execute", - []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, - `{"name": "go-testing", "content": "# Go Testing Skill\n\n..."}`, - `{"name": "go-testing", "type": "skills", "content": "# Go Testing Skill\n\n..."}`, - )) - - spec.AddPath("/projects/{id}/claude-config/skills/{name}", "get", withAuthAndParams( - "Get skill", - "Returns a specific skill's content. Requires projects:read scope.", - "Claude Config", - "projects:read", - []param{ - {Name: "id", In: "path", Description: "Project ID", Required: true}, - {Name: "name", In: "path", Description: "Skill name", Required: true}, - }, - )) - - spec.AddPath("/projects/{id}/claude-config/skills/{name}", "put", withAuthBodyAndParams( - "Update skill", - "Updates a skill's content. Requires projects:execute scope.", - "Claude Config", - "projects:execute", - []param{ - {Name: "id", In: "path", Description: "Project ID", Required: true}, - {Name: "name", In: "path", Description: "Skill name", Required: true}, - }, - `{"content": "# Updated Skill\n\n..."}`, - `{"name": "go-testing", "type": "skills", "content": "# Updated Skill\n\n..."}`, - )) - - spec.AddPath("/projects/{id}/claude-config/skills/{name}", "delete", withAuthAndParams( - "Delete skill", - "Deletes a skill. Requires projects:execute scope.", - "Claude Config", - "projects:execute", - []param{ - {Name: "id", In: "path", Description: "Project ID", Required: true}, - {Name: "name", In: "path", Description: "Skill name", Required: true}, - }, - )) - - // Claude Config - Agents (same pattern) - spec.AddPath("/projects/{id}/claude-config/agents", "get", withAuthAndParams( - "List agents", - "Lists all agents in /workspace/.claude/agents/. Requires projects:read scope.", - "Claude Config", - "projects:read", - []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, - )) - - spec.AddPath("/projects/{id}/claude-config/agents", "post", withAuthBodyAndParams( - "Create agent", - "Creates a new agent. Requires projects:execute scope.", - "Claude Config", - "projects:execute", - []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, - `{"name": "code-reviewer", "content": "# Code Reviewer Agent\n\n..."}`, - `{"name": "code-reviewer", "type": "agents", "content": "# Code Reviewer Agent\n\n..."}`, - )) - - spec.AddPath("/projects/{id}/claude-config/agents/{name}", "get", withAuthAndParams( - "Get agent", - "Returns a specific agent's content. Requires projects:read scope.", - "Claude Config", - "projects:read", - []param{ - {Name: "id", In: "path", Description: "Project ID", Required: true}, - {Name: "name", In: "path", Description: "Agent name", Required: true}, - }, - )) - - spec.AddPath("/projects/{id}/claude-config/agents/{name}", "put", withAuthBodyAndParams( - "Update agent", - "Updates an agent's content. Requires projects:execute scope.", - "Claude Config", - "projects:execute", - []param{ - {Name: "id", In: "path", Description: "Project ID", Required: true}, - {Name: "name", In: "path", Description: "Agent name", Required: true}, - }, - `{"content": "# Updated Agent\n\n..."}`, - `{"name": "code-reviewer", "type": "agents", "content": "# Updated Agent\n\n..."}`, - )) - - spec.AddPath("/projects/{id}/claude-config/agents/{name}", "delete", withAuthAndParams( - "Delete agent", - "Deletes an agent. Requires projects:execute scope.", - "Claude Config", - "projects:execute", - []param{ - {Name: "id", In: "path", Description: "Project ID", Required: true}, - {Name: "name", In: "path", Description: "Agent name", Required: true}, - }, - )) - - // Audit log endpoints - spec.AddPath("/audit-log", "get", map[string]any{ - "summary": "List audit log entries", - "description": `Returns audit log entries with optional filtering. - -**Required scope**: ` + "`audit:read`" + ` - -## Query Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| project | string | Filter by project ID | -| api_key | string | Filter by API key ID | -| command_type | string | Filter by type (claude, shell, git) | -| status | string | Filter by status (running, success, error, cancelled) | -| start | string | Filter by start time (RFC3339 format) | -| end | string | Filter by end time (RFC3339 format) | -| limit | int | Max entries to return (default: 100, max: 1000) | -| offset | int | Number of entries to skip for pagination |`, - "tags": []string{"Audit"}, - "security": []map[string]any{ - {"ApiKeyAuth": []string{}}, - }, - "parameters": []map[string]any{ - {"name": "project", "in": "query", "description": "Filter by project ID", "schema": map[string]any{"type": "string"}}, - {"name": "api_key", "in": "query", "description": "Filter by API key ID", "schema": map[string]any{"type": "string"}}, - {"name": "command_type", "in": "query", "description": "Filter by command type", "schema": map[string]any{"type": "string", "enum": []string{"claude", "shell", "git"}}}, - {"name": "status", "in": "query", "description": "Filter by status", "schema": map[string]any{"type": "string", "enum": []string{"running", "success", "error", "cancelled"}}}, - {"name": "start", "in": "query", "description": "Filter by start time (RFC3339)", "schema": map[string]any{"type": "string", "format": "date-time"}}, - {"name": "end", "in": "query", "description": "Filter by end time (RFC3339)", "schema": map[string]any{"type": "string", "format": "date-time"}}, - {"name": "limit", "in": "query", "description": "Max entries (default: 100)", "schema": map[string]any{"type": "integer", "default": 100}}, - {"name": "offset", "in": "query", "description": "Entries to skip", "schema": map[string]any{"type": "integer", "default": 0}}, - }, - "responses": map[string]any{ - "200": map[string]any{ - "description": "Success", - "content": map[string]any{ - "application/json": map[string]any{ - "example": `{ - "entries": [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "api_key_id": "key-123", - "command_id": "cmd-pantheon-001", - "project_id": "pantheon", - "command_type": "claude", - "args": "[\"fix the bug\"]", - "client_ip": "192.168.1.100", - "user_agent": "rdev-cli/1.0", - "started_at": "2026-01-25T12:00:00Z", - "completed_at": "2026-01-25T12:01:30Z", - "exit_code": 0, - "duration_ms": 90000, - "status": "success", - "output_size_bytes": 1024, - "created_at": "2026-01-25T12:00:00Z" - } - ], - "total": 1, - "limit": 100, - "offset": 0 -}`, - }, - }, - }, - "401": map[string]any{"description": "Unauthorized - Missing or invalid API key"}, - "403": map[string]any{"description": "Forbidden - Insufficient permissions"}, - }, - }) - - spec.AddPath("/audit-log/{command_id}", "get", withAuthAndParams( - "Get audit log entry", - "Returns a single audit log entry by command ID. Requires audit:read scope.", - "Audit", - "audit:read", - []param{{Name: "command_id", In: "path", Description: "Command ID", Required: true}}, - )) - - return spec -} - -// param represents an OpenAPI parameter. -type param struct { - Name string - In string - Description string - Required bool -} - -// withAuth creates an operation that requires authentication. -func withAuth(summary, description, tag, scope, example string) map[string]any { - return map[string]any{ - "summary": summary, - "description": description + "\n\n**Required scope**: `" + scope + "`", - "tags": []string{tag}, - "security": []map[string]any{ - {"ApiKeyAuth": []string{}}, - }, - "responses": map[string]any{ - "200": map[string]any{ - "description": "Success", - "content": map[string]any{ - "application/json": map[string]any{ - "example": example, - }, - }, - }, - "401": map[string]any{"description": "Unauthorized - Missing or invalid API key"}, - "403": map[string]any{"description": "Forbidden - Insufficient permissions"}, - }, - } -} - -// withAuthAndBody creates an operation with auth and request body. -func withAuthAndBody(summary, description, tag, scope, requestExample, responseExample string) map[string]any { - return map[string]any{ - "summary": summary, - "description": description + "\n\n**Required scope**: `" + scope + "`", - "tags": []string{tag}, - "security": []map[string]any{ - {"ApiKeyAuth": []string{}}, - }, - "requestBody": map[string]any{ - "required": true, - "content": map[string]any{ - "application/json": map[string]any{ - "example": requestExample, - }, - }, - }, - "responses": map[string]any{ - "201": map[string]any{ - "description": "Created", - "content": map[string]any{ - "application/json": map[string]any{ - "example": responseExample, - }, - }, - }, - "400": map[string]any{"description": "Bad Request - Invalid input"}, - "401": map[string]any{"description": "Unauthorized - Missing or invalid API key"}, - "403": map[string]any{"description": "Forbidden - Insufficient permissions"}, - }, - } -} - -// withAuthAndParams creates an operation with auth and path parameters. -func withAuthAndParams(summary, description, tag, scope string, params []param) map[string]any { - parameters := make([]map[string]any, len(params)) - for i, p := range params { - parameters[i] = map[string]any{ - "name": p.Name, - "in": p.In, - "description": p.Description, - "required": p.Required, - "schema": map[string]any{"type": "string"}, - } - } - return map[string]any{ - "summary": summary, - "description": description + "\n\n**Required scope**: `" + scope + "`", - "tags": []string{tag}, - "security": []map[string]any{ - {"ApiKeyAuth": []string{}}, - }, - "parameters": parameters, - "responses": map[string]any{ - "200": map[string]any{"description": "Success"}, - "401": map[string]any{"description": "Unauthorized - Missing or invalid API key"}, - "403": map[string]any{"description": "Forbidden - Insufficient permissions"}, - "404": map[string]any{"description": "Not Found"}, - }, - } -} - -// withAuthBodyAndParams creates an operation with auth, body, and params. -func withAuthBodyAndParams(summary, description, tag, scope string, params []param, requestExample, responseExample string) map[string]any { - parameters := make([]map[string]any, len(params)) - for i, p := range params { - parameters[i] = map[string]any{ - "name": p.Name, - "in": p.In, - "description": p.Description, - "required": p.Required, - "schema": map[string]any{"type": "string"}, - } - } - return map[string]any{ - "summary": summary, - "description": description + "\n\n**Required scope**: `" + scope + "`", - "tags": []string{tag}, - "security": []map[string]any{ - {"ApiKeyAuth": []string{}}, - }, - "parameters": parameters, - "requestBody": map[string]any{ - "required": true, - "content": map[string]any{ - "application/json": map[string]any{ - "example": requestExample, - }, - }, - }, - "responses": map[string]any{ - "201": map[string]any{ - "description": "Created", - "content": map[string]any{ - "application/json": map[string]any{ - "example": responseExample, - }, - }, - }, - "400": map[string]any{"description": "Bad Request - Invalid input"}, - "401": map[string]any{"description": "Unauthorized - Missing or invalid API key"}, - "403": map[string]any{"description": "Forbidden - Insufficient permissions"}, - }, - } -} diff --git a/cmd/rdev-api/openapi.go b/cmd/rdev-api/openapi.go new file mode 100644 index 0000000..a39b4f3 --- /dev/null +++ b/cmd/rdev-api/openapi.go @@ -0,0 +1,596 @@ +package main + +import "github.com/orchard9/rdev/pkg/api" + +// buildOpenAPISpec creates the OpenAPI specification for the rdev API. +func buildOpenAPISpec() *api.OpenAPISpec { + spec := api.NewOpenAPISpec("rdev API", "0.5.0"). + WithDescription(`Remote Developer API for controlling Claude Code instances in Kubernetes. + +rdev runs Claude Code CLI in isolated pods, controlled via this REST API with SSE streaming. +External clients (Discord bots, Slack bots, CLI tools) connect here to interact with projects. + +## Authentication + +All endpoints except /health, /ready, and /docs require authentication via API key. + +**Header**: `+"`X-API-Key: rdev_sk_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`"+` + +Or: `+"`Authorization: Bearer rdev_sk_...`"+` + +### Getting Started + +1. Set RDEV_ADMIN_KEY environment variable with your super admin key +2. Use the admin key to create additional keys via POST /keys +3. Use created keys for normal operations + +### Scopes + +| Scope | Description | +|-------|-------------| +| projects:read | List and view projects | +| projects:execute | Run commands (claude, shell, git) | +| keys:read | List API keys (metadata only) | +| keys:write | Create and revoke keys | +| audit:read | View audit logs for command executions | +| admin | Full access (all scopes) | + +## Architecture + +- **rdev-api**: This Go service +- **claudebox pods**: Isolated pods running Claude Code CLI +- **postgres**: API key storage (auto-migrating) + +## Streaming + +Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events. +`). + WithServer("http://localhost:8080", "Local development"). + WithServer("http://rdev-api.rdev.svc:8080", "Kubernetes internal") + + // Tags + spec.WithTag("Authentication", "API key management") + spec.WithTag("Projects", "Project management and discovery") + spec.WithTag("Commands", "Command execution (claude, shell, git)") + spec.WithTag("Events", "Server-Sent Events for real-time output") + spec.WithTag("Claude Config", "Manage commands, skills, and agents in /workspace/.claude/") + spec.WithTag("Audit", "Command execution audit logs") + spec.WithTag("System", "Health and readiness endpoints") + + // Register all path operations + registerSystemPaths(spec) + registerKeyPaths(spec) + registerProjectPaths(spec) + registerCommandPaths(spec) + registerEventPaths(spec) + registerClaudeConfigPaths(spec) + registerAuditPaths(spec) + + return spec +} + +func registerSystemPaths(spec *api.OpenAPISpec) { + spec.AddPath("/health", "get", api.Op( + "Health check", + "Returns health status. No authentication required.", + "System", + )) + spec.AddPath("/ready", "get", api.Op( + "Readiness check", + "Returns readiness status. No authentication required.", + "System", + )) +} + +func registerKeyPaths(spec *api.OpenAPISpec) { + spec.AddPath("/keys", "get", withAuth( + "List API keys", + "Returns all API keys with metadata (not secrets). Requires keys:read scope.", + "Authentication", + "keys:read", + `[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "discord-bot", + "key_prefix": "a1b2c3d4", + "scopes": ["projects:read", "projects:execute"], + "created_at": "2026-01-24T20:00:00Z", + "active": true + } +]`, + )) + + spec.AddPath("/keys", "post", withAuthAndBody( + "Create API key", + `Creates a new API key. The secret is returned only once - save it securely! + +**Expiration options**: 30d, 60d, 90d, 1y, never (default: never)`, + "Authentication", + "keys:write", + `{ + "name": "discord-bot", + "scopes": ["projects:read", "projects:execute"], + "project_ids": ["pantheon"], + "expires_in": "90d" +}`, + `{ + "key": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "discord-bot", + "key_prefix": "a1b2c3d4", + "scopes": ["projects:read", "projects:execute"], + "project_ids": ["pantheon"], + "created_at": "2026-01-24T20:00:00Z", + "expires_at": "2026-04-24T20:00:00Z", + "active": true + }, + "secret": "rdev_sk_a1b2c3d4_e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0" +}`, + )) + + spec.AddPath("/keys/{id}", "get", withAuthAndParams( + "Get API key", + "Returns details for a specific API key. Requires keys:read scope.", + "Authentication", + "keys:read", + []param{{Name: "id", In: "path", Description: "Key ID (UUID)", Required: true}}, + )) + + spec.AddPath("/keys/{id}", "delete", withAuthAndParams( + "Revoke API key", + "Revokes an API key immediately. The key cannot be used after revocation. Requires keys:write scope.", + "Authentication", + "keys:write", + []param{{Name: "id", In: "path", Description: "Key ID (UUID)", Required: true}}, + )) +} + +func registerProjectPaths(spec *api.OpenAPISpec) { + spec.AddPath("/projects", "get", withAuth( + "List projects", + "Returns all available projects (claudebox pods). Requires projects:read scope.", + "Projects", + "projects:read", + `[ + { + "id": "pantheon", + "name": "Pantheon", + "description": "Go API backend", + "pod": "claudebox-pantheon-0", + "status": "running" + } +]`, + )) + + spec.AddPath("/projects/{id}", "get", withAuthAndParams( + "Get project", + "Returns details for a specific project. Requires projects:read scope.", + "Projects", + "projects:read", + []param{{Name: "id", In: "path", Description: "Project ID (e.g., 'pantheon')", Required: true}}, + )) +} + +func registerCommandPaths(spec *api.OpenAPISpec) { + spec.AddPath("/projects/{id}/claude", "post", withAuthBodyAndParams( + "Run Claude command", + "Executes a Claude Code prompt in the project's claudebox pod. Requires projects:execute scope.", + "Commands", + "projects:execute", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + `{ + "prompt": "fix the bug in auth handler", + "stream_id": "optional-correlation-id" +}`, + `{ + "id": "cmd-pantheon-001", + "project": "pantheon", + "type": "claude", + "status": "queued", + "stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-001" +}`, + )) + + spec.AddPath("/projects/{id}/shell", "post", withAuthBodyAndParams( + "Run shell command", + "Executes a shell command in the project's claudebox pod. Requires projects:execute scope.", + "Commands", + "projects:execute", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + `{ + "command": "go test ./...", + "stream_id": "optional-correlation-id" +}`, + `{ + "id": "cmd-pantheon-002", + "project": "pantheon", + "type": "shell", + "status": "queued", + "stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-002" +}`, + )) + + spec.AddPath("/projects/{id}/git", "post", withAuthBodyAndParams( + "Run git command", + "Executes a git command in the project's claudebox pod. Requires projects:execute scope.", + "Commands", + "projects:execute", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + `{ + "args": ["status"], + "stream_id": "optional-correlation-id" +}`, + `{ + "id": "cmd-pantheon-003", + "project": "pantheon", + "type": "git", + "status": "queued", + "stream_url": "/projects/pantheon/events?stream_id=cmd-pantheon-003" +}`, + )) +} + +func registerEventPaths(spec *api.OpenAPISpec) { + spec.AddPath("/projects/{id}/events", "get", map[string]any{ + "summary": "Stream events", + "description": `Server-Sent Events stream for real-time command output. + +Requires projects:read scope. + +## Event Types + +- **connected**: Initial connection confirmation +- **output**: Command output (stdout or stderr) +- **error**: Error occurred during execution +- **complete**: Command finished (includes exit_code and duration_ms) +- **heartbeat**: Keep-alive (sent every 30s) + +## Example + +` + "```javascript" + ` +const events = new EventSource('/projects/pantheon/events?stream_id=cmd-001', { + headers: { 'X-API-Key': 'rdev_sk_...' } +}); + +events.addEventListener('output', (e) => { + const data = JSON.parse(e.data); + console.log(data.line); +}); + +events.addEventListener('complete', (e) => { + const data = JSON.parse(e.data); + console.log('Done:', data.exit_code); + events.close(); +}); +` + "```", + "tags": []string{"Events"}, + "security": []map[string]any{ + {"ApiKeyAuth": []string{}}, + }, + "parameters": []map[string]any{ + { + "name": "id", + "in": "path", + "description": "Project ID", + "required": true, + "schema": map[string]any{"type": "string"}, + }, + { + "name": "stream_id", + "in": "query", + "description": "Command ID to filter events (optional)", + "required": false, + "schema": map[string]any{"type": "string"}, + }, + }, + "responses": map[string]any{ + "200": map[string]any{ + "description": "SSE stream", + "content": map[string]any{ + "text/event-stream": map[string]any{ + "schema": map[string]any{ + "type": "string", + "example": "event: output\ndata: {\"line\": \"Building...\", \"stream\": \"stdout\"}\n\n", + }, + }, + }, + }, + }, + }) +} + +func registerClaudeConfigPaths(spec *api.OpenAPISpec) { + // Overview + spec.AddPath("/projects/{id}/claude-config", "get", withAuthAndParams( + "Get config overview", + `Returns an overview of the project's Claude config (/workspace/.claude/). + +Lists available commands, skills, and agents. Requires projects:read scope.`, + "Claude Config", + "projects:read", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + )) + + // Commands + registerConfigTypePaths(spec, "commands", "command", "Commands") + + // Skills + registerConfigTypePaths(spec, "skills", "skill", "Skills") + + // Agents + registerConfigTypePaths(spec, "agents", "agent", "Agents") +} + +func registerConfigTypePaths(spec *api.OpenAPISpec, typePlural, typeSingular, typeTitle string) { + basePath := "/projects/{id}/claude-config/" + typePlural + + spec.AddPath(basePath, "get", withAuthAndParams( + "List "+typePlural, + "Lists all "+typePlural+" in /workspace/.claude/"+typePlural+"/. Requires projects:read scope.", + "Claude Config", + "projects:read", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + )) + + spec.AddPath(basePath, "post", withAuthBodyAndParams( + "Create "+typeSingular, + "Creates a new "+typeSingular+". Requires projects:execute scope.", + "Claude Config", + "projects:execute", + []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, + `{"name": "example", "content": "# Example `+typeTitle+`\n\n..."}`, + `{"name": "example", "type": "`+typePlural+`", "content": "# Example `+typeTitle+`\n\n..."}`, + )) + + spec.AddPath(basePath+"/{name}", "get", withAuthAndParams( + "Get "+typeSingular, + "Returns a specific "+typeSingular+"'s content. Requires projects:read scope.", + "Claude Config", + "projects:read", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "name", In: "path", Description: typeTitle + " name", Required: true}, + }, + )) + + spec.AddPath(basePath+"/{name}", "put", withAuthBodyAndParams( + "Update "+typeSingular, + "Updates a "+typeSingular+"'s content. Requires projects:execute scope.", + "Claude Config", + "projects:execute", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "name", In: "path", Description: typeTitle + " name", Required: true}, + }, + `{"content": "# Updated `+typeTitle+`\n\n..."}`, + `{"name": "example", "type": "`+typePlural+`", "content": "# Updated `+typeTitle+`\n\n..."}`, + )) + + spec.AddPath(basePath+"/{name}", "delete", withAuthAndParams( + "Delete "+typeSingular, + "Deletes a "+typeSingular+". Requires projects:execute scope.", + "Claude Config", + "projects:execute", + []param{ + {Name: "id", In: "path", Description: "Project ID", Required: true}, + {Name: "name", In: "path", Description: typeTitle + " name", Required: true}, + }, + )) +} + +func registerAuditPaths(spec *api.OpenAPISpec) { + spec.AddPath("/audit-log", "get", map[string]any{ + "summary": "List audit log entries", + "description": `Returns audit log entries with optional filtering. + +**Required scope**: ` + "`audit:read`" + ` + +## Query Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| project | string | Filter by project ID | +| api_key | string | Filter by API key ID | +| command_type | string | Filter by type (claude, shell, git) | +| status | string | Filter by status (running, success, error, cancelled) | +| start | string | Filter by start time (RFC3339 format) | +| end | string | Filter by end time (RFC3339 format) | +| limit | int | Max entries to return (default: 100, max: 1000) | +| offset | int | Number of entries to skip for pagination |`, + "tags": []string{"Audit"}, + "security": []map[string]any{ + {"ApiKeyAuth": []string{}}, + }, + "parameters": []map[string]any{ + {"name": "project", "in": "query", "description": "Filter by project ID", "schema": map[string]any{"type": "string"}}, + {"name": "api_key", "in": "query", "description": "Filter by API key ID", "schema": map[string]any{"type": "string"}}, + {"name": "command_type", "in": "query", "description": "Filter by command type", "schema": map[string]any{"type": "string", "enum": []string{"claude", "shell", "git"}}}, + {"name": "status", "in": "query", "description": "Filter by status", "schema": map[string]any{"type": "string", "enum": []string{"running", "success", "error", "cancelled"}}}, + {"name": "start", "in": "query", "description": "Filter by start time (RFC3339)", "schema": map[string]any{"type": "string", "format": "date-time"}}, + {"name": "end", "in": "query", "description": "Filter by end time (RFC3339)", "schema": map[string]any{"type": "string", "format": "date-time"}}, + {"name": "limit", "in": "query", "description": "Max entries (default: 100)", "schema": map[string]any{"type": "integer", "default": 100}}, + {"name": "offset", "in": "query", "description": "Entries to skip", "schema": map[string]any{"type": "integer", "default": 0}}, + }, + "responses": map[string]any{ + "200": map[string]any{ + "description": "Success", + "content": map[string]any{ + "application/json": map[string]any{ + "example": `{ + "entries": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "api_key_id": "key-123", + "command_id": "cmd-pantheon-001", + "project_id": "pantheon", + "command_type": "claude", + "args": "[\"fix the bug\"]", + "client_ip": "192.168.1.100", + "user_agent": "rdev-cli/1.0", + "started_at": "2026-01-25T12:00:00Z", + "completed_at": "2026-01-25T12:01:30Z", + "exit_code": 0, + "duration_ms": 90000, + "status": "success", + "output_size_bytes": 1024, + "created_at": "2026-01-25T12:00:00Z" + } + ], + "total": 1, + "limit": 100, + "offset": 0 +}`, + }, + }, + }, + "401": map[string]any{"description": "Unauthorized - Missing or invalid API key"}, + "403": map[string]any{"description": "Forbidden - Insufficient permissions"}, + }, + }) + + spec.AddPath("/audit-log/{command_id}", "get", withAuthAndParams( + "Get audit log entry", + "Returns a single audit log entry by command ID. Requires audit:read scope.", + "Audit", + "audit:read", + []param{{Name: "command_id", In: "path", Description: "Command ID", Required: true}}, + )) +} + +// param represents an OpenAPI parameter. +type param struct { + Name string + In string + Description string + Required bool +} + +// withAuth creates an operation that requires authentication. +func withAuth(summary, description, tag, scope, example string) map[string]any { + return map[string]any{ + "summary": summary, + "description": description + "\n\n**Required scope**: `" + scope + "`", + "tags": []string{tag}, + "security": []map[string]any{ + {"ApiKeyAuth": []string{}}, + }, + "responses": map[string]any{ + "200": map[string]any{ + "description": "Success", + "content": map[string]any{ + "application/json": map[string]any{ + "example": example, + }, + }, + }, + "401": map[string]any{"description": "Unauthorized - Missing or invalid API key"}, + "403": map[string]any{"description": "Forbidden - Insufficient permissions"}, + }, + } +} + +// withAuthAndBody creates an operation with auth and request body. +func withAuthAndBody(summary, description, tag, scope, requestExample, responseExample string) map[string]any { + return map[string]any{ + "summary": summary, + "description": description + "\n\n**Required scope**: `" + scope + "`", + "tags": []string{tag}, + "security": []map[string]any{ + {"ApiKeyAuth": []string{}}, + }, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "application/json": map[string]any{ + "example": requestExample, + }, + }, + }, + "responses": map[string]any{ + "201": map[string]any{ + "description": "Created", + "content": map[string]any{ + "application/json": map[string]any{ + "example": responseExample, + }, + }, + }, + "400": map[string]any{"description": "Bad Request - Invalid input"}, + "401": map[string]any{"description": "Unauthorized - Missing or invalid API key"}, + "403": map[string]any{"description": "Forbidden - Insufficient permissions"}, + }, + } +} + +// withAuthAndParams creates an operation with auth and path parameters. +func withAuthAndParams(summary, description, tag, scope string, params []param) map[string]any { + parameters := make([]map[string]any, len(params)) + for i, p := range params { + parameters[i] = map[string]any{ + "name": p.Name, + "in": p.In, + "description": p.Description, + "required": p.Required, + "schema": map[string]any{"type": "string"}, + } + } + return map[string]any{ + "summary": summary, + "description": description + "\n\n**Required scope**: `" + scope + "`", + "tags": []string{tag}, + "security": []map[string]any{ + {"ApiKeyAuth": []string{}}, + }, + "parameters": parameters, + "responses": map[string]any{ + "200": map[string]any{"description": "Success"}, + "401": map[string]any{"description": "Unauthorized - Missing or invalid API key"}, + "403": map[string]any{"description": "Forbidden - Insufficient permissions"}, + "404": map[string]any{"description": "Not Found"}, + }, + } +} + +// withAuthBodyAndParams creates an operation with auth, body, and params. +func withAuthBodyAndParams(summary, description, tag, scope string, params []param, requestExample, responseExample string) map[string]any { + parameters := make([]map[string]any, len(params)) + for i, p := range params { + parameters[i] = map[string]any{ + "name": p.Name, + "in": p.In, + "description": p.Description, + "required": p.Required, + "schema": map[string]any{"type": "string"}, + } + } + return map[string]any{ + "summary": summary, + "description": description + "\n\n**Required scope**: `" + scope + "`", + "tags": []string{tag}, + "security": []map[string]any{ + {"ApiKeyAuth": []string{}}, + }, + "parameters": parameters, + "requestBody": map[string]any{ + "required": true, + "content": map[string]any{ + "application/json": map[string]any{ + "example": requestExample, + }, + }, + }, + "responses": map[string]any{ + "201": map[string]any{ + "description": "Created", + "content": map[string]any{ + "application/json": map[string]any{ + "example": responseExample, + }, + }, + }, + "400": map[string]any{"description": "Bad Request - Invalid input"}, + "401": map[string]any{"description": "Unauthorized - Missing or invalid API key"}, + "403": map[string]any{"description": "Forbidden - Insufficient permissions"}, + }, + } +} diff --git a/internal/adapter/deployer/deployer.go b/internal/adapter/deployer/deployer.go index cdfe6b8..5ed8dc0 100644 --- a/internal/adapter/deployer/deployer.go +++ b/internal/adapter/deployer/deployer.go @@ -5,16 +5,11 @@ import ( "bytes" "context" "fmt" - "strings" "time" - appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" - networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/client-go/kubernetes" "github.com/orchard9/rdev/internal/domain" @@ -267,236 +262,3 @@ func (d *Deployer) GetLogs(ctx context.Context, projectName string, tailLines in return buf.String(), nil } - -// Helper methods - -func (d *Deployer) ensureNamespace(ctx context.Context) error { - ns := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: d.config.Namespace, - }, - } - - _, err := d.client.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) - if err != nil && !errors.IsAlreadyExists(err) { - return err - } - return nil -} - -func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeploySpec) error { - secretName := spec.ProjectName + "-env" - ns := d.config.Namespace - - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: ns, - Labels: map[string]string{ - "app": spec.ProjectName, - "project": spec.ProjectName, - }, - }, - StringData: spec.Secrets, - } - - _, err := d.client.CoreV1().Secrets(ns).Get(ctx, secretName, metav1.GetOptions{}) - if errors.IsNotFound(err) { - _, err = d.client.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}) - } else if err == nil { - _, err = d.client.CoreV1().Secrets(ns).Update(ctx, secret, metav1.UpdateOptions{}) - } - return err -} - -func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.DeploySpec) error { - ns := d.config.Namespace - replicas := int32(spec.Replicas) - - // Build env vars - var envVars []corev1.EnvVar - for k, v := range spec.EnvVars { - envVars = append(envVars, corev1.EnvVar{Name: k, Value: v}) - } - - // Add secret env vars - var envFrom []corev1.EnvFromSource - if len(spec.Secrets) > 0 { - envFrom = append(envFrom, corev1.EnvFromSource{ - SecretRef: &corev1.SecretEnvSource{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: spec.ProjectName + "-env", - }, - }, - }) - } - - deployment := &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: spec.ProjectName, - Namespace: ns, - Labels: map[string]string{ - "app": spec.ProjectName, - "project": spec.ProjectName, - }, - }, - Spec: appsv1.DeploymentSpec{ - Replicas: &replicas, - Selector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "app": spec.ProjectName, - }, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "app": spec.ProjectName, - "project": spec.ProjectName, - }, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: spec.ProjectName, - Image: spec.Image, - Env: envVars, - EnvFrom: envFrom, - Ports: []corev1.ContainerPort{ - { - ContainerPort: int32(spec.Port), - Protocol: corev1.ProtocolTCP, - }, - }, - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resourceQuantity("100m"), - corev1.ResourceMemory: resourceQuantity("128Mi"), - }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: resourceQuantity("1000m"), - corev1.ResourceMemory: resourceQuantity("512Mi"), - }, - }, - }, - }, - }, - }, - }, - } - - _, err := d.client.AppsV1().Deployments(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) - if errors.IsNotFound(err) { - _, err = d.client.AppsV1().Deployments(ns).Create(ctx, deployment, metav1.CreateOptions{}) - } else if err == nil { - _, err = d.client.AppsV1().Deployments(ns).Update(ctx, deployment, metav1.UpdateOptions{}) - } - return err -} - -func (d *Deployer) createOrUpdateService(ctx context.Context, spec domain.DeploySpec) error { - ns := d.config.Namespace - - service := &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: spec.ProjectName, - Namespace: ns, - Labels: map[string]string{ - "app": spec.ProjectName, - "project": spec.ProjectName, - }, - }, - Spec: corev1.ServiceSpec{ - Selector: map[string]string{ - "app": spec.ProjectName, - }, - Ports: []corev1.ServicePort{ - { - Port: int32(spec.Port), - TargetPort: intstr.FromInt(spec.Port), - Protocol: corev1.ProtocolTCP, - }, - }, - }, - } - - _, err := d.client.CoreV1().Services(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) - if errors.IsNotFound(err) { - _, err = d.client.CoreV1().Services(ns).Create(ctx, service, metav1.CreateOptions{}) - } else if err == nil { - _, err = d.client.CoreV1().Services(ns).Update(ctx, service, metav1.UpdateOptions{}) - } - return err -} - -func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.DeploySpec) error { - ns := d.config.Namespace - pathType := networkingv1.PathTypePrefix - ingressClass := d.config.IngressClass - - // Build TLS secret name from domain - tlsSecretName := strings.ReplaceAll(spec.Domain, ".", "-") + "-tls" - - annotations := map[string]string{} - if d.config.TLSIssuer != "" { - annotations["cert-manager.io/issuer"] = d.config.TLSIssuer - } - - ingress := &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: spec.ProjectName, - Namespace: ns, - Labels: map[string]string{ - "app": spec.ProjectName, - "project": spec.ProjectName, - }, - Annotations: annotations, - }, - Spec: networkingv1.IngressSpec{ - IngressClassName: &ingressClass, - TLS: []networkingv1.IngressTLS{ - { - Hosts: []string{spec.Domain}, - SecretName: tlsSecretName, - }, - }, - Rules: []networkingv1.IngressRule{ - { - Host: spec.Domain, - IngressRuleValue: networkingv1.IngressRuleValue{ - HTTP: &networkingv1.HTTPIngressRuleValue{ - Paths: []networkingv1.HTTPIngressPath{ - { - Path: "/", - PathType: &pathType, - Backend: networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: spec.ProjectName, - Port: networkingv1.ServiceBackendPort{ - Number: int32(spec.Port), - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - } - - _, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) - if errors.IsNotFound(err) { - _, err = d.client.NetworkingV1().Ingresses(ns).Create(ctx, ingress, metav1.CreateOptions{}) - } else if err == nil { - _, err = d.client.NetworkingV1().Ingresses(ns).Update(ctx, ingress, metav1.UpdateOptions{}) - } - return err -} - -// resourceQuantity parses a resource quantity string. -// Returns the parsed quantity or a zero quantity on error. -func resourceQuantity(s string) resource.Quantity { - q, _ := resource.ParseQuantity(s) - return q -} diff --git a/internal/adapter/deployer/resources.go b/internal/adapter/deployer/resources.go new file mode 100644 index 0000000..a472e0c --- /dev/null +++ b/internal/adapter/deployer/resources.go @@ -0,0 +1,260 @@ +package deployer + +import ( + "context" + "strings" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/orchard9/rdev/internal/domain" +) + +// ensureNamespace creates the deployment namespace if it doesn't exist. +func (d *Deployer) ensureNamespace(ctx context.Context) error { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: d.config.Namespace, + }, + } + + _, err := d.client.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + if err != nil && !errors.IsAlreadyExists(err) { + return err + } + return nil +} + +// createOrUpdateSecret manages the secret for environment variables. +func (d *Deployer) createOrUpdateSecret(ctx context.Context, spec domain.DeploySpec) error { + secretName := spec.ProjectName + "-env" + ns := d.config.Namespace + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: ns, + Labels: map[string]string{ + "app": spec.ProjectName, + "project": spec.ProjectName, + }, + }, + StringData: spec.Secrets, + } + + _, err := d.client.CoreV1().Secrets(ns).Get(ctx, secretName, metav1.GetOptions{}) + if errors.IsNotFound(err) { + _, err = d.client.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}) + } else if err == nil { + _, err = d.client.CoreV1().Secrets(ns).Update(ctx, secret, metav1.UpdateOptions{}) + } + return err +} + +// createOrUpdateDeployment manages the Kubernetes Deployment resource. +func (d *Deployer) createOrUpdateDeployment(ctx context.Context, spec domain.DeploySpec) error { + ns := d.config.Namespace + replicas := int32(spec.Replicas) + + // Build env vars + var envVars []corev1.EnvVar + for k, v := range spec.EnvVars { + envVars = append(envVars, corev1.EnvVar{Name: k, Value: v}) + } + + // Add secret env vars + var envFrom []corev1.EnvFromSource + if len(spec.Secrets) > 0 { + envFrom = append(envFrom, corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: spec.ProjectName + "-env", + }, + }, + }) + } + + deployment := d.buildDeployment(spec, ns, replicas, envVars, envFrom) + + _, err := d.client.AppsV1().Deployments(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) + if errors.IsNotFound(err) { + _, err = d.client.AppsV1().Deployments(ns).Create(ctx, deployment, metav1.CreateOptions{}) + } else if err == nil { + _, err = d.client.AppsV1().Deployments(ns).Update(ctx, deployment, metav1.UpdateOptions{}) + } + return err +} + +func (d *Deployer) buildDeployment(spec domain.DeploySpec, ns string, replicas int32, envVars []corev1.EnvVar, envFrom []corev1.EnvFromSource) *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: spec.ProjectName, + Namespace: ns, + Labels: map[string]string{ + "app": spec.ProjectName, + "project": spec.ProjectName, + }, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": spec.ProjectName, + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "app": spec.ProjectName, + "project": spec.ProjectName, + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: spec.ProjectName, + Image: spec.Image, + Env: envVars, + EnvFrom: envFrom, + Ports: []corev1.ContainerPort{ + { + ContainerPort: int32(spec.Port), + Protocol: corev1.ProtocolTCP, + }, + }, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resourceQuantity("100m"), + corev1.ResourceMemory: resourceQuantity("128Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resourceQuantity("1000m"), + corev1.ResourceMemory: resourceQuantity("512Mi"), + }, + }, + }, + }, + }, + }, + }, + } +} + +// createOrUpdateService manages the Kubernetes Service resource. +func (d *Deployer) createOrUpdateService(ctx context.Context, spec domain.DeploySpec) error { + ns := d.config.Namespace + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: spec.ProjectName, + Namespace: ns, + Labels: map[string]string{ + "app": spec.ProjectName, + "project": spec.ProjectName, + }, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{ + "app": spec.ProjectName, + }, + Ports: []corev1.ServicePort{ + { + Port: int32(spec.Port), + TargetPort: intstr.FromInt(spec.Port), + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } + + _, err := d.client.CoreV1().Services(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) + if errors.IsNotFound(err) { + _, err = d.client.CoreV1().Services(ns).Create(ctx, service, metav1.CreateOptions{}) + } else if err == nil { + _, err = d.client.CoreV1().Services(ns).Update(ctx, service, metav1.UpdateOptions{}) + } + return err +} + +// createOrUpdateIngress manages the Kubernetes Ingress resource. +func (d *Deployer) createOrUpdateIngress(ctx context.Context, spec domain.DeploySpec) error { + ns := d.config.Namespace + pathType := networkingv1.PathTypePrefix + ingressClass := d.config.IngressClass + + // Build TLS secret name from domain + tlsSecretName := strings.ReplaceAll(spec.Domain, ".", "-") + "-tls" + + annotations := map[string]string{} + if d.config.TLSIssuer != "" { + annotations["cert-manager.io/issuer"] = d.config.TLSIssuer + } + + ingress := d.buildIngress(spec, ns, pathType, ingressClass, tlsSecretName, annotations) + + _, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, spec.ProjectName, metav1.GetOptions{}) + if errors.IsNotFound(err) { + _, err = d.client.NetworkingV1().Ingresses(ns).Create(ctx, ingress, metav1.CreateOptions{}) + } else if err == nil { + _, err = d.client.NetworkingV1().Ingresses(ns).Update(ctx, ingress, metav1.UpdateOptions{}) + } + return err +} + +func (d *Deployer) buildIngress(spec domain.DeploySpec, ns string, pathType networkingv1.PathType, ingressClass, tlsSecretName string, annotations map[string]string) *networkingv1.Ingress { + return &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: spec.ProjectName, + Namespace: ns, + Labels: map[string]string{ + "app": spec.ProjectName, + "project": spec.ProjectName, + }, + Annotations: annotations, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: &ingressClass, + TLS: []networkingv1.IngressTLS{ + { + Hosts: []string{spec.Domain}, + SecretName: tlsSecretName, + }, + }, + Rules: []networkingv1.IngressRule{ + { + Host: spec.Domain, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: spec.ProjectName, + Port: networkingv1.ServiceBackendPort{ + Number: int32(spec.Port), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +// resourceQuantity parses a resource quantity string. +// Returns the parsed quantity or a zero quantity on error. +func resourceQuantity(s string) resource.Quantity { + q, _ := resource.ParseQuantity(s) + return q +} diff --git a/internal/handlers/infrastructure.go b/internal/handlers/infrastructure.go index 96df12f..c061ae3 100644 --- a/internal/handlers/infrastructure.go +++ b/internal/handlers/infrastructure.go @@ -231,256 +231,6 @@ func (h *InfrastructureHandler) DeleteRepo(w http.ResponseWriter, r *http.Reques }) } -// DeployRequest is the request body for POST /projects/{id}/deploy. -type DeployRequest struct { - Image string `json:"image"` // Container image - Domain string `json:"domain,omitempty"` // Custom domain (optional) - Port int `json:"port,omitempty"` // Container port (default 8080) - Replicas int `json:"replicas,omitempty"` // Number of replicas (default 1) - EnvVars map[string]string `json:"env_vars,omitempty"` // Plain environment variables - Secrets map[string]string `json:"secrets,omitempty"` // Secret environment variables -} - -// DeployResponse is the response for POST /projects/{id}/deploy. -type DeployResponse struct { - ProjectName string `json:"project_name"` - Image string `json:"image"` - Domain string `json:"domain"` - URL string `json:"url"` - Status string `json:"status"` -} - -// Deploy deploys a project. -// POST /projects/{id}/deploy -func (h *InfrastructureHandler) Deploy(w http.ResponseWriter, r *http.Request) { - projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) - defer cancel() - - // Validate project ID - if err := validateProjectID(projectID); err != nil { - api.WriteBadRequest(w, r, err.Error()) - return - } - - if h.deployer == nil { - api.WriteInternalError(w, r, "deployer not configured") - return - } - - var req DeployRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - api.WriteBadRequest(w, r, "invalid request body") - return - } - - if req.Image == "" { - api.WriteBadRequest(w, r, "image is required") - return - } - - // Build domain - deployDomain := req.Domain - if deployDomain == "" { - deployDomain = projectID + "." + h.defaultDomain - } - - // Create DNS record if DNS provider is configured - if h.dns != nil && h.clusterIP != "" { - _, err := h.dns.CreateRecord(ctx, domain.DNSRecord{ - Type: "A", - Name: projectID, - Content: h.clusterIP, - TTL: 1, - Proxied: false, - }) - if err != nil { - // Check if this is a "record already exists" error (not a real failure) - // Cloudflare returns specific error codes we could check, but for now - // we log and continue - the record might already exist from a previous deploy - // TODO: Add proper duplicate detection once we have structured errors from adapter - _ = err // acknowledge error - may be duplicate record which is acceptable - } - } - - // Deploy - spec := domain.DeploySpec{ - ProjectName: projectID, - Image: req.Image, - Domain: deployDomain, - Port: req.Port, - Replicas: req.Replicas, - EnvVars: req.EnvVars, - Secrets: req.Secrets, - } - - if err := h.deployer.Deploy(ctx, spec); err != nil { - api.WriteInternalError(w, r, fmt.Sprintf("failed to deploy: %v", err)) - return - } - - api.WriteCreated(w, r, DeployResponse{ - ProjectName: projectID, - Image: req.Image, - Domain: deployDomain, - URL: "https://" + deployDomain, - Status: "deploying", - }) -} - -// GetDeployStatus returns the deployment status for a project. -// GET /projects/{id}/deploy/status -func (h *InfrastructureHandler) GetDeployStatus(w http.ResponseWriter, r *http.Request) { - projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() - - if h.deployer == nil { - api.WriteInternalError(w, r, "deployer not configured") - return - } - - status, err := h.deployer.GetStatus(ctx, projectID) - if err != nil { - api.WriteInternalError(w, r, fmt.Sprintf("failed to get status: %v", err)) - return - } - - if status == nil { - api.WriteNotFound(w, r, fmt.Sprintf("no deployment found for project: %s", projectID)) - return - } - - api.WriteSuccess(w, r, map[string]any{ - "project_name": status.ProjectName, - "image": status.Image, - "replicas": status.Replicas, - "ready_replicas": status.ReadyReplicas, - "url": status.URL, - "status": status.Status, - "created_at": status.CreatedAt, - "updated_at": status.UpdatedAt, - }) -} - -// Undeploy removes the deployment for a project. -// DELETE /projects/{id}/deploy -func (h *InfrastructureHandler) Undeploy(w http.ResponseWriter, r *http.Request) { - projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) - defer cancel() - - if h.deployer == nil { - api.WriteInternalError(w, r, "deployer not configured") - return - } - - if err := h.deployer.Undeploy(ctx, projectID); err != nil { - api.WriteInternalError(w, r, fmt.Sprintf("failed to undeploy: %v", err)) - return - } - - // Remove DNS record if DNS provider is configured - if h.dns != nil { - _ = h.dns.DeleteRecordByName(ctx, "A", projectID) - } - - api.WriteSuccess(w, r, map[string]string{ - "status": "undeployed", - "project": projectID, - }) -} - -// RestartDeploy restarts the deployment for a project. -// POST /projects/{id}/deploy/restart -func (h *InfrastructureHandler) RestartDeploy(w http.ResponseWriter, r *http.Request) { - projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) - defer cancel() - - if h.deployer == nil { - api.WriteInternalError(w, r, "deployer not configured") - return - } - - if err := h.deployer.Restart(ctx, projectID); err != nil { - api.WriteInternalError(w, r, fmt.Sprintf("failed to restart: %v", err)) - return - } - - api.WriteSuccess(w, r, map[string]string{ - "status": "restarting", - "project": projectID, - }) -} - -// ScaleRequest is the request body for POST /projects/{id}/deploy/scale. -type ScaleRequest struct { - Replicas int `json:"replicas"` -} - -// ScaleDeploy scales the deployment for a project. -// POST /projects/{id}/deploy/scale -func (h *InfrastructureHandler) ScaleDeploy(w http.ResponseWriter, r *http.Request) { - projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) - defer cancel() - - if h.deployer == nil { - api.WriteInternalError(w, r, "deployer not configured") - return - } - - var req ScaleRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - api.WriteBadRequest(w, r, "invalid request body") - return - } - - if req.Replicas < 0 || req.Replicas > 10 { - api.WriteBadRequest(w, r, "replicas must be between 0 and 10") - return - } - - if err := h.deployer.Scale(ctx, projectID, req.Replicas); err != nil { - api.WriteInternalError(w, r, fmt.Sprintf("failed to scale: %v", err)) - return - } - - api.WriteSuccess(w, r, map[string]any{ - "status": "scaled", - "project": projectID, - "replicas": req.Replicas, - }) -} - -// GetDeployLogs returns recent logs from the deployment. -// GET /projects/{id}/deploy/logs -func (h *InfrastructureHandler) GetDeployLogs(w http.ResponseWriter, r *http.Request) { - projectID := chi.URLParam(r, "id") - ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) - defer cancel() - - if h.deployer == nil { - api.WriteInternalError(w, r, "deployer not configured") - return - } - - // Default to 100 lines - tailLines := 100 - - logs, err := h.deployer.GetLogs(ctx, projectID, tailLines) - if err != nil { - api.WriteInternalError(w, r, fmt.Sprintf("failed to get logs: %v", err)) - return - } - - api.WriteSuccess(w, r, map[string]string{ - "project": projectID, - "logs": logs, - }) -} - // AddDomainRequest is the request body for POST /projects/{id}/domain. type AddDomainRequest struct { Domain string `json:"domain"` // Custom domain (e.g., "myapp.example.com") diff --git a/internal/handlers/infrastructure_deploy.go b/internal/handlers/infrastructure_deploy.go new file mode 100644 index 0000000..e703a6b --- /dev/null +++ b/internal/handlers/infrastructure_deploy.go @@ -0,0 +1,263 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/pkg/api" +) + +// DeployRequest is the request body for POST /projects/{id}/deploy. +type DeployRequest struct { + Image string `json:"image"` // Container image + Domain string `json:"domain,omitempty"` // Custom domain (optional) + Port int `json:"port,omitempty"` // Container port (default 8080) + Replicas int `json:"replicas,omitempty"` // Number of replicas (default 1) + EnvVars map[string]string `json:"env_vars,omitempty"` // Plain environment variables + Secrets map[string]string `json:"secrets,omitempty"` // Secret environment variables +} + +// DeployResponse is the response for POST /projects/{id}/deploy. +type DeployResponse struct { + ProjectName string `json:"project_name"` + Image string `json:"image"` + Domain string `json:"domain"` + URL string `json:"url"` + Status string `json:"status"` +} + +// Deploy deploys a project. +// POST /projects/{id}/deploy +func (h *InfrastructureHandler) Deploy(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + // Validate project ID + if err := validateProjectID(projectID); err != nil { + api.WriteBadRequest(w, r, err.Error()) + return + } + + if h.deployer == nil { + api.WriteInternalError(w, r, "deployer not configured") + return + } + + var req DeployRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if req.Image == "" { + api.WriteBadRequest(w, r, "image is required") + return + } + + // Build domain + deployDomain := req.Domain + if deployDomain == "" { + deployDomain = projectID + "." + h.defaultDomain + } + + // Create DNS record if DNS provider is configured + if h.dns != nil && h.clusterIP != "" { + _, err := h.dns.CreateRecord(ctx, domain.DNSRecord{ + Type: "A", + Name: projectID, + Content: h.clusterIP, + TTL: 1, + Proxied: false, + }) + if err != nil { + // Check if this is a "record already exists" error (not a real failure) + // Cloudflare returns specific error codes we could check, but for now + // we log and continue - the record might already exist from a previous deploy + // TODO: Add proper duplicate detection once we have structured errors from adapter + _ = err // acknowledge error - may be duplicate record which is acceptable + } + } + + // Deploy + spec := domain.DeploySpec{ + ProjectName: projectID, + Image: req.Image, + Domain: deployDomain, + Port: req.Port, + Replicas: req.Replicas, + EnvVars: req.EnvVars, + Secrets: req.Secrets, + } + + if err := h.deployer.Deploy(ctx, spec); err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to deploy: %v", err)) + return + } + + api.WriteCreated(w, r, DeployResponse{ + ProjectName: projectID, + Image: req.Image, + Domain: deployDomain, + URL: "https://" + deployDomain, + Status: "deploying", + }) +} + +// GetDeployStatus returns the deployment status for a project. +// GET /projects/{id}/deploy/status +func (h *InfrastructureHandler) GetDeployStatus(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + if h.deployer == nil { + api.WriteInternalError(w, r, "deployer not configured") + return + } + + status, err := h.deployer.GetStatus(ctx, projectID) + if err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to get status: %v", err)) + return + } + + if status == nil { + api.WriteNotFound(w, r, fmt.Sprintf("no deployment found for project: %s", projectID)) + return + } + + api.WriteSuccess(w, r, map[string]any{ + "project_name": status.ProjectName, + "image": status.Image, + "replicas": status.Replicas, + "ready_replicas": status.ReadyReplicas, + "url": status.URL, + "status": status.Status, + "created_at": status.CreatedAt, + "updated_at": status.UpdatedAt, + }) +} + +// Undeploy removes the deployment for a project. +// DELETE /projects/{id}/deploy +func (h *InfrastructureHandler) Undeploy(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + if h.deployer == nil { + api.WriteInternalError(w, r, "deployer not configured") + return + } + + if err := h.deployer.Undeploy(ctx, projectID); err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to undeploy: %v", err)) + return + } + + // Remove DNS record if DNS provider is configured + if h.dns != nil { + _ = h.dns.DeleteRecordByName(ctx, "A", projectID) + } + + api.WriteSuccess(w, r, map[string]string{ + "status": "undeployed", + "project": projectID, + }) +} + +// RestartDeploy restarts the deployment for a project. +// POST /projects/{id}/deploy/restart +func (h *InfrastructureHandler) RestartDeploy(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + if h.deployer == nil { + api.WriteInternalError(w, r, "deployer not configured") + return + } + + if err := h.deployer.Restart(ctx, projectID); err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to restart: %v", err)) + return + } + + api.WriteSuccess(w, r, map[string]string{ + "status": "restarting", + "project": projectID, + }) +} + +// ScaleRequest is the request body for POST /projects/{id}/deploy/scale. +type ScaleRequest struct { + Replicas int `json:"replicas"` +} + +// ScaleDeploy scales the deployment for a project. +// POST /projects/{id}/deploy/scale +func (h *InfrastructureHandler) ScaleDeploy(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + if h.deployer == nil { + api.WriteInternalError(w, r, "deployer not configured") + return + } + + var req ScaleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if req.Replicas < 0 || req.Replicas > 10 { + api.WriteBadRequest(w, r, "replicas must be between 0 and 10") + return + } + + if err := h.deployer.Scale(ctx, projectID, req.Replicas); err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to scale: %v", err)) + return + } + + api.WriteSuccess(w, r, map[string]any{ + "status": "scaled", + "project": projectID, + "replicas": req.Replicas, + }) +} + +// GetDeployLogs returns recent logs from the deployment. +// GET /projects/{id}/deploy/logs +func (h *InfrastructureHandler) GetDeployLogs(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + if h.deployer == nil { + api.WriteInternalError(w, r, "deployer not configured") + return + } + + // Default to 100 lines + tailLines := 100 + + logs, err := h.deployer.GetLogs(ctx, projectID, tailLines) + if err != nil { + api.WriteInternalError(w, r, fmt.Sprintf("failed to get logs: %v", err)) + return + } + + api.WriteSuccess(w, r, map[string]string{ + "project": projectID, + "logs": logs, + }) +}