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

This commit is contained in:
Nuxgrid
2026-08-02 13:48:21 +02:00
parent eeec8e239b
commit d4009d329c
10 changed files with 343 additions and 604 deletions
+29 -1
View File
@@ -40,7 +40,12 @@ var (
// transitent que par le fichier de valeurs Helm temporaire puis le Secret
// k8s du chart, comme les autres secrets.
type ProvisionConfig struct {
TelegramBotToken string
// TelegramBotUsername alimente TELEGRAM_BOT_USERNAME (lu par le backend
// au démarrage). TELEGRAM_WEBHOOK_URL est calculée automatiquement
// (URL publique de la démo) ; TELEGRAM_WEBHOOK_SECRET reste généré par
// le pool de ressources (ServiceTelegram), jamais saisi par l'admin.
TelegramBotUsername string
TelegramBotToken string
// NowPaymentsAPIKey écrase NOWPAYMENTS_API_KEY. NowPaymentsIPNSecret,
// s'il est fourni, écrase le secret pré-provisionné par le pool
// (ServiceNowPayments) — sinon celui du pool reste utilisé.
@@ -49,6 +54,29 @@ type ProvisionConfig struct {
StorageDriver string // "local" | "s3", "" => "local"
S3Bucket string
S3Endpoint string
// Load-balancer Telegram (chart lbtelegram) : installé seulement si au
// moins un bot est renseigné. Le reste de la config du chart (DSN
// postgres/redis, secrets JWT/webhook/backend-link) est généré par le
// provisioner, pas saisi par l'admin.
LBBot1Username string
LBBot1Token string
LBBot2Username string
LBBot2Token string
// Compte admin de l'application déployée (pas le compte omnex de
// l'admin qui lance la démo) — requis : sans lui, personne ne peut se
// connecter au backoffice de la démo. Créé directement en base une fois
// postgres/redis/backend/frontend Running (voir HelmProvisioner.Provision),
// car la table users n'existe qu'après la migration du backend au
// premier démarrage.
AdminUsername string
AdminPassword string
}
// LBTelegramEnabled indique si au moins un bot du load-balancer est configuré.
func (c ProvisionConfig) LBTelegramEnabled() bool {
return c.LBBot1Username != "" || c.LBBot2Username != ""
}
// Validate vérifie la cohérence du choix de stockage.
@@ -23,12 +23,23 @@ type createRequest struct {
Username string `json:"username" binding:"omitempty,min=3,max=64,alphanum"`
// Réglages saisis dans le popup de déploiement (voir ProvisionConfig).
TelegramBotUsername string `json:"telegram_bot_username" binding:"omitempty,max=64"`
TelegramBotToken string `json:"telegram_bot_token" binding:"omitempty"`
NowPaymentsAPIKey string `json:"nowpayments_api_key" binding:"omitempty"`
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret" binding:"omitempty"`
StorageDriver string `json:"storage_driver" binding:"omitempty,oneof=local s3"`
S3Bucket string `json:"s3_bucket" binding:"omitempty,max=63"`
S3Endpoint string `json:"s3_endpoint" binding:"omitempty,max=255"`
LBBot1Username string `json:"lb_bot1_username" binding:"omitempty,max=64"`
LBBot1Token string `json:"lb_bot1_token" binding:"omitempty"`
LBBot2Username string `json:"lb_bot2_username" binding:"omitempty,max=64"`
LBBot2Token string `json:"lb_bot2_token" binding:"omitempty"`
// Compte admin de l'application déployée — requis, sans lui personne ne
// peut se connecter au backoffice de la démo.
AdminUsername string `json:"admin_username" binding:"required,min=3,max=64,alphanum"`
AdminPassword string `json:"admin_password" binding:"required,min=8"`
}
type DetailDemoUserRequest struct {
@@ -52,12 +63,19 @@ func (h *Handler) Create(c *gin.Context) {
return
}
cfg := ProvisionConfig{
TelegramBotUsername: req.TelegramBotUsername,
TelegramBotToken: req.TelegramBotToken,
NowPaymentsAPIKey: req.NowPaymentsAPIKey,
NowPaymentsIPNSecret: req.NowPaymentsIPNSecret,
StorageDriver: req.StorageDriver,
S3Bucket: req.S3Bucket,
S3Endpoint: req.S3Endpoint,
LBBot1Username: req.LBBot1Username,
LBBot1Token: req.LBBot1Token,
LBBot2Username: req.LBBot2Username,
LBBot2Token: req.LBBot2Token,
AdminUsername: req.AdminUsername,
AdminPassword: req.AdminPassword,
}
d, err := h.svc.Create(req.LeadID, req.Username, cfg)
if err != nil {
@@ -3,6 +3,8 @@ package demos
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log"
@@ -14,6 +16,7 @@ import (
"github.com/omnex/control-plane/api/internal/config"
"go.yaml.in/yaml/v2"
"golang.org/x/crypto/bcrypt"
k8sCoreV1 "k8s.io/api/core/v1"
k8sErrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -102,7 +105,16 @@ func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource, cfg Pr
// en stockage persistant lorsqu'un client devient payant.
postgresValues := h.buildPostgresValues(d, false)
redisValues := h.buildRedisValues(d, false)
backendValues := h.buildBackendValues(d, resources, cfg)
// Secret partagé backend <-> lbtelegram (authentifie les appels de
// lbtelegram vers backend) — généré une seule fois si le load-balancer
// Telegram est activé, vide sinon (le chart lbtelegram n'est pas installé).
var backendLinkSecret string
if cfg.LBTelegramEnabled() {
backendLinkSecret = randomSecret()
}
backendValues := h.buildBackendValues(d, resources, cfg, backendLinkSecret)
frontendValues := h.buildFrontendValues(d)
ingressValues := h.buildIngressRouteValues(d)
@@ -137,9 +149,28 @@ func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource, cfg Pr
return fmt.Errorf("déploiement ingressroute: %w", err)
}
// Attendre que les pods soient prêts
// Load-balancer Telegram : optionnel, seulement si l'admin a renseigné
// au moins un bot.
if cfg.LBTelegramEnabled() {
lbValues := h.buildLBTelegramValues(d, cfg, backendLinkSecret)
if err := h.installChart(d.Namespace, "lbtelegram", lbValues); err != nil {
h.deleteNamespace(d.Namespace)
return fmt.Errorf("déploiement lbtelegram: %w", err)
}
}
// Attendre que postgres/redis/backend/frontend soient tous Running avant
// de créer le compte admin : la table "users" n'existe qu'une fois que
// le backend a fait tourner sa migration au démarrage, donc tenter
// l'insertion avant que le rollout ne soit confirmé échouerait
// ("relation users does not exist").
if err := h.waitForRollout(d.Namespace); err != nil {
log.Printf("Warning: rollout check échoué pour %s: %v", d.Namespace, err)
log.Printf("Warning: rollout check échoué pour %s: %v — compte admin non créé", d.Namespace, err)
return nil
}
if err := h.createGestionAdmin(context.Background(), d.Namespace, cfg.AdminUsername, cfg.AdminPassword); err != nil {
log.Printf("Warning: création du compte admin échouée pour %s: %v", d.Namespace, err)
}
return nil
@@ -171,6 +202,53 @@ func (h *HelmProvisioner) Teardown(d Demo) error {
return h.deleteNamespace(d.Namespace)
}
// 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
// un compte admin, par design de l'app). Mot de passe haché en bcrypt
// (golang.org/x/crypto/bcrypt), comme le fait l'app elle-même
// (bcrypt.CompareHashAndPassword côté LoginAdmin).
func (h *HelmProvisioner) createGestionAdmin(ctx context.Context, namespace, username, password string) error {
if username == "" || password == "" {
return fmt.Errorf("username/password admin manquants")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash bcrypt: %w", err)
}
pgPod, err := h.findPod(ctx, namespace, "postgresql")
if err != nil {
return fmt.Errorf("pod postgresql introuvable: %w", err)
}
// Échappement basique des quotes simples : le username est déjà validé
// alphanumérique côté handler HTTP, le hash bcrypt ne contient jamais de
// guillemet (alphabet base64 restreint ./A-Za-z0-9).
escapedUsername := strings.ReplaceAll(username, "'", "''")
sql := fmt.Sprintf(
`INSERT INTO users (username, password, role) VALUES ('%s', '%s', 'admin') ON CONFLICT (username) DO NOTHING;`,
escapedUsername, string(hash),
)
shellCmd := fmt.Sprintf("PGPASSWORD=%s psql -h localhost -U %s -d %s -v ON_ERROR_STOP=1 -c %s",
demoDBPass, demoDBUser, demoDBName, shellQuote(sql))
_, stderr, err := h.execInPod(ctx, namespace, pgPod, "postgresql", []string{"sh", "-c", shellCmd}, nil)
if err != nil {
return fmt.Errorf("insertion compte admin: %w (%s)", err, stderr)
}
log.Printf("Compte admin %q créé pour la démo %s", username, namespace)
return nil
}
// shellQuote entoure une chaîne de guillemets simples pour un usage sûr dans
// une commande sh -c (échappe les guillemets simples qu'elle contient).
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// Identifiants postgres des démos — mêmes valeurs codées en dur que
// buildPostgresValues/buildBackendValues (voir ces fonctions).
const (
@@ -397,7 +475,9 @@ func parseImage(image string) (repo string, tag string) {
}
// buildBackendValues construit les valeurs pour le chart backend.
func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource, cfg ProvisionConfig) map[string]interface{} {
// backendLinkSecret : partagé avec le chart lbtelegram (voir
// buildLBTelegramValues) — vide si le load-balancer Telegram n'est pas activé.
func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource, cfg ProvisionConfig, backendLinkSecret string) map[string]interface{} {
// Parser l'image backend pour séparer repository et tag
backendRepo, backendTag := parseImage(h.backendImage)
@@ -429,9 +509,17 @@ func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResourc
env["S3_ENDPOINT"] = cfg.S3Endpoint
}
if cfg.TelegramBotUsername != "" {
env["TELEGRAM_BOT_USERNAME"] = cfg.TelegramBotUsername
}
secrets := h.buildSecrets(resources)
if cfg.TelegramBotToken != "" {
secrets["TELEGRAM_BOT_TOKEN"] = cfg.TelegramBotToken
// URL publique du webhook Telegram de cette démo (route backend :
// POST /webhook/telegram, sans préfixe /api/v1). TELEGRAM_WEBHOOK_SECRET
// reste celui généré par le pool (h.buildSecrets ci-dessus).
secrets["TELEGRAM_WEBHOOK_URL"] = d.URL + "/webhook/telegram"
}
if cfg.NowPaymentsAPIKey != "" {
secrets["NOWPAYMENTS_API_KEY"] = cfg.NowPaymentsAPIKey
@@ -443,6 +531,12 @@ func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResourc
// marchand NowPayments, pas à une valeur générée par le pool.
secrets["NOWPAYMENTS_IPN_SECRET"] = cfg.NowPaymentsIPNSecret
}
if cfg.LBTelegramEnabled() {
env["LBTELEGRAM_URL"] = fmt.Sprintf("http://%s-lbtelegram-lbtelegram.%s.svc.cluster.local:8081", d.Namespace, d.Namespace)
env["LBTELEGRAM_BOT1_USERNAME"] = cfg.LBBot1Username
env["LBTELEGRAM_BOT2_USERNAME"] = cfg.LBBot2Username
secrets["BACKEND_LINK_SECRET"] = backendLinkSecret
}
values := map[string]interface{}{
"replicaCount": 1,
@@ -505,6 +599,58 @@ func (h *HelmProvisioner) buildIngressRouteValues(d Demo) map[string]interface{}
}
}
// buildLBTelegramValues construit les valeurs pour le chart lbtelegram
// (load-balancer multi-bots Telegram). N'est appelé que si
// cfg.LBTelegramEnabled() — au moins un bot renseigné par l'admin. Les
// identifiants des bots (username/token) viennent de l'admin ; le reste
// (DSN postgres/redis, secrets JWT/webhook/backend-link) est généré ici,
// comme indiqué par le commentaire du chart (deploy/chart-gestion/lbtelegram/values.yaml).
func (h *HelmProvisioner) buildLBTelegramValues(d Demo, cfg ProvisionConfig, backendLinkSecret string) map[string]interface{} {
botCount := 0
if cfg.LBBot1Username != "" {
botCount++
}
if cfg.LBBot2Username != "" {
botCount++
}
backendURL := fmt.Sprintf("http://%s-backend-gestion-backend.%s.svc.cluster.local:8080", d.Namespace, d.Namespace)
dbURL := fmt.Sprintf("postgres://postgres:demo-postgres-pass@%s-postgresql-postgresql:5432/demo_db?sslmode=disable", d.Namespace)
redisURL := fmt.Sprintf("redis://:demo-redis-pass@%s-redis-redis:6379/0", d.Namespace)
return map[string]interface{}{
"env": map[string]string{
"PORT": "8081",
"ENV": "production",
"BOT_COUNT": fmt.Sprintf("%d", botCount),
"BOT1_USERNAME": cfg.LBBot1Username,
"BOT2_USERNAME": cfg.LBBot2Username,
"BACKEND_LINK_URL": backendURL,
},
"secrets": map[string]string{
"BOT1_TOKEN": cfg.LBBot1Token,
"BOT2_TOKEN": cfg.LBBot2Token,
"BOT1_WEBHOOK_SECRET": randomSecret(),
"BOT2_WEBHOOK_SECRET": randomSecret(),
"BACKEND_LINK_SECRET": backendLinkSecret,
"JWT_SECRET": randomSecret(),
"DATABASE_URL": dbURL,
"REDIS_URL": redisURL,
},
}
}
// randomSecret génère un secret hexadécimal aléatoire de 32 octets (256 bits).
func randomSecret() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
// crypto/rand.Read ne devrait jamais échouer sur un système normal ;
// fail-secure plutôt que de retourner un secret prévisible.
panic(fmt.Sprintf("randomSecret: %v", err))
}
return hex.EncodeToString(b)
}
// buildSecrets extrait les références des secrets des ressources externes.
func (h *HelmProvisioner) buildSecrets(resources []ExternalResource) map[string]string {
secrets := map[string]string{}