rdev/cmd/rdev-api/main.go
jordan 39df51defd feat: Add multi-provider code agent interface with Claude Code and OpenCode adapters
Implements weeks 1-4 of the multi-provider architecture:

Week 1 - Foundation:
- Add domain models (AgentProvider, AgentRequest, AgentEvent, AgentResult)
- Define CodeAgent port interface with Execute, Cancel, Capabilities
- Create thread-safe provider registry with first-registered default

Week 2 - Claude Code Adapter:
- Extract kubectl exec logic into CodeAgent implementation
- Parse stream-json output format (init, message, tool_use, result)
- Support session continuation via --resume flag

Week 3 - OpenCode Adapter:
- HTTP/SSE client for opencode serve API
- Session management (create, send message, abort)
- Event streaming with documented buffer rationale

Week 4 - Quality & Polish:
- Fix race condition in OpenCode Cancel method
- Add AgentRequest.Validate() with ErrPromptRequired, ErrInvalidTimeout
- Document DefaultAvailabilityTimeout constants
- Add HTTP error context for debugging

Also includes:
- Work queue system with PostgreSQL adapter
- Credential store for infrastructure secrets
- Project templates with Woodpecker CI integration
- Comprehensive test coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 09:25:51 -07:00

531 lines
18 KiB
Go

// Package main provides the entry point for the rdev API server.
//
// rdev (Remote Developer) provides a REST API for controlling Claude Code
// instances running in Kubernetes pods. External clients (Discord bots,
// CLI tools, etc.) connect via this API.
//
// Authentication:
// - All endpoints (except /health, /ready, /docs) require X-API-Key header
// - Admin key from RDEV_ADMIN_KEY env var for key management
// - Create additional keys via POST /keys
//
// Endpoints:
// - GET /health - Health check (no auth)
// - GET /ready - Readiness check (no auth)
// - GET /docs - Scalar API documentation (no auth)
// - GET /openapi.json - OpenAPI 3.0 specification (no auth)
// - GET /keys - List API keys
// - POST /keys - Create API key
// - GET /keys/{id} - Get API key details
// - DELETE /keys/{id} - Revoke API key
// - GET /projects - List available projects
// - GET /projects/{id} - Get project details
// - POST /projects/{id}/claude - Run Claude command
// - POST /projects/{id}/shell - Run shell command
// - POST /projects/{id}/git - Run git command
// - GET /projects/{id}/events - SSE stream for output
// - GET /projects/{id}/claude-config - List commands/skills/agents
// - GET /projects/{id}/claude-config/commands - List commands
// - POST /projects/{id}/claude-config/commands - Create command
// - GET /projects/{id}/claude-config/commands/{name} - Get command
// - PUT /projects/{id}/claude-config/commands/{name} - Update command
// - DELETE /projects/{id}/claude-config/commands/{name} - Delete command
// (same pattern for /skills and /agents)
package main
import (
"context"
"log/slog"
"os"
"strconv"
"time"
"github.com/orchard9/rdev/internal/adapter/cloudflare"
"github.com/orchard9/rdev/internal/adapter/deployer"
"github.com/orchard9/rdev/internal/adapter/gitea"
"github.com/orchard9/rdev/internal/adapter/kubernetes"
"github.com/orchard9/rdev/internal/adapter/memory"
"github.com/orchard9/rdev/internal/adapter/postgres"
"github.com/orchard9/rdev/internal/adapter/templates"
"github.com/orchard9/rdev/internal/adapter/woodpecker"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/db"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/handlers"
"github.com/orchard9/rdev/internal/metrics"
"github.com/orchard9/rdev/internal/middleware"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/telemetry"
"github.com/orchard9/rdev/internal/webhook"
"github.com/orchard9/rdev/internal/worker"
"github.com/orchard9/rdev/pkg/api"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
// Initialize telemetry (OpenTelemetry)
telCfg := telemetry.DefaultConfig()
telCfg.Logger = logger
tel, err := telemetry.New(context.Background(), telCfg)
if err != nil {
logger.Error("failed to initialize telemetry", "error", err)
os.Exit(1)
}
// Load configuration from environment
cfg := loadConfig()
// Validate required security configuration
if cfg.CredentialEncryptionKey == "" {
logger.Warn("CREDENTIAL_ENCRYPTION_KEY not set - credential store will use insecure default",
"hint", "Generate with: openssl rand -base64 32")
// Use a deterministic fallback for development only
cfg.CredentialEncryptionKey = "rdev-dev-key-not-for-production"
}
// Initialize database with auto-migrations
database, err := db.New(db.Config{
Host: cfg.DBHost,
Port: cfg.DBPort,
User: cfg.DBUser,
Password: cfg.DBPassword,
Database: cfg.DBName,
SSLMode: cfg.DBSSLMode,
}, logger)
if err != nil {
logger.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer func() { _ = database.Close() }()
// Initialize auth service
authService := auth.NewService(database.DB, cfg.AdminKey)
// Initialize credential store (for infrastructure secrets)
credentialStore := postgres.NewCredentialStore(database.DB, cfg.CredentialEncryptionKey)
// Load infrastructure config from credential store (falls back to env vars)
infraCfg := loadInfraConfig(context.Background(), credentialStore, cfg, logger)
// Create adapters (dependency injection)
namespace := getEnv("K8S_NAMESPACE", "rdev")
// Initialize K8s client for dynamic project discovery
// Falls back gracefully if K8s is unavailable (e.g., local development)
k8sClient := kubernetes.NewClientOrNil(kubernetes.ClientConfig{
Namespace: namespace,
Kubeconfig: os.Getenv("KUBECONFIG"),
})
if k8sClient != nil {
logger.Info("k8s client initialized, dynamic project discovery enabled")
} else {
logger.Warn("k8s client unavailable, using hardcoded fallback projects")
}
projectRepo := kubernetes.NewProjectRepositoryWithClient(namespace, k8sClient, logger)
k8sExecutor := kubernetes.NewExecutor(namespace)
streamPub := memory.NewStreamPublisher()
// Start watching for project pod changes if K8s client is available
if k8sClient != nil {
if err := projectRepo.StartWatching(context.Background()); err != nil {
logger.Warn("failed to start project watcher", "error", err)
}
}
// Initialize audit logger
auditLogger := postgres.NewAuditLogger(database.DB)
// Initialize rate limiter
rateLimiter := postgres.NewRateLimiter(database.DB)
stopRateLimitCleanup := rateLimiter.StartCleanupWorker(context.Background(), 5*time.Minute)
// Initialize command queue
commandQueue := postgres.NewCommandQueueRepository(database.DB)
// Initialize work queue (for worker pool tasks)
workQueueRepo := postgres.NewWorkQueueRepository(database.DB)
// Initialize webhook repository and dispatcher
webhookRepo := postgres.NewWebhookRepository(database.DB)
webhookDispatcher := webhook.NewDispatcher(webhookRepo, &webhook.DispatcherConfig{
WorkerCount: 10,
MaxRetries: 3,
Timeout: 30 * time.Second,
RetryBackoff: 5 * time.Second,
Logger: logger,
})
if err := webhookDispatcher.Start(); err != nil {
logger.Error("failed to start webhook dispatcher", "error", err)
os.Exit(1)
}
// Initialize infrastructure adapters (optional - only if configured)
// Uses infraCfg which loads from credential store with env var fallback
var giteaClient *gitea.Client
if infraCfg.GiteaToken != "" && infraCfg.GiteaURL != "" {
var err error
giteaClient, err = gitea.NewClient(infraCfg.GiteaURL, infraCfg.GiteaToken, infraCfg.GiteaDefaultOrg)
if err != nil {
logger.Warn("failed to initialize gitea client", "error", err)
} else {
logger.Info("gitea client initialized", "url", infraCfg.GiteaURL, "org", infraCfg.GiteaDefaultOrg)
}
}
var dnsClient *cloudflare.Client
if infraCfg.CloudflareToken != "" && infraCfg.CloudflareZoneID != "" {
dnsClient = cloudflare.NewClient(infraCfg.CloudflareToken, infraCfg.CloudflareZoneID, infraCfg.DefaultDomain)
logger.Info("cloudflare DNS client initialized", "domain", infraCfg.DefaultDomain)
}
var deployerAdapter *deployer.Deployer
if k8sClient != nil {
deployerAdapter = deployer.NewDeployer(k8sClient, deployer.Config{
Namespace: infraCfg.DeployNamespace,
IngressClass: "traefik",
TLSIssuer: infraCfg.DeployTLSIssuer,
DefaultDomain: infraCfg.DefaultDomain,
DefaultReplicas: 1,
})
logger.Info("deployer initialized", "namespace", infraCfg.DeployNamespace)
}
var woodpeckerClient *woodpecker.Client
if infraCfg.WoodpeckerURL != "" && infraCfg.WoodpeckerAPIToken != "" {
var err error
woodpeckerClient, err = woodpecker.NewClient(
infraCfg.WoodpeckerURL,
infraCfg.WoodpeckerAPIToken,
woodpecker.WithLogger(logger),
)
if err != nil {
logger.Warn("failed to initialize woodpecker client", "error", err)
} else {
logger.Info("woodpecker CI client initialized", "url", infraCfg.WoodpeckerURL)
}
}
// Initialize template provider (requires Gitea client for seeding repos)
var templateProvider *templates.Provider
if giteaClient != nil {
// Get the underlying Gitea SDK client for the template provider
templateProvider = templates.NewProvider(giteaClient.SDKClient(), logger)
logger.Info("template provider initialized")
}
// Create services
projectService := service.NewProjectService(projectRepo, k8sExecutor, streamPub).
WithAuditLogger(auditLogger).
WithCommandQueue(commandQueue).
WithWebhookDispatcher(webhookDispatcher)
// Create work service (for worker pool task management)
workService := service.NewWorkService(workQueueRepo, service.WorkServiceConfig{
Logger: logger,
}).WithWebhookDispatcher(webhookDispatcher)
// Create app
app := api.New("rdev-api",
api.WithPort(cfg.Port),
api.WithLogger(logger),
)
// Add telemetry middleware (first to capture all requests)
app.Use(telemetry.Middleware(telCfg.ServiceName))
// Add metrics middleware (before auth to track all requests)
app.Use(metrics.Middleware)
// Add auth middleware (skips /health, /ready, /docs, /openapi.json, /metrics)
app.Use(auth.Middleware(authService))
// Add rate limiting middleware (after auth, so we have API key context)
rateLimitCfg := middleware.DefaultRateLimitConfig()
rateLimitCfg.Limiter = rateLimiter
app.Use(middleware.RateLimitMiddleware(rateLimitCfg))
// Register metrics endpoint (no auth required)
app.Router().Handle("/metrics", metrics.Handler())
// Initialize handlers
projectsHandler := handlers.NewProjectsHandlerWithService(projectService)
keysHandler := handlers.NewKeysHandler(authService)
claudeConfigHandler := handlers.NewClaudeConfigHandlerWithService(projectService, projectRepo, k8sExecutor)
auditHandler := handlers.NewAuditHandler(auditLogger)
queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo)
webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo)
workHandler := handlers.NewWorkHandler(workService)
// Initialize infrastructure handler (for threesix.ai git/deploy/dns)
infraHandler := handlers.NewInfrastructureHandler(
giteaClient,
dnsClient,
deployerAdapter,
projectRepo,
handlers.InfrastructureConfig{
DefaultGitOwner: infraCfg.GiteaDefaultOrg,
DefaultDomain: infraCfg.DefaultDomain,
},
)
// Initialize project infrastructure service (orchestrates full project lifecycle)
projectInfraService := service.NewProjectInfraService(
database.DB,
giteaClient,
dnsClient,
deployerAdapter,
woodpeckerClient, // CI provider for auto-activating repos
templateProvider, // Template provider for seeding repos
service.ProjectInfraConfig{
DefaultGitOwner: infraCfg.GiteaDefaultOrg,
DefaultDomain: infraCfg.DefaultDomain,
ClusterIP: infraCfg.ClusterIP,
Logger: logger,
},
)
// Initialize project management handler
projectMgmtHandler := handlers.NewProjectManagementHandler(projectInfraService)
// Initialize Woodpecker webhook handler (for CI/CD auto-deploy)
woodpeckerHandler := handlers.NewWoodpeckerWebhookHandler(
deployerAdapter,
dnsClient,
handlers.WoodpeckerWebhookConfig{
WebhookSecret: infraCfg.WoodpeckerWebhookSecret,
DefaultDomain: infraCfg.DefaultDomain,
RegistryURL: infraCfg.RegistryURL,
ClusterIP: infraCfg.ClusterIP,
Logger: logger,
},
)
// Initialize credentials handler (superadmin only)
credentialsHandler := handlers.NewCredentialsHandler(credentialStore)
// Register routes
projectsHandler.Mount(app.Router())
keysHandler.Mount(app.Router())
claudeConfigHandler.Mount(app.Router())
auditHandler.Mount(app.Router())
queueHandler.Mount(app.Router())
webhookHandler.Mount(app.Router())
workHandler.Mount(app.Router())
infraHandler.Mount(app.Router())
projectMgmtHandler.Mount(app.Router())
woodpeckerHandler.Mount(app.Router())
credentialsHandler.Mount(app.Router())
// Start queue processor worker
queueProcessor := worker.NewQueueProcessor(
commandQueue,
k8sExecutor,
projectRepo,
streamPub,
&worker.QueueProcessorConfig{
PollPeriod: 5 * time.Second,
Logger: logger,
},
).WithWebhookDispatcher(webhookDispatcher)
if err := queueProcessor.Start(); err != nil {
logger.Error("failed to start queue processor", "error", err)
os.Exit(1)
}
// Enable API documentation
app.EnableDocs(buildOpenAPISpec())
// Cleanup on shutdown
app.OnShutdown(func(ctx context.Context) error {
// Stop queue processor
queueProcessor.Stop()
// Stop webhook dispatcher
webhookDispatcher.Stop()
// Stop project watcher
projectRepo.StopWatching()
// Stop rate limit cleanup worker
stopRateLimitCleanup()
// Shutdown telemetry (flush pending traces)
if err := tel.Shutdown(ctx); err != nil {
logger.Error("telemetry shutdown error", "error", err)
}
return database.Close()
})
logger.Info("rdev-api starting",
"port", cfg.Port,
"db_host", cfg.DBHost,
"admin_key_set", cfg.AdminKey != "",
)
app.Run()
}
// Config holds application configuration.
type Config struct {
Port int
DBHost string
DBPort int
DBUser string
DBPassword string
DBName string
DBSSLMode string
AdminKey string
// Credential store encryption key (required for storing secrets in DB)
CredentialEncryptionKey string
// Infrastructure adapters (threesix.ai) - fallback values if not in credential store
GiteaURL string
GiteaToken string
GiteaDefaultOrg string
CloudflareToken string
CloudflareZoneID string
DefaultDomain string
DeployNamespace string
DeployTLSIssuer string
ClusterIP string
RegistryURL string
WoodpeckerURL string
WoodpeckerAPIToken string
WoodpeckerWebhookSecret string
}
// InfraConfig holds infrastructure adapter configuration.
// Loaded from credential store with env var fallback.
type InfraConfig struct {
GiteaURL string
GiteaToken string
GiteaDefaultOrg string
CloudflareToken string
CloudflareZoneID string
DefaultDomain string
DeployNamespace string
DeployTLSIssuer string
ClusterIP string
RegistryURL string
WoodpeckerURL string
WoodpeckerAPIToken string
WoodpeckerWebhookSecret string
}
func loadConfig() Config {
port := 8080
if v := os.Getenv("PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
port = p
}
}
dbPort := 5432
if v := os.Getenv("DB_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
dbPort = p
}
}
return Config{
Port: port,
DBHost: getEnv("DB_HOST", "postgres.databases.svc"),
DBPort: dbPort,
DBUser: getEnv("DB_USER", "appuser"),
DBPassword: os.Getenv("DB_PASSWORD"),
DBName: getEnv("DB_NAME", "rdev"),
DBSSLMode: getEnv("DB_SSL_MODE", "disable"),
AdminKey: os.Getenv("RDEV_ADMIN_KEY"),
// Encryption key for credential store (generate with: openssl rand -base64 32)
// REQUIRED in production - no default to prevent insecure deployments
CredentialEncryptionKey: os.Getenv("CREDENTIAL_ENCRYPTION_KEY"),
// Infrastructure adapters (fallback if not in credential store)
GiteaURL: getEnv("GITEA_URL", "https://git.threesix.ai"),
GiteaToken: os.Getenv("GITEA_TOKEN"),
GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "jordan"),
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
DefaultDomain: getEnv("DEFAULT_DOMAIN", "threesix.ai"),
DeployNamespace: getEnv("DEPLOY_NAMESPACE", "projects"),
DeployTLSIssuer: getEnv("DEPLOY_TLS_ISSUER", "letsencrypt-threesix"),
ClusterIP: getEnv("CLUSTER_IP", "208.122.204.172"),
RegistryURL: getEnv("REGISTRY_URL", "zot.threesix.svc.cluster.local:5000"),
WoodpeckerURL: getEnv("WOODPECKER_URL", "https://ci.threesix.ai"),
WoodpeckerAPIToken: os.Getenv("WOODPECKER_API_TOKEN"),
WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"),
}
}
func getEnv(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
// loadInfraConfig loads infrastructure configuration from credential store,
// falling back to environment variables if not found in the store.
func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config, logger *slog.Logger) InfraConfig {
// Try to load from credential store
creds, err := store.GetMultiple(ctx, []string{
domain.CredKeyGiteaToken,
domain.CredKeyGiteaURL,
domain.CredKeyCloudflareAPIToken,
domain.CredKeyCloudflareZoneID,
domain.CredKeyWoodpeckerURL,
domain.CredKeyWoodpeckerAPIToken,
domain.CredKeyWoodpeckerWebhookSecret,
domain.CredKeyRegistryURL,
})
if err != nil {
logger.Warn("failed to load credentials from store, using env vars", "error", err)
creds = make(map[string]string)
}
// Helper to get from store or fall back to env var
getOrFallback := func(key, envFallback string) string {
if v, ok := creds[key]; ok && v != "" {
return v
}
return envFallback
}
infraCfg := InfraConfig{
GiteaURL: getOrFallback(domain.CredKeyGiteaURL, cfg.GiteaURL),
GiteaToken: getOrFallback(domain.CredKeyGiteaToken, cfg.GiteaToken),
GiteaDefaultOrg: cfg.GiteaDefaultOrg, // Not a secret, use env
CloudflareToken: getOrFallback(domain.CredKeyCloudflareAPIToken, cfg.CloudflareToken),
CloudflareZoneID: getOrFallback(domain.CredKeyCloudflareZoneID, cfg.CloudflareZoneID),
DefaultDomain: cfg.DefaultDomain, // Not a secret, use env
DeployNamespace: cfg.DeployNamespace, // Not a secret, use env
DeployTLSIssuer: cfg.DeployTLSIssuer, // Not a secret, use env
ClusterIP: cfg.ClusterIP, // Not a secret, use env
RegistryURL: getOrFallback(domain.CredKeyRegistryURL, cfg.RegistryURL),
WoodpeckerURL: getOrFallback(domain.CredKeyWoodpeckerURL, cfg.WoodpeckerURL),
WoodpeckerAPIToken: getOrFallback(domain.CredKeyWoodpeckerAPIToken, cfg.WoodpeckerAPIToken),
WoodpeckerWebhookSecret: getOrFallback(domain.CredKeyWoodpeckerWebhookSecret, cfg.WoodpeckerWebhookSecret),
}
// Log which credentials were loaded from store vs env
fromStore := 0
for k := range creds {
if creds[k] != "" {
fromStore++
}
}
if fromStore > 0 {
logger.Info("loaded credentials from store", "count", fromStore)
}
return infraCfg
}