chore: update
ci-api / test (push) Successful in 22m17s
ci-web / test (push) Successful in 13m44s

This commit is contained in:
Xor290
2026-08-02 18:34:59 +02:00
parent 6992e505ae
commit c5ee5636ef
34 changed files with 2660 additions and 498 deletions
+30
View File
@@ -10,6 +10,7 @@ import (
"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"
@@ -67,6 +68,10 @@ func main() {
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 {
@@ -85,6 +90,7 @@ func main() {
demoStore = demos.NewGormStore(gdb)
demoPool = demos.NewGormPool(gdb)
profileStore = profile.NewGormStore(gdb)
alertRecipients = alerts.GormRecipients(gdb)
log.Printf("persistance: PostgreSQL (GORM)")
} else {
userStore = seedMemUsers()
@@ -141,6 +147,30 @@ func main() {
TTL: demos.TTL,
})
// Alerting : surveille les pods des démos actives, 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, 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,
@@ -0,0 +1,90 @@
// Package alerts : surveillance des pods des démos + notification (Discord,
// Telegram) quand une plateforme tombe en erreur ou se rétablit.
package alerts
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
)
// Notifier envoie un message d'alerte vers un canal externe.
type Notifier interface {
Notify(ctx context.Context, message string) error
}
// MultiNotifier diffuse vers plusieurs canaux ; une erreur sur l'un
// n'empêche pas la notification des autres.
type MultiNotifier []Notifier
func (m MultiNotifier) Notify(ctx context.Context, message string) error {
var errs []error
for _, n := range m {
if err := n.Notify(ctx, message); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
var httpClient = &http.Client{Timeout: 10 * time.Second}
// DiscordNotifier envoie le message sur un webhook Discord.
type DiscordNotifier struct {
WebhookURL string
}
func (d *DiscordNotifier) Notify(ctx context.Context, message string) error {
body, err := json.Marshal(map[string]string{"content": message})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.WebhookURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("discord: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("discord: statut %d", resp.StatusCode)
}
return nil
}
// TelegramNotifier envoie le message via un bot Telegram (sendMessage).
type TelegramNotifier struct {
BotToken string
ChatID string
}
func (t *TelegramNotifier) Notify(ctx context.Context, message string) error {
endpoint := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.BotToken)
body, err := json.Marshal(map[string]string{"chat_id": t.ChatID, "text": message})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("telegram: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("telegram: statut %d", resp.StatusCode)
}
return nil
}
@@ -0,0 +1,38 @@
package alerts
import (
"context"
"gorm.io/gorm"
"github.com/omnex/control-plane/api/internal/auth"
)
// GormRecipients construit, à partir de la table des utilisateurs, la liste
// des notifiers actifs : chaque admin ayant renseigné un webhook Discord
// et/ou un bot Telegram (dans son profil) reçoit les alertes. Interrogée à
// chaque notification nécessaire — un admin qui change ses réglages est pris
// en compte sans redémarrage de l'API.
func GormRecipients(gdb *gorm.DB) func(ctx context.Context) ([]Notifier, error) {
return func(ctx context.Context) ([]Notifier, error) {
var admins []auth.User
if err := gdb.WithContext(ctx).Where("role = ?", auth.RoleAdmin).Find(&admins).Error; err != nil {
return nil, err
}
var notifiers []Notifier
for _, a := range admins {
if a.AlertDiscordWebhookURL != nil && *a.AlertDiscordWebhookURL != "" {
notifiers = append(notifiers, &DiscordNotifier{WebhookURL: *a.AlertDiscordWebhookURL})
}
if a.AlertTelegramBotToken != nil && *a.AlertTelegramBotToken != "" &&
a.AlertTelegramChatID != nil && *a.AlertTelegramChatID != "" {
notifiers = append(notifiers, &TelegramNotifier{
BotToken: *a.AlertTelegramBotToken,
ChatID: *a.AlertTelegramChatID,
})
}
}
return notifiers, nil
}
}
@@ -0,0 +1,169 @@
package alerts
import (
"context"
"fmt"
"log"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
const pollInterval = 30 * time.Second
// Raisons de conteneur considérées comme des pannes (hors CrashLoopBackOff
// initial normal au tout premier démarrage d'un pod fraîchement créé — non
// distingué ici, le bruit au provisioning est acceptable).
var badWaitingReasons = map[string]bool{
"CrashLoopBackOff": true,
"ImagePullBackOff": true,
"ErrImagePull": true,
"CreateContainerConfigError": true,
"CreateContainerError": true,
"InvalidImageName": true,
"RunContainerError": true,
}
type podKey struct{ namespace, pod string }
// Watcher scrute périodiquement les pods des namespaces actifs et notifie
// tout changement d'état (sain -> panne, panne -> rétabli). Les destinataires
// (recipients) sont résolus à chaque tick où une notification est nécessaire
// — chaque admin configure son propre webhook Discord / bot Telegram dans
// son profil (voir internal/profile), rien n'est figé au démarrage.
type Watcher struct {
k8sClient *kubernetes.Clientset
recipients func(ctx context.Context) ([]Notifier, error)
namespaces func() ([]string, error)
state map[podKey]string // dernière raison de panne connue ("" = sain)
}
func NewWatcher(k8sClient *kubernetes.Clientset, recipients func(ctx context.Context) ([]Notifier, error), namespaces func() ([]string, error)) *Watcher {
return &Watcher{
k8sClient: k8sClient,
recipients: recipients,
namespaces: namespaces,
state: make(map[podKey]string),
}
}
// Run boucle jusqu'à annulation du contexte. À lancer dans une goroutine.
func (w *Watcher) Run(ctx context.Context) {
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
w.tick(ctx)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (w *Watcher) tick(ctx context.Context) {
nsList, err := w.namespaces()
if err != nil {
log.Printf("alerts: liste des démos actives indisponible: %v", err)
return
}
// Les destinataires ne sont chargés (requête DB) que si une notification
// s'avère nécessaire ce tick-ci, et une seule fois par tick.
var notifier Notifier
notifierLoaded := false
loadNotifier := func() Notifier {
if notifierLoaded {
return notifier
}
notifierLoaded = true
list, err := w.recipients(ctx)
if err != nil {
log.Printf("alerts: chargement des destinataires échoué: %v", err)
return nil
}
if len(list) == 0 {
return nil
}
notifier = MultiNotifier(list)
return notifier
}
seen := make(map[podKey]bool, len(w.state))
for _, ns := range nsList {
pods, err := w.k8sClient.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{})
if err != nil {
log.Printf("alerts: liste des pods de %s indisponible: %v", ns, err)
continue
}
for _, pod := range pods.Items {
key := podKey{namespace: ns, pod: pod.Name}
seen[key] = true
reason := problemReason(pod)
prev, known := w.state[key]
if reason != prev {
if known {
w.notify(ctx, loadNotifier(), key, prev, reason)
} else if reason != "" {
// Premier passage sur ce pod : on ne notifie que s'il est
// déjà en panne (sinon rien d'anormal à signaler).
w.notify(ctx, loadNotifier(), key, "", reason)
}
w.state[key] = reason
}
}
}
// Pods disparus (démo supprimée, rollout) : on oublie leur état pour ne
// pas polluer la mémoire indéfiniment.
for key := range w.state {
if !seen[key] {
delete(w.state, key)
}
}
}
func (w *Watcher) notify(ctx context.Context, notifier Notifier, key podKey, prev, reason string) {
if notifier == nil {
return
}
var msg string
switch {
case prev == "" && reason != "":
msg = fmt.Sprintf("🔴 [%s] pod %s en erreur : %s", key.namespace, key.pod, reason)
case prev != "" && reason == "":
msg = fmt.Sprintf("🟢 [%s] pod %s rétabli", key.namespace, key.pod)
default:
msg = fmt.Sprintf("🔴 [%s] pod %s toujours en erreur : %s", key.namespace, key.pod, reason)
}
if err := notifier.Notify(ctx, msg); err != nil {
log.Printf("alerts: notification échouée: %v", err)
}
}
// problemReason retourne une raison de panne non vide si le pod est dans un
// état anormal (phase Failed, ou conteneur en CrashLoopBackOff/ImagePullBackOff/
// OOMKilled...), "" si tout va bien.
func problemReason(pod corev1.Pod) string {
if pod.DeletionTimestamp != nil {
return "" // suppression en cours, pas une panne
}
if pod.Status.Phase == corev1.PodFailed {
if pod.Status.Reason != "" {
return pod.Status.Reason
}
return "Failed"
}
for _, cs := range pod.Status.ContainerStatuses {
if waiting := cs.State.Waiting; waiting != nil && badWaitingReasons[waiting.Reason] {
return waiting.Reason
}
if term := cs.State.Terminated; term != nil && term.Reason == "OOMKilled" {
return "OOMKilled"
}
}
return ""
}
+8 -2
View File
@@ -30,10 +30,16 @@ func NewHandler(users UserStore, sessions session.Manager, iss *Issuer, secure b
type loginRequest struct {
Username string
Password string
// Role : page de connexion utilisée ("admin" ou "client"). La page
// admin et la page client sont désormais séparées côté front — un
// compte client ne peut pas se connecter depuis la page admin et
// inversement, même avec des identifiants valides.
Role Role `json:"role" binding:"required,oneof=admin client"`
}
// Login authentifie, ouvre une session Redis et pose le cookie httpOnly.
// Réponse volontairement uniforme (pas d'énumération d'utilisateurs).
// Réponse volontairement uniforme (pas d'énumération d'utilisateurs, ni du
// rôle réel du compte en cas de connexion depuis la mauvaise page).
func (h *Handler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -50,7 +56,7 @@ func (h *Handler) Login(c *gin.Context) {
}
ok, err := VerifyPassword(req.Password, user.PasswordHash)
if err != nil || !ok {
if err != nil || !ok || user.Role != req.Role {
c.JSON(http.StatusUnauthorized, gin.H{"error": "identifiants invalides"})
return
}
+10 -2
View File
@@ -10,6 +10,14 @@ type User struct {
Role Role `gorm:"size:20;not null" json:"role"`
TypeAbo string `gorm:"type:varchar(35);default:demo" json:"type_abonnement"`
ExpiredAt time.Time `json:"expired_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Réglages d'alerte monitoring (admin uniquement) : chaque admin
// configure son propre canal, aucune variable d'env globale — voir
// internal/alerts et internal/profile (Get/SetAlertSettings).
AlertDiscordWebhookURL *string `gorm:"size:500" json:"-"`
AlertTelegramBotToken *string `gorm:"size:200" json:"-"`
AlertTelegramChatID *string `gorm:"size:64" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+18
View File
@@ -4,6 +4,7 @@ package demos
import (
"errors"
"strings"
"time"
)
@@ -145,3 +146,20 @@ const (
ResourceFree ResourceStatus = "free"
ResourceBorrowed ResourceStatus = "borrowed"
)
// premiumNamespace calcule le namespace "premium-<client>" utilisé une fois
// la démo passée en abonnement payant (voir Service.TransferToPaid et
// HelmProvisioner.MigrateToPremiumNamespace) — Kubernetes ne permet pas de
// renommer un namespace, seulement d'en créer un nouveau et d'y migrer les
// données. Contrainte K8s : nom en minuscules, 63 caractères max ; le
// username est déjà alphanumérique (voir handler.go), il suffit de le
// mettre en minuscule et de le tronquer si besoin.
func premiumNamespace(username string) string {
const prefix = "premium-"
maxLen := 63 - len(prefix)
u := strings.ToLower(username)
if len(u) > maxLen {
u = u[:maxLen]
}
return prefix + u
}
@@ -5,6 +5,7 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
@@ -206,6 +207,227 @@ func (h *HelmProvisioner) Teardown(d Demo) error {
return h.deleteNamespace(d.Namespace)
}
// MigrateToPremiumNamespace reconstruit l'intégralité de la démo dans
// newNamespace ("premium-<client>") et y migre les données postgres —
// Kubernetes ne permet pas de renommer un namespace, il faut donc
// recréer toute la stack (postgres/redis/backend/frontend/ingressroute/
// lbtelegram) ailleurs.
//
// La configuration (bot Telegram, NowPayments, load-balancer...) n'est
// jamais persistée en base (voir ProvisionConfig) : elle est relue depuis
// les valeurs Helm de l'ancien déploiement ("helm get values"), qui
// contiennent exactement ce qui a été installé à l'origine, secrets
// inclus — jamais journalisés ni interprétés ici, seulement retransmis
// tels quels au nouveau déploiement. Seuls les champs qui référencent
// l'ancien namespace (DNS internes, URL du webhook Telegram) sont
// recalculés pour le nouveau (voir patchBackendValuesForNamespace /
// patchLBTelegramValuesForNamespace).
//
// L'ancien namespace n'est supprimé qu'une fois le nouveau confirmé
// opérationnel (rollout réussi) : en cas d'échec à n'importe quelle étape,
// la démo continue de fonctionner sous son ancienne adresse, rien n'est
// perdu. No-op si newNamespace == d.Namespace (déjà migrée, ex.
// renouvellement d'un client déjà premium).
func (h *HelmProvisioner) MigrateToPremiumNamespace(d Demo, newNamespace, newURL string) error {
oldNamespace := d.Namespace
if newNamespace == oldNamespace {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
backendValues, err := h.getReleaseValues(oldNamespace, oldNamespace+"-backend")
if err != nil {
return fmt.Errorf("lecture config backend existante: %w", err)
}
lbValues, lbEnabled := h.getReleaseValuesOptional(oldNamespace, oldNamespace+"-lbtelegram")
if err := h.createNamespace(newNamespace); err != nil {
return fmt.Errorf("création namespace %s: %w", newNamespace, err)
}
newDemo := d
newDemo.Namespace = newNamespace
newDemo.URL = newURL
if err := h.installChart(newNamespace, "postgresql", h.buildPostgresValues(newDemo)); err != nil {
h.deleteNamespace(newNamespace)
return fmt.Errorf("déploiement postgresql: %w", err)
}
if err := h.installChart(newNamespace, "redis", h.buildRedisValues(newDemo)); err != nil {
h.deleteNamespace(newNamespace)
return fmt.Errorf("déploiement redis: %w", err)
}
// Données postgres : migrées par dump/restore (le nouveau PVC est
// vide). Redis (cache/sessions) n'est pas migré, reconstruit
// naturellement — même convention que l'historique passage en
// stockage persistant.
if err := h.migratePostgresData(ctx, oldNamespace, newNamespace); err != nil {
h.deleteNamespace(newNamespace)
return fmt.Errorf("migration données postgres: %w", err)
}
var backendLinkSecret string
if lbEnabled {
backendLinkSecret = asStringMap(backendValues["secrets"])["BACKEND_LINK_SECRET"]
if backendLinkSecret == "" {
backendLinkSecret = randomSecret()
}
}
backendPatched := h.patchBackendValuesForNamespace(backendValues, newDemo, lbEnabled, backendLinkSecret)
if err := h.installChart(newNamespace, "backend", backendPatched); err != nil {
h.deleteNamespace(newNamespace)
return fmt.Errorf("déploiement backend: %w", err)
}
if err := h.installChart(newNamespace, "frontend", h.buildFrontendValues(newDemo)); err != nil {
h.deleteNamespace(newNamespace)
return fmt.Errorf("déploiement frontend: %w", err)
}
if err := h.installChart(newNamespace, "ingressroute", h.buildIngressRouteValues(newDemo)); err != nil {
h.deleteNamespace(newNamespace)
return fmt.Errorf("déploiement ingressroute: %w", err)
}
if lbEnabled {
lbPatched := h.patchLBTelegramValuesForNamespace(lbValues, newDemo, backendLinkSecret)
if err := h.installChart(newNamespace, "lbtelegram", lbPatched); err != nil {
h.deleteNamespace(newNamespace)
return fmt.Errorf("déploiement lbtelegram: %w", err)
}
}
if err := h.waitForRollout(newNamespace); err != nil {
// Le nouveau namespace n'est PAS supprimé : les données y sont déjà
// migrées, un rollout en échec est réparable manuellement sans tout
// reperdre. L'ancien namespace reste lui aussi en place tant que le
// nouveau n'est pas confirmé opérationnel.
return fmt.Errorf("rollout du nouveau namespace %s: %w", newNamespace, err)
}
if err := h.deleteNamespace(oldNamespace); err != nil {
log.Printf("Warning: suppression ancien namespace %s échouée (nouveau namespace %s opérationnel): %v", oldNamespace, newNamespace, err)
}
log.Printf("Démo %s migrée vers le namespace premium %s", oldNamespace, newNamespace)
return nil
}
// migratePostgresData copie les données postgres d'un namespace à l'autre
// (pg_dump / psql restore), utilisé par MigrateToPremiumNamespace.
func (h *HelmProvisioner) migratePostgresData(ctx context.Context, oldNamespace, newNamespace string) error {
oldPod, err := h.findPod(ctx, oldNamespace, "postgresql")
if err != nil {
return fmt.Errorf("pod postgresql source introuvable: %w", err)
}
dumpCmd := fmt.Sprintf("PGPASSWORD=%s pg_dump -h localhost -U %s %s", demoDBPass, demoDBUser, demoDBName)
dump, stderr, err := h.execInPod(ctx, oldNamespace, oldPod, "postgresql", []string{"sh", "-c", dumpCmd}, nil)
if err != nil {
return fmt.Errorf("pg_dump: %w (%s)", err, stderr)
}
if strings.TrimSpace(dump) == "" {
return nil
}
newPod, err := h.findPod(ctx, newNamespace, "postgresql")
if err != nil {
return fmt.Errorf("pod postgresql cible introuvable: %w", err)
}
restoreCmd := fmt.Sprintf("PGPASSWORD=%s psql -h localhost -U %s %s", demoDBPass, demoDBUser, demoDBName)
if _, stderr, err := h.execInPod(ctx, newNamespace, newPod, "postgresql", []string{"sh", "-c", restoreCmd}, strings.NewReader(dump)); err != nil {
return fmt.Errorf("restore pg_dump: %w (%s)", err, stderr)
}
return nil
}
// patchBackendValuesForNamespace réutilise les valeurs Helm existantes du
// backend (image, secrets, réglages métier...) en ne recalculant que ce qui
// référence le nom du namespace : DNS internes postgres/redis/lbtelegram et
// URL du webhook Telegram (dépend de l'URL publique de la démo).
func (h *HelmProvisioner) patchBackendValuesForNamespace(values map[string]interface{}, newDemo Demo, lbEnabled bool, backendLinkSecret string) map[string]interface{} {
env := asStringMap(values["env"])
secrets := asStringMap(values["secrets"])
env["DB_HOST"] = fmt.Sprintf("%s-postgresql-postgresql", newDemo.Namespace)
env["REDIS_HOST"] = fmt.Sprintf("%s-redis-redis", newDemo.Namespace)
if lbEnabled {
env["LBTELEGRAM_URL"] = fmt.Sprintf("http://%s-lbtelegram-lbtelegram.%s.svc.cluster.local:8081", newDemo.Namespace, newDemo.Namespace)
secrets["BACKEND_LINK_SECRET"] = backendLinkSecret
}
if secrets["TELEGRAM_BOT_TOKEN"] != "" {
secrets["TELEGRAM_WEBHOOK_URL"] = newDemo.URL + "/webhook/telegram"
}
values["env"] = env
values["secrets"] = secrets
return values
}
// patchLBTelegramValuesForNamespace réutilise les valeurs Helm existantes
// du chart lbtelegram (bots, stratégie, tokens...) en ne recalculant que ce
// qui référence le nom du namespace : host public, GATEWAY_URL, DSN
// postgres/redis et URL interne du backend.
func (h *HelmProvisioner) patchLBTelegramValuesForNamespace(values map[string]interface{}, newDemo Demo, backendLinkSecret string) map[string]interface{} {
env := asStringMap(values["env"])
secrets := asStringMap(values["secrets"])
host := fmt.Sprintf("%s.%s", newDemo.Namespace, h.baseDomain)
env["GATEWAY_URL"] = "https://" + host
env["BACKEND_LINK_URL"] = fmt.Sprintf("http://%s-backend-gestion-backend.%s.svc.cluster.local:8080", newDemo.Namespace, newDemo.Namespace)
secrets["BACKEND_LINK_SECRET"] = backendLinkSecret
secrets["DATABASE_URL"] = fmt.Sprintf("postgres://postgres:demo-postgres-pass@%s-postgresql-postgresql:5432/demo_db?sslmode=disable", newDemo.Namespace)
secrets["REDIS_URL"] = fmt.Sprintf("redis://:demo-redis-pass@%s-redis-redis:6379/0", newDemo.Namespace)
values["host"] = host
values["env"] = env
values["secrets"] = secrets
return values
}
// getReleaseValues récupère les valeurs Helm exactes utilisées par une
// release déjà installée ("helm get values -o json").
func (h *HelmProvisioner) getReleaseValues(namespace, release string) (map[string]interface{}, error) {
out, err := h.runHelmOutput("get", "values", release, "--namespace", namespace, "-o", "json")
if err != nil {
return nil, err
}
var values map[string]interface{}
if err := json.Unmarshal(out, &values); err != nil {
return nil, fmt.Errorf("parse valeurs helm de %s: %w", release, err)
}
return values, nil
}
// getReleaseValuesOptional : comme getReleaseValues, mais renvoie
// simplement (nil, false) si la release n'existe pas (ex. lbtelegram non
// activé pour cette démo) plutôt qu'une erreur.
func (h *HelmProvisioner) getReleaseValuesOptional(namespace, release string) (map[string]interface{}, bool) {
values, err := h.getReleaseValues(namespace, release)
if err != nil {
return nil, false
}
return values, true
}
// asStringMap convertit une valeur JSON décodée (map[string]interface{})
// en map[string]string, en ignorant silencieusement les clés dont la
// valeur n'est pas une chaîne.
func asStringMap(v interface{}) map[string]string {
out := map[string]string{}
m, ok := v.(map[string]interface{})
if !ok {
return out
}
for k, val := range m {
if s, ok := val.(string); ok {
out[k] = s
}
}
return out
}
// createGestionAdmin crée le compte admin de l'application "gestion"
// déployée dans cette démo, en insérant directement dans la table "users"
// (aucune route API ne le permet : CreateUser refuse explicitement de créer
@@ -405,15 +627,22 @@ func (h *HelmProvisioner) writeValuesFile(namespace, chartName string, values ma
// runHelm exécute une commande helm et remonte stdout/stderr en cas d'erreur.
func (h *HelmProvisioner) runHelm(args ...string) error {
_, err := h.runHelmOutput(args...)
return err
}
// runHelmOutput exécute une commande helm et retourne son stdout (ex. "helm
// get values -o json").
func (h *HelmProvisioner) runHelmOutput(args ...string) ([]byte, error) {
cmd := exec.Command(h.helmPath, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%v (stdout: %s, stderr: %s)", err, stdout.String(), stderr.String())
return nil, fmt.Errorf("%v (stdout: %s, stderr: %s)", err, stdout.String(), stderr.String())
}
return nil
return stdout.Bytes(), nil
}
func parseImage(image string) (repo string, tag string) {
@@ -10,6 +10,11 @@ type Provisioner interface {
Provision(d Demo, resources []ExternalResource, cfg ProvisionConfig) error
// Teardown demande la destruction d'une démo.
Teardown(d Demo) error
// MigrateToPremiumNamespace reconstruit la démo dans newNamespace
// (Kubernetes ne permet pas de renommer un namespace) en préservant les
// données postgres, puis supprime l'ancien namespace une fois le
// nouveau opérationnel. No-op si d.Namespace == newNamespace.
MigrateToPremiumNamespace(d Demo, newNamespace, newURL string) error
GetResourceState(ctx context.Context, namespace string) (ResourceState, error)
}
@@ -19,6 +24,7 @@ type NoopProvisioner struct{}
func (NoopProvisioner) Provision(Demo, []ExternalResource, ProvisionConfig) error { return nil }
func (NoopProvisioner) Teardown(Demo) error { return nil }
func (NoopProvisioner) MigrateToPremiumNamespace(Demo, string, string) error { return nil }
func (NoopProvisioner) GetResourceState(ctx context.Context, namespace string) (ResourceState, error) {
return ResourceState{}, nil
}
+42 -15
View File
@@ -75,15 +75,7 @@ func (s *Service) Create(username string, cfg ProvisionConfig) (Demo, error) {
CreatedAt: s.now().UTC(),
ExpiresAt: s.now().UTC().Add(s.cfg.TTL),
}
// Port omis dans l'URL s'il s'agit du 443 standard ; sinon affiché
// explicitement (ex. NodePort HTTPS sans LoadBalancer). Ne concerne que
// l'URL affichée : le Host() de l'IngressRoute reste sans port (voir
// buildIngressRouteValues), le port n'y a pas sa place pour le matching.
portSuffix := ""
if s.cfg.HTTPSPort != "" && s.cfg.HTTPSPort != "443" {
portSuffix = ":" + s.cfg.HTTPSPort
}
demo.URL = "https://" + demo.Namespace + "." + s.cfg.BaseDomain + portSuffix
demo.URL = s.urlFor(demo.Namespace)
// Réserve les ressources externes (slots Telegram/NowPayments pré-
// provisionnés) — contrainte réelle indépendante du nombre de démos,
@@ -134,11 +126,16 @@ func (s *Service) ListForUser(username string) ([]Demo, error) {
}
// TransferToPaid marque la démo active du client comme abonnement payant
// (n'expire plus). Le stockage (PVC postgres/redis) est déjà en place depuis
// la création de la démo (voir HelmProvisioner.Provision) : rien à basculer
// côté infra, seul le statut change. No-op si le client n'a pas de démo
// active — ce n'est pas une erreur (ex: compte créé sans jamais avoir
// demandé de démo).
// (n'expire plus) et déclenche la migration de son namespace vers
// "premium-<client>" (Kubernetes ne permet pas de renommer un namespace,
// voir HelmProvisioner.MigrateToPremiumNamespace) — no-op si déjà migrée
// (ex. renouvellement d'un client déjà premium). Le stockage (PVC
// postgres/redis) est déjà en place depuis la création de la démo, seules
// les données postgres sont recopiées vers le nouveau namespace. No-op
// total si le client n'a pas de démo active — ce n'est pas une erreur (ex:
// compte créé sans jamais avoir demandé de démo). La migration est lente
// (dump/restore/helm) : elle tourne en tâche de fond, cette méthode ne
// bloque pas.
func (s *Service) TransferToPaid(username string) error {
existing, err := s.store.ListByUsername(auth.NormalizeUsername(username))
if err != nil {
@@ -158,13 +155,43 @@ func (s *Service) TransferToPaid(username string) error {
active.TypeAbo = "premium"
active.ExpiresAt = s.now().UTC().AddDate(10, 0, 0) // n'expire plus, en pratique
if _, err := s.store.Update(*active); err != nil {
updated, err := s.store.Update(*active)
if err != nil {
return err
}
newNamespace := premiumNamespace(updated.Username)
if newNamespace != updated.Namespace {
newURL := s.urlFor(newNamespace)
go func() {
if err := s.prov.MigrateToPremiumNamespace(updated, newNamespace, newURL); err != nil {
log.Printf("Migration namespace premium échouée pour %s: %v", updated.Namespace, err)
return
}
updated.Namespace = newNamespace
updated.URL = newURL
if _, err := s.store.Update(updated); err != nil {
log.Printf("Mise à jour namespace après migration premium échouée pour %s: %v", newNamespace, err)
}
}()
}
return nil
}
// urlFor construit l'URL publique d'une démo pour un namespace donné. Port
// omis s'il s'agit du 443 standard ; sinon affiché explicitement (ex.
// NodePort HTTPS sans LoadBalancer). Ne concerne que l'URL affichée : le
// Host() de l'IngressRoute reste sans port (voir buildIngressRouteValues),
// le port n'y a pas sa place pour le matching.
func (s *Service) urlFor(namespace string) string {
portSuffix := ""
if s.cfg.HTTPSPort != "" && s.cfg.HTTPSPort != "443" {
portSuffix = ":" + s.cfg.HTTPSPort
}
return "https://" + namespace + "." + s.cfg.BaseDomain + portSuffix
}
// Delete déclenche le teardown et libère le pool.
func (s *Service) Delete(id string) (Demo, error) {
d, ok := s.store.Get(id)
@@ -19,6 +19,8 @@ type Store interface {
UpdatePassword(id, passwordHahs string) (auth.User, error)
GetTelegram(id string) (auth.User, error)
SetTelegram(id, telegram string) (auth.User, error)
GetAlertSettings(id string) (auth.User, error)
SetAlertSettings(id string, discordWebhookURL, telegramBotToken, telegramChatID string) (auth.User, error)
}
func (s *GormStore) UpdateUsername(id, newUsername string) (auth.User, error) {
@@ -71,3 +73,36 @@ func (s *GormStore) SetTelegram(id, telegram string) (auth.User, error) {
}
return user, nil
}
func (s *GormStore) GetAlertSettings(id string) (auth.User, error) {
var user auth.User
if err := s.db.First(&user, "id = ?", id).Error; err != nil {
return auth.User{}, err
}
return user, nil
}
// SetAlertSettings enregistre les réglages d'alerte de cet admin. Une chaîne
// vide efface le champ correspondant (désactive ce canal pour cet admin).
func (s *GormStore) SetAlertSettings(id string, discordWebhookURL, telegramBotToken, telegramChatID string) (auth.User, error) {
updates := map[string]interface{}{
"alert_discord_webhook_url": nullIfEmpty(discordWebhookURL),
"alert_telegram_bot_token": nullIfEmpty(telegramBotToken),
"alert_telegram_chat_id": nullIfEmpty(telegramChatID),
}
if err := s.db.Model(&auth.User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return auth.User{}, err
}
var user auth.User
if err := s.db.First(&user, "id = ?", id).Error; err != nil {
return auth.User{}, err
}
return user, nil
}
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
@@ -27,6 +27,32 @@ type newTelegram struct {
Telegram string `json:"telegram" binding:"required,min=3,max=64"`
}
type alertSettingsRequest struct {
DiscordWebhookURL string `json:"discord_webhook_url" binding:"omitempty,max=500,url"`
TelegramBotToken string `json:"telegram_bot_token" binding:"omitempty,max=200"`
TelegramChatID string `json:"telegram_chat_id" binding:"omitempty,max=64"`
}
type alertSettingsResponse struct {
DiscordWebhookURL string `json:"discord_webhook_url"`
TelegramBotToken string `json:"telegram_bot_token"`
TelegramChatID string `json:"telegram_chat_id"`
}
func toAlertSettingsResponse(user auth.User) alertSettingsResponse {
deref := func(s *string) string {
if s == nil {
return ""
}
return *s
}
return alertSettingsResponse{
DiscordWebhookURL: deref(user.AlertDiscordWebhookURL),
TelegramBotToken: deref(user.AlertTelegramBotToken),
TelegramChatID: deref(user.AlertTelegramChatID),
}
}
func (h *Handler) UpdateUsernameById(c *gin.Context) {
var u newUsername
@@ -110,3 +136,40 @@ func (h *Handler) SetTelegramById(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"telegram": user.Telegram})
}
// GetAlerts : réglages d'alerte monitoring (webhook Discord / bot Telegram)
// de l'admin authentifié — chacun configure les siens, rien de partagé.
func (h *Handler) GetAlerts(c *gin.Context) {
p := auth.PrincipalFrom(c)
if p == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
return
}
user, err := h.store.GetAlertSettings(p.UserID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
return
}
c.JSON(http.StatusOK, toAlertSettingsResponse(user))
}
// SetAlerts : enregistre les réglages d'alerte de l'admin authentifié. Un
// champ vide désactive le canal correspondant.
func (h *Handler) SetAlerts(c *gin.Context) {
var req alertSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
return
}
p := auth.PrincipalFrom(c)
if p == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
return
}
user, err := h.store.SetAlertSettings(p.UserID, req.DiscordWebhookURL, req.TelegramBotToken, req.TelegramChatID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"})
return
}
c.JSON(http.StatusOK, toAlertSettingsResponse(user))
}
@@ -68,6 +68,8 @@ func New(d Deps) *gin.Engine {
{
admin.GET("/codes", d.SubH.ListCodes)
admin.POST("/codes", d.SubH.CreateCodeForBuy)
admin.GET("/profile/alerts", d.ProfileH.GetAlerts)
admin.POST("/profile/alerts", d.ProfileH.SetAlerts)
if d.DemosH != nil {
admin.POST("/demos", d.DemosH.Create)
admin.GET("/demos", d.DemosH.List)