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:
parent
0fd4e32073
commit
812b8341be
@ -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
596
cmd/rdev-api/openapi.go
Normal 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"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
260
internal/adapter/deployer/resources.go
Normal file
260
internal/adapter/deployer/resources.go
Normal 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
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
|||||||
263
internal/handlers/infrastructure_deploy.go
Normal file
263
internal/handlers/infrastructure_deploy.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user