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