259 lines
9.0 KiB
Go
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
|
|
}
|