Files
omnex/control-plane/api/cmd/api/main.go
T
Xor290 aa1aa60f00
ci-api / test (push) Successful in 26m18s
chore: build
2026-08-18 16:37:00 +02:00

259 lines
9.0 KiB
Go

// Commande api : point d'entrée du control-plane Omnex.
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/omnex/control-plane/api/internal/alerts"
"github.com/omnex/control-plane/api/internal/auth"
"github.com/omnex/control-plane/api/internal/config"
"github.com/omnex/control-plane/api/internal/db"
"github.com/omnex/control-plane/api/internal/demos"
"github.com/omnex/control-plane/api/internal/downloads"
"github.com/omnex/control-plane/api/internal/k8s"
"github.com/omnex/control-plane/api/internal/profile"
"github.com/omnex/control-plane/api/internal/router"
"github.com/omnex/control-plane/api/internal/session"
"github.com/omnex/control-plane/api/internal/sub"
)
// shutdownGracePeriod : délai laissé aux requêtes HTTP en cours pour se
// terminer après réception de SIGTERM/SIGINT (ex. redéploiement Docker
// Compose) avant l'arrêt forcé. Ne couvre pas les provisionnings de démo en
// arrière-plan (goroutines fire-and-forget, voir demos.Service.Create) —
// seules les requêtes HTTP en vol sont drainées.
const shutdownGracePeriod = 15 * time.Second
// memUsers : store utilisateurs en mémoire (fallback dev sans base).
type memUsers map[string]auth.User
func (m memUsers) ByUsername(username string) (auth.User, bool) {
u, ok := m[strings.ToLower(strings.TrimSpace(username))]
return u, ok
}
func (m memUsers) Create(username, passwordHash string, role auth.Role) (auth.User, error) {
key := strings.ToLower(strings.TrimSpace(username))
u := auth.User{ID: uuid.NewString(), Username: key, PasswordHash: passwordHash, Role: role}
m[key] = u
return u, nil
}
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
iss := auth.NewIssuer(cfg.JWTSecret, cfg.SessionTTL)
// Sessions : Redis en prod/dev configuré, mémoire en fallback dev.
var sessions session.Manager
if cfg.RedisURL != "" {
opt, err := redis.ParseURL(cfg.RedisURL)
if err != nil {
log.Fatalf("redis url: %v", err)
}
rdb := redis.NewClient(opt)
if err := rdb.Ping(context.Background()).Err(); err != nil {
log.Fatalf("redis ping: %v", err)
}
sessions = session.NewRedisManager(rdb, cfg.SessionTTL)
log.Printf("sessions: Redis")
} else {
sessions = session.NewMemManager(cfg.SessionTTL)
log.Printf("sessions: mémoire (dev — définir OMNEX_REDIS_URL pour Redis)")
}
var userStore auth.UserStore
var demoStore demos.Store
var demoPool demos.Pool
var codeStore sub.Store
var profileStore profile.Store
// Résout les destinataires d'alerte pods depuis les réglages de chaque
// admin (table users) — nil si pas de base (mode mémoire, dev), auquel
// cas le monitoring reste désactivé (rien à interroger).
var alertRecipients func(ctx context.Context) ([]alerts.Notifier, error)
if cfg.DatabaseURL != "" {
gdb, err := db.Open(cfg.DatabaseURL)
if err != nil {
log.Fatalf("db: %v", err)
}
if err := db.AutoMigrate(gdb); err != nil {
log.Fatalf("migrate: %v", err)
}
if err := db.SeedExternalPool(gdb); err != nil {
log.Fatalf("seed pool: %v", err)
}
users := auth.NewGormStore(gdb)
seedAdmin(users)
userStore = users
codeStore = sub.NewGormStore(gdb)
demoStore = demos.NewGormStore(gdb)
demoPool = demos.NewGormPool(gdb)
profileStore = profile.NewGormStore(gdb)
alertRecipients = alerts.GormRecipients(gdb)
log.Printf("persistance: PostgreSQL (GORM)")
} else {
userStore = seedMemUsers()
codeStore = sub.NewMemStore()
demoStore = demos.NewMemStore()
demoPool = demos.NewMemPool()
log.Printf("persistance: mémoire (dev — définir OMNEX_DATABASE_URL pour PostgreSQL)")
}
// Créer le client Kubernetes
k8sClient, err := k8s.NewClient(&cfg)
if err != nil {
log.Fatalf("k8s client: %v", err)
}
// Config REST brute — nécessaire pour l'exec dans les pods (création du
// compte admin de la démo via psql une fois le backend démarré).
restConfig, err := k8s.NewRESTConfig(&cfg)
if err != nil {
log.Fatalf("k8s rest config: %v", err)
}
// Client metrics.k8s.io (usage CPU/mémoire live) : optionnel, ne bloque
// pas le démarrage si metrics-server n'est pas déployé sur le cluster.
metricsClient, err := k8s.NewMetricsClient(&cfg)
if err != nil {
log.Printf("metrics client indisponible (usage CPU/mémoire désactivé): %v", err)
metricsClient = nil
}
helmProv, err := demos.NewHelmProvisioner(&cfg, k8sClient, restConfig, metricsClient, "/charts", "helm")
if err != nil {
log.Fatalf("helm provisioner: %v", err)
}
// Traefik partagé + WAF Coraza : installé une fois pour toutes les démos
// (idempotent). En arrière-plan et non-fatal : "helm upgrade --install
// --wait --timeout 5m" peut bloquer plusieurs minutes si le cluster est
// lent/injoignable, ce qui retardait le démarrage du serveur HTTP au
// point de faire échouer le healthcheck Docker avant même que l'API
// n'écoute sur le port. Ici ça ne retarde plus rien : les démos
// provisionnées avant la fin de cet appel ne seront juste ni routées ni
// protégées tant qu'il n'a pas fini.
go func() {
if err := helmProv.EnsureSharedInfra(); err != nil {
log.Printf("ATTENTION: infra partagée (Traefik/WAF) indisponible: %v", err)
}
}()
// Service de démos avec le provisioner Helm
demoSvc := demos.NewService(demoStore, demoPool, helmProv, demos.Config{
BaseDomain: cfg.DemoDomain,
HTTPSPort: cfg.DemoHTTPSPort,
TTL: demos.TTL,
})
// Détruit automatiquement les démos dont le TTL est dépassé — sans ça,
// le compte à rebours affiché en interface n'a aucun effet réel (voir
// Service.RunExpirationLoop). Fire-and-forget comme les autres goroutines
// de ce fichier : pas de contexte annulable, s'arrête avec le processus.
go demoSvc.RunExpirationLoop(context.Background(), 5*time.Minute)
// Alerting : surveille les pods des démos actives (état + seuils
// CPU/mémoire/PVC), notifie Discord et/ou Telegram de chaque admin ayant
// configuré son propre canal (voir profil admin, /profile/alerts).
// Désactivé en mode mémoire (pas de réglages persistés à interroger).
if alertRecipients != nil {
watcher := alerts.NewWatcher(k8sClient, metricsClient, alertRecipients, func() ([]string, error) {
all, err := demoSvc.List()
if err != nil {
return nil, err
}
ns := make([]string, 0, len(all))
for _, d := range all {
if d.Status.Active() {
ns = append(ns, d.Namespace)
}
}
return ns, nil
})
go watcher.Run(context.Background())
log.Printf("alerts: monitoring des pods actif (réglages par admin, voir /profile/alerts)")
} else {
log.Printf("alerts: monitoring désactivé (nécessite OMNEX_DATABASE_URL — réglages persistés par admin)")
}
deps := router.Deps{
Cfg: cfg,
Issuer: iss,
Sessions: sessions,
AuthH: auth.NewHandler(userStore, sessions, iss, cfg.Secure()),
DemosH: demos.NewHandler(demoSvc, helmProv),
SubH: sub.NewHandler(codeStore, demoSvc),
ProfileH: profile.NewHandler(profileStore),
DownloadsH: downloads.NewHandler(cfg.AppDownloadsDir, userStore, demoSvc),
}
r := router.New(deps)
srv := &http.Server{Addr: cfg.Addr, Handler: r}
go func() {
log.Printf("Omnex API sur %s (env=%s)", cfg.Addr, cfg.Env)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("serveur HTTP: %v", err)
}
}()
// Arrêt propre : sur SIGTERM/SIGINT, cesse d'accepter de nouvelles
// connexions et laisse shutdownGracePeriod aux requêtes en cours pour se
// terminer (évite de couper une requête en vol lors d'un redéploiement).
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
<-stop
log.Printf("signal d'arrêt reçu, extinction en cours (jusqu'à %s)...", shutdownGracePeriod)
ctx, cancel := context.WithTimeout(context.Background(), shutdownGracePeriod)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("extinction forcée après délai: %v", err)
}
}
// seedAdmin crée l'utilisateur admin de départ depuis l'env (idempotent).
func seedAdmin(users *auth.GormStore) {
username, pass := os.Getenv("OMNEX_SEED_USERNAME"), os.Getenv("OMNEX_SEED_PASSWORD")
if username == "" || pass == "" {
return
}
hash, err := auth.HashPassword(pass)
if err != nil {
log.Fatalf("seed: %v", err)
}
if err := users.EnsureUser(username, hash, auth.RoleAdmin); err != nil {
log.Fatalf("seed: %v", err)
}
log.Printf("utilisateur admin seedé: %s", username)
}
// seedMemUsers construit un store mémoire depuis l'env (dev uniquement).
func seedMemUsers() memUsers {
users := memUsers{}
if username, pass := os.Getenv("OMNEX_SEED_USERNAME"), os.Getenv("OMNEX_SEED_PASSWORD"); username != "" && pass != "" {
hash, err := auth.HashPassword(pass)
if err != nil {
log.Fatalf("seed: %v", err)
}
key := strings.ToLower(strings.TrimSpace(username))
users[key] = auth.User{ID: "seed-admin", Username: key, PasswordHash: hash, Role: auth.RoleAdmin}
log.Printf("utilisateur admin seedé (mémoire): %s", key)
}
return users
}