// 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/auth" "github.com/orchard9/rdev/internal/db" "github.com/orchard9/rdev/internal/handlers" "github.com/orchard9/rdev/internal/metrics" "github.com/orchard9/rdev/internal/middleware" "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() // 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) // 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 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) var giteaClient *gitea.Client if cfg.GiteaToken != "" && cfg.GiteaURL != "" { var err error giteaClient, err = gitea.NewClient(cfg.GiteaURL, cfg.GiteaToken, cfg.GiteaDefaultOrg) if err != nil { logger.Warn("failed to initialize gitea client", "error", err) } else { logger.Info("gitea client initialized", "url", cfg.GiteaURL, "org", cfg.GiteaDefaultOrg) } } var dnsClient *cloudflare.Client if cfg.CloudflareToken != "" && cfg.CloudflareZoneID != "" { dnsClient = cloudflare.NewClient(cfg.CloudflareToken, cfg.CloudflareZoneID, cfg.DefaultDomain) logger.Info("cloudflare DNS client initialized", "domain", cfg.DefaultDomain) } var deployerAdapter *deployer.Deployer if k8sClient != nil { deployerAdapter = deployer.NewDeployer(k8sClient, deployer.Config{ Namespace: cfg.DeployNamespace, IngressClass: "traefik", TLSIssuer: cfg.DeployTLSIssuer, DefaultDomain: cfg.DefaultDomain, DefaultReplicas: 1, }) logger.Info("deployer initialized", "namespace", cfg.DeployNamespace) } // Create services projectService := service.NewProjectService(projectRepo, k8sExecutor, streamPub). WithAuditLogger(auditLogger). WithCommandQueue(commandQueue). 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) // Initialize infrastructure handler (for threesix.ai git/deploy/dns) infraHandler := handlers.NewInfrastructureHandler( giteaClient, dnsClient, deployerAdapter, projectRepo, handlers.InfrastructureConfig{ DefaultGitOwner: cfg.GiteaDefaultOrg, DefaultDomain: cfg.DefaultDomain, }, ) // Initialize project infrastructure service (orchestrates full project lifecycle) projectInfraService := service.NewProjectInfraService( database.DB, giteaClient, dnsClient, deployerAdapter, service.ProjectInfraConfig{ DefaultGitOwner: cfg.GiteaDefaultOrg, DefaultDomain: cfg.DefaultDomain, ClusterIP: cfg.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: cfg.WoodpeckerWebhookSecret, DefaultDomain: cfg.DefaultDomain, RegistryURL: cfg.RegistryURL, ClusterIP: cfg.ClusterIP, Logger: logger, }, ) // 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()) infraHandler.Mount(app.Router()) projectMgmtHandler.Mount(app.Router()) woodpeckerHandler.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 // Infrastructure adapters (threesix.ai) GiteaURL string GiteaToken string GiteaDefaultOrg string CloudflareToken string CloudflareZoneID string DefaultDomain string DeployNamespace string DeployTLSIssuer string ClusterIP string RegistryURL 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"), // Infrastructure adapters GiteaURL: getEnv("GITEA_URL", "https://git.threesix.ai"), GiteaToken: os.Getenv("GITEA_TOKEN"), GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "threesix"), 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"), WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"), } } func getEnv(key, defaultVal string) string { if v := os.Getenv(key); v != "" { return v } return defaultVal }