diff --git a/control-plane/api/cmd/api/main.go b/control-plane/api/cmd/api/main.go index e79e897..060dc1c 100644 --- a/control-plane/api/cmd/api/main.go +++ b/control-plane/api/cmd/api/main.go @@ -104,6 +104,13 @@ func main() { log.Fatalf("k8s client: %v", err) } + // Config REST brute — nécessaire pour l'exec dans les pods (pg_dump / + // restore lors du passage d'une démo en abonnement payant). + 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) @@ -112,7 +119,7 @@ func main() { metricsClient = nil } - helmProv, err := demos.NewHelmProvisioner(&cfg, k8sClient, metricsClient, "/charts", "helm") + helmProv, err := demos.NewHelmProvisioner(&cfg, k8sClient, restConfig, metricsClient, "/charts", "helm") if err != nil { log.Fatalf("helm provisioner: %v", err) } @@ -136,7 +143,6 @@ func main() { BaseDomain: cfg.DemoDomain, HTTPSPort: cfg.DemoHTTPSPort, TTL: demos.TTL, - MaxDemos: demos.MaxConcurrentDemos, }) deps := router.Deps{ @@ -146,7 +152,7 @@ func main() { AuthH: auth.NewHandler(userStore, sessions, iss, cfg.Secure()), LeadsH: leads.NewHandler(leadStore), DemosH: demos.NewHandler(demoSvc, helmProv), - SubH: sub.NewHandler(codeStore), + SubH: sub.NewHandler(codeStore, demoSvc), ProfileH: profile.NewHandler(profileStore), } diff --git a/control-plane/api/go.mod b/control-plane/api/go.mod index 8aefcd0..240c7e4 100644 --- a/control-plane/api/go.mod +++ b/control-plane/api/go.mod @@ -15,6 +15,7 @@ require ( k8s.io/api v0.36.3 k8s.io/apimachinery v0.36.3 k8s.io/client-go v0.36.3 + k8s.io/metrics v0.36.3 ) require ( @@ -38,6 +39,7 @@ require ( github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/google/gnostic-models v0.7.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect @@ -50,6 +52,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/spdystream v0.5.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -72,7 +75,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect - k8s.io/metrics v0.36.3 // indirect + k8s.io/streaming v0.36.3 // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/control-plane/api/go.sum b/control-plane/api/go.sum index e3e8aea..3b064b6 100644 --- a/control-plane/api/go.sum +++ b/control-plane/api/go.sum @@ -1,3 +1,5 @@ +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -58,6 +60,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= @@ -91,6 +95,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= +github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -183,6 +189,8 @@ k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hk k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/metrics v0.36.3 h1:NDKceAgWS8CJCdDtM5kFACkBOa9Lxia1jUiibJfvUgQ= k8s.io/metrics v0.36.3/go.mod h1:NTLS8ybwn+zYGwKqYublWPvmnNp8N4pV3etjtx7XWaM= +k8s.io/streaming v0.36.3 h1:9rAaqBk0C0Pc7+/fqGekj07NV+/Xrew58p647A0JT8w= +k8s.io/streaming v0.36.3/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/control-plane/api/internal/demos/demos.go b/control-plane/api/internal/demos/demos.go index 77707d4..ca8a51a 100644 --- a/control-plane/api/internal/demos/demos.go +++ b/control-plane/api/internal/demos/demos.go @@ -2,12 +2,15 @@ // dans le cluster (une démo = un namespace isolé, TTL 30 jours). package demos -import "time" +import ( + "errors" + "time" +) -// Paramètres capacité / durée de vie. +// Paramètres durée de vie. Pas de quota sur le nombre de démos : seule +// limite réelle, le pool de ressources externes (voir PoolSizePerService). const ( TTL = 30 * 24 * time.Hour // durée de vie d'une démo - MaxConcurrentDemos = 5 // capacité cible PoolSizePerService = 5 // slots par service externe poolé ) @@ -21,6 +24,48 @@ const ( // PooledServices : services nécessitant un slot de pool par démo. var PooledServices = []string{ServiceTelegram, ServiceNowPayments} +const ( + StorageLocal = "local" + StorageS3 = "s3" +) + +var ( + ErrInvalidStorageDriver = errors.New("storage_driver doit être 'local' ou 's3'") + ErrS3ConfigIncomplete = errors.New("s3_bucket et s3_endpoint sont requis pour le stockage S3") +) + +// ProvisionConfig : réglages saisis par l'admin à la création (popup de +// déploiement), propagés jusqu'au chart backend. Jamais persisté en base — +// notamment les secrets (token Telegram, clés NowPayments), qui ne +// transitent que par le fichier de valeurs Helm temporaire puis le Secret +// k8s du chart, comme les autres secrets. +type ProvisionConfig struct { + 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é. + NowPaymentsAPIKey string + NowPaymentsIPNSecret string + StorageDriver string // "local" | "s3", "" => "local" + S3Bucket string + S3Endpoint string +} + +// Validate vérifie la cohérence du choix de stockage. +func (c ProvisionConfig) Validate() error { + switch c.StorageDriver { + case "", StorageLocal: + return nil + case StorageS3: + if c.S3Bucket == "" || c.S3Endpoint == "" { + return ErrS3ConfigIncomplete + } + return nil + default: + return ErrInvalidStorageDriver + } +} + // Status : état d'une démo. type Status string diff --git a/control-plane/api/internal/demos/handler.go b/control-plane/api/internal/demos/handler.go index 5b05b31..fefa014 100644 --- a/control-plane/api/internal/demos/handler.go +++ b/control-plane/api/internal/demos/handler.go @@ -21,6 +21,14 @@ func NewHandler(svc *Service, helm *HelmProvisioner) *Handler { type createRequest struct { LeadID string `json:"lead_id" binding:"omitempty,uuid4"` Username string `json:"username" binding:"omitempty,min=3,max=64,alphanum"` + + // Réglages saisis dans le popup de déploiement (voir ProvisionConfig). + 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"` } type DetailDemoUserRequest struct { @@ -43,13 +51,23 @@ func (h *Handler) Create(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) return } - d, err := h.svc.Create(req.LeadID, req.Username) + cfg := ProvisionConfig{ + TelegramBotToken: req.TelegramBotToken, + NowPaymentsAPIKey: req.NowPaymentsAPIKey, + NowPaymentsIPNSecret: req.NowPaymentsIPNSecret, + StorageDriver: req.StorageDriver, + S3Bucket: req.S3Bucket, + S3Endpoint: req.S3Endpoint, + } + d, err := h.svc.Create(req.LeadID, req.Username, cfg) if err != nil { switch { - case errors.Is(err, ErrCapacityReached): - c.JSON(http.StatusConflict, gin.H{"error": "capacité maximale de démos atteinte"}) + case errors.Is(err, ErrPoolExhausted): + c.JSON(http.StatusConflict, gin.H{"error": "plus de ressources externes disponibles pour le moment (slots Telegram/NowPayments épuisés)"}) case errors.Is(err, ErrUserHasActiveDemo): c.JSON(http.StatusConflict, gin.H{"error": "ce client a déjà une démo active"}) + case errors.Is(err, ErrS3ConfigIncomplete), errors.Is(err, ErrInvalidStorageDriver): + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) default: c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) } diff --git a/control-plane/api/internal/demos/helm_provisioner.go b/control-plane/api/internal/demos/helm_provisioner.go index 23e6aca..fac7fc5 100644 --- a/control-plane/api/internal/demos/helm_provisioner.go +++ b/control-plane/api/internal/demos/helm_provisioner.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "io" "log" "os" "os/exec" @@ -17,6 +18,9 @@ import ( k8sErrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" metricsclient "k8s.io/metrics/pkg/client/clientset/versioned" ) @@ -25,6 +29,7 @@ import ( type HelmProvisioner struct { cfg *config.Config k8sClient *kubernetes.Clientset + restConfig *rest.Config // pour l'exec dans les pods (pg_dump/restore) metricsClient *metricsclient.Clientset // optionnel : nil si metrics-server indisponible chartsDir string // chemin vers le dossier des charts (ex: /charts) frontendImage string @@ -40,6 +45,7 @@ type HelmProvisioner struct { func NewHelmProvisioner( cfg *config.Config, k8sClient *kubernetes.Clientset, + restConfig *rest.Config, metricsClient *metricsclient.Clientset, chartsDir string, helmPath string, @@ -59,6 +65,7 @@ func NewHelmProvisioner( return &HelmProvisioner{ cfg: cfg, k8sClient: k8sClient, + restConfig: restConfig, metricsClient: metricsClient, chartsDir: chartsDir, frontendImage: cfg.FrontendImage, @@ -84,16 +91,18 @@ const ( // 4. Installe le chart backend // 5. Installe le chart frontend // 6. Raccorde la démo au Traefik partagé (WAF + routage HTTPS du sous-domaine) -func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource) error { +func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource, cfg ProvisionConfig) error { // Créer le namespace if err := h.createNamespace(d.Namespace); err != nil { return fmt.Errorf("création namespace %s: %w", d.Namespace, err) } - // Préparer les valeurs - postgresValues := h.buildPostgresValues(d) - redisValues := h.buildRedisValues(d) - backendValues := h.buildBackendValues(d, resources) + // Préparer les valeurs. Pas de persistence à la création : les démos + // d'essai sont éphémères (TTL 30j) — voir UpgradeToPaid pour le passage + // en stockage persistant lorsqu'un client devient payant. + postgresValues := h.buildPostgresValues(d, false) + redisValues := h.buildRedisValues(d, false) + backendValues := h.buildBackendValues(d, resources, cfg) frontendValues := h.buildFrontendValues(d) ingressValues := h.buildIngressRouteValues(d) @@ -162,6 +171,111 @@ func (h *HelmProvisioner) Teardown(d Demo) error { return h.deleteNamespace(d.Namespace) } +// Identifiants postgres des démos — mêmes valeurs codées en dur que +// buildPostgresValues/buildBackendValues (voir ces fonctions). +const ( + demoDBUser = "postgres" + demoDBName = "demo_db" + demoDBPass = "demo-postgres-pass" +) + +// UpgradeToPaid bascule postgresql/redis d'une démo en stockage persistant +// lorsqu'un client passe en abonnement payant, en préservant les données +// postgres déjà créées pendant l'essai : pg_dump avant la bascule (le volume +// actuel est un emptyDir éphémère), puis restore une fois le nouveau volume +// persistant en place. Redis n'est pas migré (cache/sessions — reconstruit +// naturellement), seule sa persistence est activée pour la suite. +func (h *HelmProvisioner) UpgradeToPaid(d Demo) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + pgPod, err := h.findPod(ctx, d.Namespace, "postgresql") + if err != nil { + return fmt.Errorf("pod postgresql introuvable: %w", err) + } + + dumpCmd := fmt.Sprintf("PGPASSWORD=%s pg_dump -h localhost -U %s %s", demoDBPass, demoDBUser, demoDBName) + dump, stderr, err := h.execInPod(ctx, d.Namespace, pgPod, "postgresql", []string{"sh", "-c", dumpCmd}, nil) + if err != nil { + return fmt.Errorf("pg_dump: %w (%s)", err, stderr) + } + + // Bascule postgresql en stockage persistant : le pod redémarre (nouveau + // PVC, initialement vide) — c'est pour ça que le dump ci-dessus doit + // être pris AVANT cet appel. helm upgrade --wait attend que le nouveau + // pod soit prêt (readinessProbe = pg_isready) avant de retourner. + if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-postgresql", "postgresql", h.buildPostgresValues(d, true)); err != nil { + return fmt.Errorf("upgrade postgresql (persistence): %w", err) + } + + newPgPod, err := h.findPod(ctx, d.Namespace, "postgresql") + if err != nil { + return fmt.Errorf("pod postgresql introuvable après upgrade: %w", err) + } + + if strings.TrimSpace(dump) != "" { + restoreCmd := fmt.Sprintf("PGPASSWORD=%s psql -h localhost -U %s %s", demoDBPass, demoDBUser, demoDBName) + _, stderr, err := h.execInPod(ctx, d.Namespace, newPgPod, "postgresql", []string{"sh", "-c", restoreCmd}, strings.NewReader(dump)) + if err != nil { + return fmt.Errorf("restore pg_dump: %w (%s)", err, stderr) + } + } + + if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-redis", "redis", h.buildRedisValues(d, true)); err != nil { + return fmt.Errorf("upgrade redis (persistence): %w", err) + } + + log.Printf("Démo %s basculée en stockage persistant (données postgres migrées)", d.Namespace) + return nil +} + +// findPod retourne le nom du premier pod du namespace dont le nom contient nameContains. +func (h *HelmProvisioner) findPod(ctx context.Context, namespace, nameContains string) (string, error) { + pods, err := h.k8sClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return "", err + } + for _, p := range pods.Items { + if strings.Contains(p.Name, nameContains) { + return p.Name, nil + } + } + return "", fmt.Errorf("aucun pod contenant %q dans %s", nameContains, namespace) +} + +// execInPod exécute une commande dans un conteneur et capture stdout/stderr. +// stdin peut être nil (aucune entrée à fournir). +func (h *HelmProvisioner) execInPod(ctx context.Context, namespace, pod, container string, command []string, stdin io.Reader) (string, string, error) { + req := h.k8sClient.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(pod). + Namespace(namespace). + SubResource("exec"). + VersionedParams(&k8sCoreV1.PodExecOptions{ + Container: container, + Command: command, + Stdin: stdin != nil, + Stdout: true, + Stderr: true, + }, scheme.ParameterCodec) + + executor, err := remotecommand.NewSPDYExecutor(h.restConfig, "POST", req.URL()) + if err != nil { + return "", "", fmt.Errorf("exec setup: %w", err) + } + + var stdout, stderrBuf bytes.Buffer + err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdin: stdin, + Stdout: &stdout, + Stderr: &stderrBuf, + }) + if err != nil { + return stdout.String(), stderrBuf.String(), err + } + return stdout.String(), stderrBuf.String(), nil +} + // createNamespace crée un namespace Kubernetes. func (h *HelmProvisioner) createNamespace(name string) error { ns := &k8sCoreV1.Namespace{ @@ -283,10 +397,53 @@ func parseImage(image string) (repo string, tag string) { } // buildBackendValues construit les valeurs pour le chart backend. -func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource) map[string]interface{} { +func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource, cfg ProvisionConfig) map[string]interface{} { // Parser l'image backend pour séparer repository et tag backendRepo, backendTag := parseImage(h.backendImage) + storageDriver := cfg.StorageDriver + if storageDriver == "" { + storageDriver = StorageLocal + } + + // PVC uploads inutile si le stockage est S3 (rien n'est écrit sur le + // disque local dans ce cas). + uploadsEnabled := storageDriver == StorageLocal + + env := map[string]string{ + "DB_HOST": fmt.Sprintf("%s-postgresql-postgresql", d.Namespace), + "DB_PORT": "5432", + "DB_PASSWORD": "demo-postgres-pass", + "DB_USER": "postgres", + "DB_NAME": "demo_db", + "DB_SSLMODE": "disable", + "REDIS_HOST": fmt.Sprintf("%s-redis-redis", d.Namespace), + "REDIS_PORT": "6379", + "REDIS_PASSWORD": "demo-redis-pass", // Mot de passe Redis + "API_PORT": "8080", + "NODE_ENV": "production", + "STORAGE_DRIVER": storageDriver, + } + if storageDriver == StorageS3 { + env["S3_BUCKET"] = cfg.S3Bucket + env["S3_ENDPOINT"] = cfg.S3Endpoint + } + + secrets := h.buildSecrets(resources) + if cfg.TelegramBotToken != "" { + secrets["TELEGRAM_BOT_TOKEN"] = cfg.TelegramBotToken + } + if cfg.NowPaymentsAPIKey != "" { + secrets["NOWPAYMENTS_API_KEY"] = cfg.NowPaymentsAPIKey + } + if cfg.NowPaymentsIPNSecret != "" { + // Écrase le secret pré-provisionné par le pool (ServiceNowPayments) : + // contrairement au webhook Telegram, l'IPN NowPayments doit + // correspondre à ce qui est réellement configuré sur le compte + // marchand NowPayments, pas à une valeur générée par le pool. + secrets["NOWPAYMENTS_IPN_SECRET"] = cfg.NowPaymentsIPNSecret + } + values := map[string]interface{}{ "replicaCount": 1, "image": map[string]interface{}{ @@ -303,23 +460,11 @@ func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResourc }, "persistence": map[string]interface{}{ "uploads": map[string]interface{}{ - "enabled": false, + "enabled": uploadsEnabled, }, }, - "env": map[string]string{ - "DB_HOST": fmt.Sprintf("%s-postgresql-postgresql", d.Namespace), - "DB_PORT": "5432", - "DB_PASSWORD": "demo-postgres-pass", - "DB_USER": "postgres", - "DB_NAME": "demo_db", - "DB_SSLMODE": "disable", - "REDIS_HOST": fmt.Sprintf("%s-redis-redis", d.Namespace), - "REDIS_PORT": "6379", - "REDIS_PASSWORD": "demo-redis-pass", // Mot de passe Redis - "API_PORT": "8080", - "NODE_ENV": "production", - }, - "secrets": h.buildSecrets(resources), + "env": env, + "secrets": secrets, } return values @@ -375,7 +520,7 @@ func (h *HelmProvisioner) buildSecrets(resources []ExternalResource) map[string] } // buildPostgresValues construit les valeurs pour le chart postgresql. -func (h *HelmProvisioner) buildPostgresValues(d Demo) map[string]interface{} { +func (h *HelmProvisioner) buildPostgresValues(d Demo, persistent bool) map[string]interface{} { return map[string]interface{}{ "auth": map[string]interface{}{ "password": "demo-postgres-pass", // Mot de passe OBLIGATOIRE (champ correct pour le chart) @@ -387,13 +532,13 @@ func (h *HelmProvisioner) buildPostgresValues(d Demo) map[string]interface{} { "port": 5432, }, "persistence": map[string]interface{}{ - "enabled": false, + "enabled": persistent, }, } } // buildRedisValues construit les valeurs pour le chart redis. -func (h *HelmProvisioner) buildRedisValues(d Demo) map[string]interface{} { +func (h *HelmProvisioner) buildRedisValues(d Demo, persistent bool) map[string]interface{} { return map[string]interface{}{ "service": map[string]interface{}{ "type": "ClusterIP", @@ -403,7 +548,7 @@ func (h *HelmProvisioner) buildRedisValues(d Demo) map[string]interface{} { "password": "demo-redis-pass", // Mot de passe simple pour les démos }, "persistence": map[string]interface{}{ - "enabled": false, // Pas de persistence pour les démos + "enabled": persistent, }, } } diff --git a/control-plane/api/internal/demos/provisioner.go b/control-plane/api/internal/demos/provisioner.go index 248c6fd..529dff8 100644 --- a/control-plane/api/internal/demos/provisioner.go +++ b/control-plane/api/internal/demos/provisioner.go @@ -7,9 +7,12 @@ import "context" // contente d'enfiler les intentions. type Provisioner interface { // Provision demande le déploiement d'une démo (asynchrone). - Provision(d Demo, resources []ExternalResource) error + Provision(d Demo, resources []ExternalResource, cfg ProvisionConfig) error // Teardown demande la destruction d'une démo. Teardown(d Demo) error + // UpgradeToPaid bascule postgresql/redis en stockage persistant, en + // migrant les données postgres déjà créées (pg_dump/restore). + UpgradeToPaid(d Demo) error GetResourceState(ctx context.Context, namespace string) (ResourceState, error) } @@ -17,8 +20,9 @@ type Provisioner interface { // Sert au scaffolding et aux tests tant que le worker n'existe pas. type NoopProvisioner struct{} -func (NoopProvisioner) Provision(Demo, []ExternalResource) error { return nil } -func (NoopProvisioner) Teardown(Demo) error { return nil } +func (NoopProvisioner) Provision(Demo, []ExternalResource, ProvisionConfig) error { return nil } +func (NoopProvisioner) Teardown(Demo) error { return nil } +func (NoopProvisioner) UpgradeToPaid(Demo) error { return nil } func (NoopProvisioner) GetResourceState(ctx context.Context, namespace string) (ResourceState, error) { return ResourceState{}, nil } diff --git a/control-plane/api/internal/demos/service.go b/control-plane/api/internal/demos/service.go index 3c8bf49..0f1e295 100644 --- a/control-plane/api/internal/demos/service.go +++ b/control-plane/api/internal/demos/service.go @@ -11,7 +11,6 @@ import ( ) var ( - ErrCapacityReached = errors.New("capacité maximale de démos atteinte") ErrNotFound = errors.New("démo introuvable") ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel") ErrUserHasActiveDemo = errors.New("ce client a déjà une démo active") @@ -22,7 +21,6 @@ type Config struct { BaseDomain string // ex. "demo.omnex.app" HTTPSPort string // port public HTTPS pour l'URL affichée ("443" = omis, sinon ex. NodePort) TTL time.Duration // durée de vie - MaxDemos int // capacité } // Service : logique métier des démos (indépendante du transport HTTP). @@ -38,9 +36,6 @@ func NewService(store Store, pool Pool, prov Provisioner, cfg Config) *Service { if cfg.TTL == 0 { cfg.TTL = TTL } - if cfg.MaxDemos == 0 { - cfg.MaxDemos = MaxConcurrentDemos - } if cfg.BaseDomain == "" { cfg.BaseDomain = "demo.omnex.app" } @@ -52,8 +47,12 @@ func NewService(store Store, pool Pool, prov Provisioner, cfg Config) *Service { // Create réserve la capacité + le pool, persiste la démo et déclenche le worker. // username (optionnel) rattache la démo à un client existant. Un client n'a -// droit qu'à une seule démo active à la fois. -func (s *Service) Create(leadID, username string) (Demo, error) { +// droit qu'à une seule démo active à la fois. cfg (bot Telegram, stockage) +// n'est jamais persisté : transmis tel quel au provisioning. +func (s *Service) Create(leadID, username string, cfg ProvisionConfig) (Demo, error) { + if err := cfg.Validate(); err != nil { + return Demo{}, err + } normalizedUsername := auth.NormalizeUsername(username) if normalizedUsername != "" { existing, err := s.store.ListByUsername(normalizedUsername) @@ -67,14 +66,6 @@ func (s *Service) Create(leadID, username string) (Demo, error) { } } - active, err := s.store.CountActive() - if err != nil { - return Demo{}, err - } - if active >= s.cfg.MaxDemos { - return Demo{}, ErrCapacityReached - } - id := uuid.NewString() demo := Demo{ ID: id, @@ -95,12 +86,11 @@ func (s *Service) Create(leadID, username string) (Demo, error) { } demo.URL = "https://" + demo.Namespace + "." + s.cfg.BaseDomain + portSuffix - // Réserve les ressources externes ; ErrPoolExhausted => capacité atteinte. + // Réserve les ressources externes (slots Telegram/NowPayments pré- + // provisionnés) — contrainte réelle indépendante du nombre de démos, + // non concernée par la suppression du quota. resources, err := s.pool.Borrow(id) if err != nil { - if errors.Is(err, ErrPoolExhausted) { - return Demo{}, ErrCapacityReached - } return Demo{}, err } @@ -113,7 +103,7 @@ func (s *Service) Create(leadID, username string) (Demo, error) { // Lance le déploiement de manière asynchrone (worker inline via goroutine). go func() { - if err := s.prov.Provision(created, resources); err != nil { + if err := s.prov.Provision(created, resources, cfg); err != nil { log.Printf("Provisioning échoué pour %s: %v", created.ID, err) created.Status = StatusFailed _, _ = s.store.Update(created) @@ -144,6 +134,45 @@ func (s *Service) ListForUser(username string) ([]Demo, error) { return s.store.ListByUsername(auth.NormalizeUsername(username)) } +// TransferToPaid marque la démo active du client comme abonnement payant +// (n'expire plus) et déclenche la bascule en stockage persistant (migration +// des données postgres incluse, voir Provisioner.UpgradeToPaid). 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). L'upgrade infra est lent (dump/ +// restore/helm) : il 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 { + return err + } + + var active *Demo + for i := range existing { + if existing[i].Status.Active() { + active = &existing[i] + break + } + } + if active == nil { + return nil + } + + active.TypeAbo = "premium" + active.ExpiresAt = s.now().UTC().AddDate(10, 0, 0) // n'expire plus, en pratique + updated, err := s.store.Update(*active) + if err != nil { + return err + } + + go func() { + if err := s.prov.UpgradeToPaid(updated); err != nil { + log.Printf("Transfert payant échoué pour %s: %v", updated.Namespace, err) + } + }() + + return nil +} + // Delete déclenche le teardown et libère le pool. func (s *Service) Delete(id string) (Demo, error) { d, ok := s.store.Get(id) diff --git a/control-plane/api/internal/k8s/client.go b/control-plane/api/internal/k8s/client.go index 7788b3f..fdf2a7a 100644 --- a/control-plane/api/internal/k8s/client.go +++ b/control-plane/api/internal/k8s/client.go @@ -18,6 +18,13 @@ func restConfig(cfg *config.Config) (*rest.Config, error) { return rest.InClusterConfig() } +// NewRESTConfig expose la config REST brute — nécessaire pour les appels qui +// ne passent pas par le Clientset généré (ex: exec dans un pod via +// remotecommand, utilisé pour la migration pg_dump/restore). +func NewRESTConfig(cfg *config.Config) (*rest.Config, error) { + return restConfig(cfg) +} + func NewClient(cfg *config.Config) (*kubernetes.Clientset, error) { k8sCfg, err := restConfig(cfg) if err != nil { diff --git a/control-plane/api/internal/sub/handler.go b/control-plane/api/internal/sub/handler.go index 67b8cd2..1f06bb3 100644 --- a/control-plane/api/internal/sub/handler.go +++ b/control-plane/api/internal/sub/handler.go @@ -2,21 +2,24 @@ package sub import ( "crypto/rand" + "log" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/omnex/control-plane/api/internal/auth" + "github.com/omnex/control-plane/api/internal/demos" ) // Handler gère les requêtes HTTP pour les codes de souscription. type Handler struct { store Store + demos *demos.Service // optionnel : nil => pas de transfert démo automatique } // NewHandler crée un nouveau Handler. -func NewHandler(store Store) *Handler { - return &Handler{store: store} +func NewHandler(store Store, demoSvc *demos.Service) *Handler { + return &Handler{store: store, demos: demoSvc} } // createCodeRequest représente la requête pour créer un code. @@ -122,5 +125,15 @@ func (h *Handler) AddCode(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "code invalide"}) return } + + // Transfert de la démo active (si elle existe) vers la version payante : + // n'expire plus + bascule stockage persistant en tâche de fond. Ne fait + // jamais échouer la validation du code — le client est déjà premium + // même si sa démo (optionnelle) pose problème. + if h.demos != nil { + if err := h.demos.TransferToPaid(p.Username); err != nil { + log.Printf("Transfert démo vers payant échoué pour %s: %v", p.Username, err) + } + } c.JSON(http.StatusOK, gin.H{"success": "vous passez en abonnement premium"}) } diff --git a/deploy/chart-gestion/backend/values.yaml b/deploy/chart-gestion/backend/values.yaml index e568757..a41add3 100644 --- a/deploy/chart-gestion/backend/values.yaml +++ b/deploy/chart-gestion/backend/values.yaml @@ -39,20 +39,34 @@ env: REDIS_HOST: redis REDIS_PORT: "6379" API_PORT: "8080" + STORAGE_DRIVER: local + S3_REGION: us-east-1 + S3_BUCKET: "" + S3_ENDPOINT: "" + TELEGRAM_BOT_USERNAME: "" + LBTELEGRAM_URL: "" + LBTELEGRAM_BOT1_USERNAME: "" + LBTELEGRAM_BOT2_USERNAME: "" # Valeurs sensibles — à surcharger via --set-string ou un Secret Manager externe secrets: DB_PASSWORD: "" SESSION_SECRET: "" USER_JWT_SECRET: "" - USER_JWT_SECRET_OLD: "" ADMIN_JWT_SECRET: "" - ADMIN_JWT_SECRET_OLD: "" REDIS_PASSWORD: "" TOMTOM_API_KEY: "" + TOMTOM_API_KEY_1: "" + TOMTOM_API_KEY_2: "" + TOMTOM_API_KEY_3: "" + TELEGRAM_BOT_TOKEN: "" TELEGRAM_WEBHOOK_URL: "" TELEGRAM_WEBHOOK_SECRET: "" + NOWPAYMENTS_API_KEY: "" NOWPAYMENTS_IPN_SECRET: "" + RUSTFS_ACCESS_KEY: "" + RUSTFS_SECRET_KEY: "" + BACKEND_LINK_SECRET: "" podAnnotations: {} diff --git a/deploy/chart-gestion/lbtelegram/Chart.yaml b/deploy/chart-gestion/lbtelegram/Chart.yaml new file mode 100644 index 0000000..876fca6 --- /dev/null +++ b/deploy/chart-gestion/lbtelegram/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: lbtelegram +description: LoadBalancer telegram +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/deploy/chart-gestion/lbtelegram/templates/_helpers.tpl b/deploy/chart-gestion/lbtelegram/templates/_helpers.tpl new file mode 100644 index 0000000..967e66a --- /dev/null +++ b/deploy/chart-gestion/lbtelegram/templates/_helpers.tpl @@ -0,0 +1,21 @@ +{{- define "lbtelegram.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "lbtelegram.fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "lbtelegram.labels" -}} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }} +app.kubernetes.io/name: {{ include "lbtelegram.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "lbtelegram.selectorLabels" -}} +app.kubernetes.io/name: {{ include "lbtelegram.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/deploy/chart-gestion/lbtelegram/templates/deployment.yaml b/deploy/chart-gestion/lbtelegram/templates/deployment.yaml new file mode 100644 index 0000000..4ec137a --- /dev/null +++ b/deploy/chart-gestion/lbtelegram/templates/deployment.yaml @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "lbtelegram.fullname" . }} + labels: + {{- include "lbtelegram.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "lbtelegram.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "lbtelegram.selectorLabels" . | nindent 8 }} + spec: + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: lbtelegram + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + envFrom: + - secretRef: + name: {{ include "lbtelegram.fullname" . }}-secrets + env: + {{- range $k, $v := .Values.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + livenessProbe: + tcpSocket: + port: http + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + tcpSocket: + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.resources | nindent 12 }} diff --git a/deploy/chart-gestion/lbtelegram/templates/secret.yaml b/deploy/chart-gestion/lbtelegram/templates/secret.yaml new file mode 100644 index 0000000..b4fe00d --- /dev/null +++ b/deploy/chart-gestion/lbtelegram/templates/secret.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "lbtelegram.fullname" . }}-secrets + labels: + {{- include "lbtelegram.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- range $k, $v := .Values.secrets }} + {{ $k }}: {{ $v | quote }} + {{- end }} diff --git a/deploy/chart-gestion/lbtelegram/templates/service.yaml b/deploy/chart-gestion/lbtelegram/templates/service.yaml new file mode 100644 index 0000000..76c7957 --- /dev/null +++ b/deploy/chart-gestion/lbtelegram/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "lbtelegram.fullname" . }} + labels: + {{- include "lbtelegram.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + selector: + {{- include "lbtelegram.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP diff --git a/deploy/chart-gestion/lbtelegram/values.yaml b/deploy/chart-gestion/lbtelegram/values.yaml new file mode 100644 index 0000000..2f07c00 --- /dev/null +++ b/deploy/chart-gestion/lbtelegram/values.yaml @@ -0,0 +1,56 @@ +replicaCount: 1 + +image: + repository: lbtelegram + tag: latest + pullPolicy: IfNotPresent + +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + +env: + PORT: "8081" + ENV: "" + BOT_COUNT: "" + GATEWAY_URL: "" + BOT1_USERNAME: "" + BOT2_USERNAME: "" + JWT_TTL_SECONDS: "" + LB_STRATEGY: "" + HEALTH_CHECK_INTERVAL: "" + BACKEND_LINK_URL: "" + +secrets: + BOT1_TOKEN: "" + BOT2_TOKEN: "" + BOT1_WEBHOOK_SECRET: "" + BOT2_WEBHOOK_SECRET: "" + BACKEND_LINK_SECRET: "" + JWT_SECRET: "" + # DSN complets (identifiants inclus) — à générer par le worker de provisioning + # vers les services postgresql/redis du même namespace de démo. + DATABASE_URL: "" + REDIS_URL: "" + +podAnnotations: {} + +podSecurityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + +service: + type: ClusterIP + port: 8081 diff --git a/docker/.env b/docker/.env index 9313f14..bb5885d 100644 --- a/docker/.env +++ b/docker/.env @@ -17,7 +17,8 @@ REDIS_PASSWORD=630fa654c76f1256fea3ba3c51a718a4 OMNEX_ENV=dev OMNEX_SEED_USERNAME=admin OMNEX_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 -OMNEX_DEMO_DOMAIN=demo.omnex.app +OMNEX_DEMO_DOMAIN=192.168.1.31.nip.io +OMNEX_DEMO_HTTPS_PORT=31908 KUBECONFIG=/home/xor_fakers/.kube/config FRONTEND_IMAGE_APP=xor1234/frontend-mln:helm BACKEND_IMAGE_APP=xor1234/backend-mln:helm diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7311db1..c65c71f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -53,7 +53,8 @@ services: OMNEX_REDIS_URL: "redis://:${REDIS_PASSWORD:-omnexredis}@redis:6379/0" OMNEX_SEED_USERNAME: ${OMNEX_SEED_USERNAME:-admin} OMNEX_SEED_PASSWORD: ${OMNEX_SEED_PASSWORD} - OMNEX_DEMO_DOMAIN: ${OMNEX_DEMO_DOMAIN:-demo.omnex.app} + OMNEX_DEMO_DOMAIN: ${OMNEX_DEMO_DOMAIN:-192.168.1.31.nip.io} + OMNEX_DEMO_HTTPS_PORT: ${OMNEX_DEMO_HTTPS_PORT:-443} FRONTEND_IMAGE_APP: ${FRONTEND_IMAGE_APP:-xor1234/frontend-mln:latest} BACKEND_IMAGE_APP: ${BACKEND_IMAGE_APP:-xor1234/backend-mln:latest} KUBECONFIG: /kubeconfig/config @@ -65,6 +66,12 @@ services: interval: 5s timeout: 3s retries: 10 + # Le temps que le fix "EnsureSharedInfra en arrière-plan" soit déployé + # (voir control-plane/api/cmd/api/main.go) : laisse la marge pour que + # le "helm upgrade --install --wait --timeout 5m" bloquant échoue tout + # seul si le cluster est injoignable, plutôt que de faire échouer + # `docker compose up` avant même que le serveur HTTP démarre. + start_period: 330s ports: - "8080:8080" diff --git a/web/src/components/CreateDemoModal.tsx b/web/src/components/CreateDemoModal.tsx index 1007f2d..3374d13 100644 --- a/web/src/components/CreateDemoModal.tsx +++ b/web/src/components/CreateDemoModal.tsx @@ -1,8 +1,10 @@ import { useState } from 'react' import { + Box, Button, FormControl, FormLabel, + HStack, Input, Modal, ModalBody, @@ -14,6 +16,7 @@ import { Radio, RadioGroup, Stack, + Switch, Text, useToast, } from '@chakra-ui/react' @@ -32,7 +35,14 @@ interface CreateDemoModalProps { export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUsername }: CreateDemoModalProps) { const toast = useToast() const [username, setUsername] = useState(lockedUsername ?? '') + + const [enableTelegram, setEnableTelegram] = useState(false) const [telegramBotToken, setTelegramBotToken] = useState('') + + const [enableNowPayments, setEnableNowPayments] = useState(false) + const [nowPaymentsApiKey, setNowPaymentsApiKey] = useState('') + const [nowPaymentsIpnSecret, setNowPaymentsIpnSecret] = useState('') + const [storageDriver, setStorageDriver] = useState('local') const [s3Bucket, setS3Bucket] = useState('') const [s3Endpoint, setS3Endpoint] = useState('') @@ -40,7 +50,11 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser const reset = () => { setUsername(lockedUsername ?? '') + setEnableTelegram(false) setTelegramBotToken('') + setEnableNowPayments(false) + setNowPaymentsApiKey('') + setNowPaymentsIpnSecret('') setStorageDriver('local') setS3Bucket('') setS3Endpoint('') @@ -65,7 +79,9 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser const params: CreateDemoParams = { username: username.trim(), leadId, - telegramBotToken: telegramBotToken.trim() || undefined, + telegramBotToken: enableTelegram ? telegramBotToken.trim() || undefined : undefined, + nowPaymentsApiKey: enableNowPayments ? nowPaymentsApiKey.trim() || undefined : undefined, + nowPaymentsIpnSecret: enableNowPayments ? nowPaymentsIpnSecret.trim() || undefined : undefined, storageDriver, ...(storageDriver === 's3' ? { s3Bucket: s3Bucket.trim(), s3Endpoint: s3Endpoint.trim() } @@ -104,20 +120,70 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser /> + {/* --- Telegram --- */} - Token bot Telegram - setTelegramBotToken(e.target.value)} - type="password" - autoComplete="off" - /> - - Optionnel — laissez vide si le bot n'est pas encore prêt. - + + Bot Telegram + setEnableTelegram(e.target.checked)} + /> + + {enableTelegram && ( + + + Token bot Telegram + setTelegramBotToken(e.target.value)} + type="password" + autoComplete="off" + /> + + + )} + {/* --- NowPayments --- */} + + + NowPayments (paiement crypto) + setEnableNowPayments(e.target.checked)} + /> + + + {enableNowPayments && ( + + + Clé API NowPayments + setNowPaymentsApiKey(e.target.value)} + type="password" + autoComplete="off" + /> + + + Secret IPN NowPayments + setNowPaymentsIpnSecret(e.target.value)} + type="password" + autoComplete="off" + /> + + Laissez vide pour garder celui pré-généré automatiquement. + + + + )} + + {/* --- Stockage --- */} Stockage des fichiers setStorageDriver(v as StorageDriver)}> @@ -129,17 +195,17 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser {storageDriver === 's3' && ( - - - Nom du bucket + + + Nom du bucket setS3Bucket(e.target.value)} /> - - Endpoint S3 + + Endpoint S3