build source
This commit is contained in:
commit
ee1fec43ed
4171 changed files with 1351288 additions and 0 deletions
279
main.go
Normal file
279
main.go
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue