refactor: Split large files to comply with 500-line limit

- 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 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-01-25 23:02:31 -07:00
parent 0fd4e32073
commit 812b8341be
6 changed files with 1119 additions and 1175 deletions

View File

@ -384,690 +384,3 @@ func getEnv(key, defaultVal string) string {
} }
return defaultVal 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"},
},
}
}

596
cmd/rdev-api/openapi.go Normal file
View File

@ -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"},
},
}
}

View File

@ -5,16 +5,11 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
"k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"github.com/orchard9/rdev/internal/domain" "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 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
}

View File

@ -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
}

View File

@ -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. // AddDomainRequest is the request body for POST /projects/{id}/domain.
type AddDomainRequest struct { type AddDomainRequest struct {
Domain string `json:"domain"` // Custom domain (e.g., "myapp.example.com") Domain string `json:"domain"` // Custom domain (e.g., "myapp.example.com")

View File

@ -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,
})
}