rdev/cmd/rdev-api/openapi.go
jordan adcea2fc1f
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(templates): upgrade Go to 1.25 and fix Woodpecker syntax
## Template Version Alignment
- Go: 1.23 → 1.25 across all templates (go.work, go.mod, Dockerfiles, CI)
- Alpine: latest → 3.19 (explicit version pinning)
- Woodpecker: failure:retry → failure:ignore (invalid syntax fix)

## SDLC Tree Fixes (slackpath-5-full-lifecycle)
Fixed merge failures by correcting lifecycle flow:

1. **Branch Creation**: Added missing create-branch step (planned → ready)
   - Bug: Merge command requires feature.Branch field to be set
   - Fix: POST /projects/{id}/sdlc/features/{slug}/branch

2. **Artifact Status**: Changed approval to pass for execution artifacts
   - Bug: Review/audit/QA need status="passed" not "approved"
   - Fix: /artifacts/{type}/approve → /artifacts/{type}/pass
   - Added: pass-qa step after wait-qa

3. **Phase Transition Order**: Reordered merge phase transition
   - Bug: Merge command checks if phase == "merge" first
   - Fix: transition-to-merge BEFORE merge-feature (not after)

## GCS Provisioner Fix
- Replaced deprecated option.WithCredentialsFile with env var approach
- Now uses GOOGLE_APPLICATION_CREDENTIALS for ADC (Application Default Credentials)
- Avoids security risk from deprecated credential options
- Fixed test: Added ComponentTypeGCS to ValidComponentTypes test

## Critical Rules Added
- Version alignment: All template versions must stay in sync
- When updating versions, grep entire templates/ tree

## Files Changed
- 27 template files: Go version + Woodpecker syntax
- 1 tree file: SDLC lifecycle flow corrections
- 1 CLAUDE.md: Version alignment rule
- 1 GCS provisioner: Deprecated API fix
- 1 test file: Added missing component type

Root cause: Skeleton templates lagged behind Go 1.25 release and had
invalid Woodpecker syntax. SDLC tree skipped required branch creation
and used wrong artifact approval endpoints.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 23:57:38 -07:00

561 lines
18 KiB
Go

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("Code Agents", "Multi-provider code agent management")
spec.WithTag("Workers", "Worker pool management")
spec.WithTag("Builds", "Build orchestration and history")
spec.WithTag("SDLC", "Software Development Lifecycle orchestration")
spec.WithTag("System", "Health and readiness endpoints")
spec.WithTag("Components", "Composable monorepo component management")
spec.WithTag("Credentials", "Infrastructure credential management")
spec.WithTag("Diagnostics", "Project health and diagnostics")
spec.WithTag("Verify", "Visual verification with Playwright")
spec.WithTag("Webhooks", "External webhook receivers")
spec.WithTag("Infrastructure", "Git, deployment, DNS, and CI pipeline management")
spec.WithTag("Sagas", "Distributed workflow orchestration with compensation")
// Register all path operations
registerSystemPaths(spec)
registerKeyPaths(spec)
registerProjectPaths(spec)
registerCommandPaths(spec)
registerEventPaths(spec)
registerClaudeConfigPaths(spec)
registerAuditPaths(spec)
registerAgentPaths(spec)
registerWorkerPaths(spec)
registerBuildPaths(spec)
registerSDLCPaths(spec)
registerComponentPaths(spec)
registerCredentialPaths(spec)
registerDiagnosticsPaths(spec)
registerVerifyPaths(spec)
registerWebhookPaths(spec)
registerInfrastructurePaths(spec)
registerSagaPaths(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}},
))
spec.AddPath("/projects/cleanup", "delete", map[string]any{
"summary": "Cleanup test projects",
"description": `Deletes test projects matching the given name patterns that are older than a specified age.
This is useful for cleaning up orphaned test projects created by cookbook scripts.
**Required scope**: ` + "`admin`" + `
## Pattern Syntax
Patterns support shell-style globs with ` + "`*`" + ` wildcards:
- ` + "`tree-test-*`" + ` matches ` + "`tree-test-123456`" + `
- ` + "`landing-test-*`" + ` matches ` + "`landing-test-1706889600`" + `
- ` + "`*-validation-*`" + ` matches ` + "`template-validation-abc`" + `
## Safety
- Use ` + "`dry_run: true`" + ` first to preview what would be deleted
- Only projects older than ` + "`older_than_hours`" + ` are affected
- Git repos are preserved; only K8s resources and DB records are removed`,
"tags": []string{"Projects"},
"security": []map[string]any{
{"ApiKeyAuth": []string{}},
},
"requestBody": map[string]any{
"required": true,
"content": map[string]any{
"application/json": map[string]any{
"schema": map[string]any{
"type": "object",
"required": []string{"patterns"},
"properties": map[string]any{
"patterns": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
"description": "Name patterns to match (e.g., \"tree-test-*\", \"landing-test-*\")",
"example": []string{"tree-test-*", "landing-test-*", "template-validation-*"},
},
"older_than_hours": map[string]any{
"type": "integer",
"description": "Only delete projects older than this many hours (default: 0)",
"example": 1,
},
"dry_run": map[string]any{
"type": "boolean",
"description": "If true, don't actually delete, just return what would be deleted",
"example": true,
},
},
},
"example": `{
"patterns": ["tree-test-*", "landing-test-*", "template-validation-*"],
"older_than_hours": 1,
"dry_run": true
}`,
},
},
},
"responses": map[string]any{
"200": map[string]any{
"description": "Success",
"content": map[string]any{
"application/json": map[string]any{
"example": `{
"deleted": ["tree-test-1706889600", "landing-test-1706889601"],
"count": 2,
"dry_run": true
}`,
},
},
},
"400": map[string]any{"description": "Bad Request - Missing patterns or invalid input"},
"401": map[string]any{"description": "Unauthorized - Missing or invalid API key"},
"403": map[string]any{"description": "Forbidden - Requires admin scope"},
},
})
}
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. Supports session continuation, model selection (OpenCode), and tool restrictions. 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",
"session_id": "prev-session-123",
"model": "claude-sonnet-4-20250514",
"allowed_tools": ["Read", "Write", "Bash(git:*)"]
}`,
`{
"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}},
))
}