package main import ( "context" "crypto/ed25519" "crypto/rand" "encoding/base64" "errors" "fmt" "log/slog" "net/http" "os" "os/signal" "syscall" "time" "github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/api" "github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/forgejo" "github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/k8s" "github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model" "github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/store" ) func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) slog.SetDefault(logger) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() // Database dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { slog.Error("DATABASE_URL is required") os.Exit(1) } pool, err := store.NewPool(ctx, dbURL) if err != nil { slog.Error("failed to connect to database", "error", err) os.Exit(1) } defer pool.Close() st := store.New(pool) if err := st.Migrate(ctx); err != nil { slog.Error("failed to run migrations", "error", err) os.Exit(1) } slog.Info("database migrations complete") // Bootstrap admin user if ADMIN_BOOTSTRAP_KEY is set and admin doesn't exist bootstrapAdmin(ctx, st, logger) // Kubernetes client k8sCfg := k8s.ConfigFromEnv() k8sClient, err := k8s.NewClient(k8sCfg) if err != nil { slog.Error("failed to create k8s client", "error", err) os.Exit(1) } // Resource sampler (background goroutine) sampler := k8s.NewResourceSampler(k8sClient, st, logger) go sampler.Start(ctx) // Forgejo integration (optional) var forgejoManager api.ForgejoManager forgejoAdminUser := os.Getenv("FORGEJO_ADMIN_USER") forgejoAdminPassword := os.Getenv("FORGEJO_ADMIN_PASSWORD") if forgejoAdminUser != "" && forgejoAdminPassword != "" { forgejoManager = forgejo.NewClient(k8sCfg.ForgejoURL, forgejoAdminUser, forgejoAdminPassword) slog.Info("forgejo integration enabled", "url", k8sCfg.ForgejoURL) } else { slog.Info("forgejo integration disabled (FORGEJO_ADMIN_USER/FORGEJO_ADMIN_PASSWORD not set)") } // web-tui: JWT signer (optional — only activates /api/v1/auth/exchange // and /.well-known/jwks.json when WEBTUI_JWT_SEED is set). jwtSigner := loadJWTSigner(logger) // web-tui: session-host manager (optional — activates // /api/v1/pods/session-host endpoints when WEBTUI_ENABLED=1). var sessionHost api.SessionHostManager if os.Getenv("WEBTUI_ENABLED") == "1" { spec := k8s.SessionHostSpec{ Image: envOr("WEBTUI_GOLDEN_IMAGE", "10.22.0.56:30500/dev-golden:v2"), PortWatchImage: envOr("WEBTUI_PORTWATCH_IMAGE", "10.22.0.56:30500/web-tui-port-watch:dev"), ApexDomain: envOr("WEBTUI_APEX_DOMAIN", "spinoff.dev"), ClusterIssuer: envOr("WEBTUI_CLUSTER_ISSUER", "letsencrypt-cloudflare-dns01"), GatewayService: envOr("WEBTUI_GATEWAY_SERVICE", "web-tui-gateway"), GatewayServiceNamespace: envOr("WEBTUI_GATEWAY_NAMESPACE", "web-tui"), } sessionHost = &sessionHostAdapter{client: k8sClient, spec: spec} logger.Info("web-tui session-host manager enabled", "apex", spec.ApexDomain) } // Runner cleanup goroutine runnerCleaner := k8s.NewRunnerCleaner(k8sClient, st, logger) go runnerCleaner.Start(ctx) // HTTP server srv := &api.Server{ Store: st, K8s: k8sClient, Cluster: k8sClient, Users: st, Usage: st, Forgejo: forgejoManager, Runners: st, RunnerPods: k8sClient, Logger: logger, GenerateKey: store.GenerateAPIKey, JWTSigner: jwtSigner, SessionHost: sessionHost, WebhookSecret: os.Getenv("FORGEJO_WEBHOOK_SECRET"), ForgejoRunnerToken: os.Getenv("FORGEJO_RUNNER_TOKEN"), } router := api.NewRouter(srv) port := os.Getenv("PORT") if port == "" { port = "8080" } httpSrv := &http.Server{ Addr: ":" + port, Handler: router, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 60 * time.Second, } go func() { <-ctx.Done() slog.Info("shutting down server") shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := httpSrv.Shutdown(shutdownCtx); err != nil { slog.Error("server shutdown error", "error", err) } }() slog.Info("starting dev-pod-api", "port", port) if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("server failed", "error", err) os.Exit(1) } slog.Info("server stopped") } // envOr returns the named env var if set, otherwise fallback. func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // loadJWTSigner returns a signer backed by the base64-encoded seed in // WEBTUI_JWT_SEED (32-byte Ed25519 seed). Missing env → nil signer → // /api/v1/auth/exchange returns 503, which is fine when web-tui isn't // in use. func loadJWTSigner(logger *slog.Logger) api.JWTSigner { raw := os.Getenv("WEBTUI_JWT_SEED") var seed []byte if raw == "" { // Dev convenience: synthesise a fresh seed at startup. All JWTs // issued with this key become invalid on restart. For prod set // WEBTUI_JWT_SEED from a Secret. seed = make([]byte, ed25519.SeedSize) if _, err := rand.Read(seed); err != nil { logger.Error("failed to generate JWT seed", "error", err) return nil } logger.Warn("WEBTUI_JWT_SEED unset — generated an ephemeral key; tokens will be invalidated on restart") } else { var err error seed, err = base64.StdEncoding.DecodeString(raw) if err != nil { logger.Error("WEBTUI_JWT_SEED is not valid base64", "error", err) return nil } } signer, err := api.NewEd25519SignerFromSeed(envOr("WEBTUI_JWT_KID", "webtui-1"), seed) if err != nil { logger.Error("failed to construct JWT signer", "error", err) return nil } return signer } // sessionHostAdapter bridges k8s.Client into the api.SessionHostManager // interface, translating k8s.SessionHostStatus → api.SessionHostStatus. type sessionHostAdapter struct { client *k8s.Client spec k8s.SessionHostSpec } func (a *sessionHostAdapter) EnsureForUser(ctx context.Context, user string) (api.SessionHostStatus, error) { s, err := a.client.EnsureSessionHost(ctx, user, a.spec) return toAPIStatus(s), err } func (a *sessionHostAdapter) StatusForUser(ctx context.Context, user string) (api.SessionHostStatus, error) { s, err := a.client.SessionHostStatusForUser(ctx, user) return toAPIStatus(s), err } func toAPIStatus(s k8s.SessionHostStatus) api.SessionHostStatus { return api.SessionHostStatus{ Ready: s.Ready, PodName: s.PodName, Namespace: s.Namespace, CertReady: s.CertReady, CertPendingReason: s.CertPendingReason, AtchSessionCount: s.AtchSessionCount, CreatedAt: s.CreatedAt, } } // bootstrapAdmin creates an admin user with an API key on first run. // Set ADMIN_BOOTSTRAP_KEY to a pre-chosen key, or leave empty to auto-generate one. func bootstrapAdmin(ctx context.Context, st *store.Store, logger *slog.Logger) { // Check if admin user already exists _, err := st.GetUser(ctx, "admin") if err == nil { logger.Info("admin user already exists, skipping bootstrap") return } if !errors.Is(err, store.ErrNotFound) { logger.Error("failed to check for admin user", "error", err) return } // Create admin user with elevated quotas adminQuota := model.Quota{ MaxConcurrentPods: 10, MaxCPUPerPod: 16, MaxRAMGBPerPod: 32, MonthlyPodHours: 10000, MonthlyAIRequests: 100000, } if _, err := st.CreateUser(ctx, "admin", adminQuota); err != nil { logger.Error("failed to create admin user", "error", err) return } // Use pre-set key or generate one bootstrapKey := os.Getenv("ADMIN_BOOTSTRAP_KEY") var plainKey, keyHash string if bootstrapKey != "" { plainKey = bootstrapKey keyHash = store.HashKey(plainKey) } else { var genErr error plainKey, keyHash, genErr = store.GenerateAPIKey() if genErr != nil { logger.Error("failed to generate admin API key", "error", genErr) if delErr := st.DeleteUser(ctx, "admin"); delErr != nil { logger.Error("rollback: failed to delete orphaned admin user", "error", delErr) } return } } if err := st.CreateAPIKey(ctx, "admin", model.RoleAdmin, keyHash); err != nil { logger.Error("failed to create admin API key", "error", err) if delErr := st.DeleteUser(ctx, "admin"); delErr != nil { logger.Error("rollback: failed to delete orphaned admin user", "error", delErr) } return } logger.Info("admin user bootstrapped successfully") // Print the key to stdout so it can be captured from logs fmt.Fprintf(os.Stderr, "\n=== ADMIN API KEY (save this, it won't be shown again) ===\n%s\n===\n\n", plainKey) }