279 lines
8.7 KiB
Go
279 lines
8.7 KiB
Go
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)
|
|
}
|