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

This commit is contained in:
Nuxgrid
2026-08-01 23:33:11 +02:00
parent cb966fcaf0
commit fb10d25ee2
21 changed files with 612 additions and 81 deletions
+9 -3
View File
@@ -104,6 +104,13 @@ func main() {
log.Fatalf("k8s client: %v", err) 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 // 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. // pas le démarrage si metrics-server n'est pas déployé sur le cluster.
metricsClient, err := k8s.NewMetricsClient(&cfg) metricsClient, err := k8s.NewMetricsClient(&cfg)
@@ -112,7 +119,7 @@ func main() {
metricsClient = nil metricsClient = nil
} }
helmProv, err := demos.NewHelmProvisioner(&cfg, k8sClient, metricsClient, "/charts", "helm") helmProv, err := demos.NewHelmProvisioner(&cfg, k8sClient, restConfig, metricsClient, "/charts", "helm")
if err != nil { if err != nil {
log.Fatalf("helm provisioner: %v", err) log.Fatalf("helm provisioner: %v", err)
} }
@@ -136,7 +143,6 @@ func main() {
BaseDomain: cfg.DemoDomain, BaseDomain: cfg.DemoDomain,
HTTPSPort: cfg.DemoHTTPSPort, HTTPSPort: cfg.DemoHTTPSPort,
TTL: demos.TTL, TTL: demos.TTL,
MaxDemos: demos.MaxConcurrentDemos,
}) })
deps := router.Deps{ deps := router.Deps{
@@ -146,7 +152,7 @@ func main() {
AuthH: auth.NewHandler(userStore, sessions, iss, cfg.Secure()), AuthH: auth.NewHandler(userStore, sessions, iss, cfg.Secure()),
LeadsH: leads.NewHandler(leadStore), LeadsH: leads.NewHandler(leadStore),
DemosH: demos.NewHandler(demoSvc, helmProv), DemosH: demos.NewHandler(demoSvc, helmProv),
SubH: sub.NewHandler(codeStore), SubH: sub.NewHandler(codeStore, demoSvc),
ProfileH: profile.NewHandler(profileStore), ProfileH: profile.NewHandler(profileStore),
} }
+4 -1
View File
@@ -15,6 +15,7 @@ require (
k8s.io/api v0.36.3 k8s.io/api v0.36.3
k8s.io/apimachinery v0.36.3 k8s.io/apimachinery v0.36.3
k8s.io/client-go v0.36.3 k8s.io/client-go v0.36.3
k8s.io/metrics v0.36.3
) )
require ( require (
@@ -38,6 +39,7 @@ require (
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/google/gnostic-models v0.7.0 // 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // 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/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
@@ -72,7 +75,7 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.140.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // 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 k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/randfill v1.0.0 // indirect
+8
View File
@@ -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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 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= 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/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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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/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 h1:NDKceAgWS8CJCdDtM5kFACkBOa9Lxia1jUiibJfvUgQ=
k8s.io/metrics v0.36.3/go.mod h1:NTLS8ybwn+zYGwKqYublWPvmnNp8N4pV3etjtx7XWaM= 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 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= 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= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+48 -3
View File
@@ -2,12 +2,15 @@
// dans le cluster (une démo = un namespace isolé, TTL 30 jours). // dans le cluster (une démo = un namespace isolé, TTL 30 jours).
package demos 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 ( const (
TTL = 30 * 24 * time.Hour // durée de vie d'une démo TTL = 30 * 24 * time.Hour // durée de vie d'une démo
MaxConcurrentDemos = 5 // capacité cible
PoolSizePerService = 5 // slots par service externe poolé PoolSizePerService = 5 // slots par service externe poolé
) )
@@ -21,6 +24,48 @@ const (
// PooledServices : services nécessitant un slot de pool par démo. // PooledServices : services nécessitant un slot de pool par démo.
var PooledServices = []string{ServiceTelegram, ServiceNowPayments} 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. // Status : état d'une démo.
type Status string type Status string
+21 -3
View File
@@ -21,6 +21,14 @@ func NewHandler(svc *Service, helm *HelmProvisioner) *Handler {
type createRequest struct { type createRequest struct {
LeadID string `json:"lead_id" binding:"omitempty,uuid4"` LeadID string `json:"lead_id" binding:"omitempty,uuid4"`
Username string `json:"username" binding:"omitempty,min=3,max=64,alphanum"` 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 { type DetailDemoUserRequest struct {
@@ -43,13 +51,23 @@ func (h *Handler) Create(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
return 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 { if err != nil {
switch { switch {
case errors.Is(err, ErrCapacityReached): case errors.Is(err, ErrPoolExhausted):
c.JSON(http.StatusConflict, gin.H{"error": "capacité maximale de démos atteinte"}) 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): case errors.Is(err, ErrUserHasActiveDemo):
c.JSON(http.StatusConflict, gin.H{"error": "ce client a déjà une démo active"}) 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: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
} }
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"os/exec" "os/exec"
@@ -17,6 +18,9 @@ import (
k8sErrors "k8s.io/apimachinery/pkg/api/errors" k8sErrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes" "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" metricsclient "k8s.io/metrics/pkg/client/clientset/versioned"
) )
@@ -25,6 +29,7 @@ import (
type HelmProvisioner struct { type HelmProvisioner struct {
cfg *config.Config cfg *config.Config
k8sClient *kubernetes.Clientset k8sClient *kubernetes.Clientset
restConfig *rest.Config // pour l'exec dans les pods (pg_dump/restore)
metricsClient *metricsclient.Clientset // optionnel : nil si metrics-server indisponible metricsClient *metricsclient.Clientset // optionnel : nil si metrics-server indisponible
chartsDir string // chemin vers le dossier des charts (ex: /charts) chartsDir string // chemin vers le dossier des charts (ex: /charts)
frontendImage string frontendImage string
@@ -40,6 +45,7 @@ type HelmProvisioner struct {
func NewHelmProvisioner( func NewHelmProvisioner(
cfg *config.Config, cfg *config.Config,
k8sClient *kubernetes.Clientset, k8sClient *kubernetes.Clientset,
restConfig *rest.Config,
metricsClient *metricsclient.Clientset, metricsClient *metricsclient.Clientset,
chartsDir string, chartsDir string,
helmPath string, helmPath string,
@@ -59,6 +65,7 @@ func NewHelmProvisioner(
return &HelmProvisioner{ return &HelmProvisioner{
cfg: cfg, cfg: cfg,
k8sClient: k8sClient, k8sClient: k8sClient,
restConfig: restConfig,
metricsClient: metricsClient, metricsClient: metricsClient,
chartsDir: chartsDir, chartsDir: chartsDir,
frontendImage: cfg.FrontendImage, frontendImage: cfg.FrontendImage,
@@ -84,16 +91,18 @@ const (
// 4. Installe le chart backend // 4. Installe le chart backend
// 5. Installe le chart frontend // 5. Installe le chart frontend
// 6. Raccorde la démo au Traefik partagé (WAF + routage HTTPS du sous-domaine) // 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 // Créer le namespace
if err := h.createNamespace(d.Namespace); err != nil { if err := h.createNamespace(d.Namespace); err != nil {
return fmt.Errorf("création namespace %s: %w", d.Namespace, err) return fmt.Errorf("création namespace %s: %w", d.Namespace, err)
} }
// Préparer les valeurs // Préparer les valeurs. Pas de persistence à la création : les démos
postgresValues := h.buildPostgresValues(d) // d'essai sont éphémères (TTL 30j) — voir UpgradeToPaid pour le passage
redisValues := h.buildRedisValues(d) // en stockage persistant lorsqu'un client devient payant.
backendValues := h.buildBackendValues(d, resources) postgresValues := h.buildPostgresValues(d, false)
redisValues := h.buildRedisValues(d, false)
backendValues := h.buildBackendValues(d, resources, cfg)
frontendValues := h.buildFrontendValues(d) frontendValues := h.buildFrontendValues(d)
ingressValues := h.buildIngressRouteValues(d) ingressValues := h.buildIngressRouteValues(d)
@@ -162,6 +171,111 @@ func (h *HelmProvisioner) Teardown(d Demo) error {
return h.deleteNamespace(d.Namespace) 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. // createNamespace crée un namespace Kubernetes.
func (h *HelmProvisioner) createNamespace(name string) error { func (h *HelmProvisioner) createNamespace(name string) error {
ns := &k8sCoreV1.Namespace{ ns := &k8sCoreV1.Namespace{
@@ -283,10 +397,53 @@ func parseImage(image string) (repo string, tag string) {
} }
// buildBackendValues construit les valeurs pour le chart backend. // 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 // Parser l'image backend pour séparer repository et tag
backendRepo, backendTag := parseImage(h.backendImage) 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{}{ values := map[string]interface{}{
"replicaCount": 1, "replicaCount": 1,
"image": map[string]interface{}{ "image": map[string]interface{}{
@@ -303,23 +460,11 @@ func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResourc
}, },
"persistence": map[string]interface{}{ "persistence": map[string]interface{}{
"uploads": map[string]interface{}{ "uploads": map[string]interface{}{
"enabled": false, "enabled": uploadsEnabled,
}, },
}, },
"env": map[string]string{ "env": env,
"DB_HOST": fmt.Sprintf("%s-postgresql-postgresql", d.Namespace), "secrets": secrets,
"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),
} }
return values return values
@@ -375,7 +520,7 @@ func (h *HelmProvisioner) buildSecrets(resources []ExternalResource) map[string]
} }
// buildPostgresValues construit les valeurs pour le chart postgresql. // 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{}{ return map[string]interface{}{
"auth": map[string]interface{}{ "auth": map[string]interface{}{
"password": "demo-postgres-pass", // Mot de passe OBLIGATOIRE (champ correct pour le chart) "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, "port": 5432,
}, },
"persistence": map[string]interface{}{ "persistence": map[string]interface{}{
"enabled": false, "enabled": persistent,
}, },
} }
} }
// buildRedisValues construit les valeurs pour le chart redis. // 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{}{ return map[string]interface{}{
"service": map[string]interface{}{ "service": map[string]interface{}{
"type": "ClusterIP", "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 "password": "demo-redis-pass", // Mot de passe simple pour les démos
}, },
"persistence": map[string]interface{}{ "persistence": map[string]interface{}{
"enabled": false, // Pas de persistence pour les démos "enabled": persistent,
}, },
} }
} }
@@ -7,9 +7,12 @@ import "context"
// contente d'enfiler les intentions. // contente d'enfiler les intentions.
type Provisioner interface { type Provisioner interface {
// Provision demande le déploiement d'une démo (asynchrone). // 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 demande la destruction d'une démo.
Teardown(d Demo) error 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) 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. // Sert au scaffolding et aux tests tant que le worker n'existe pas.
type NoopProvisioner struct{} type NoopProvisioner struct{}
func (NoopProvisioner) Provision(Demo, []ExternalResource) error { return nil } func (NoopProvisioner) Provision(Demo, []ExternalResource, ProvisionConfig) error { return nil }
func (NoopProvisioner) Teardown(Demo) 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) { func (NoopProvisioner) GetResourceState(ctx context.Context, namespace string) (ResourceState, error) {
return ResourceState{}, nil return ResourceState{}, nil
} }
+49 -20
View File
@@ -11,7 +11,6 @@ import (
) )
var ( var (
ErrCapacityReached = errors.New("capacité maximale de démos atteinte")
ErrNotFound = errors.New("démo introuvable") ErrNotFound = errors.New("démo introuvable")
ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel") ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel")
ErrUserHasActiveDemo = errors.New("ce client a déjà une démo active") 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" BaseDomain string // ex. "demo.omnex.app"
HTTPSPort string // port public HTTPS pour l'URL affichée ("443" = omis, sinon ex. NodePort) HTTPSPort string // port public HTTPS pour l'URL affichée ("443" = omis, sinon ex. NodePort)
TTL time.Duration // durée de vie TTL time.Duration // durée de vie
MaxDemos int // capacité
} }
// Service : logique métier des démos (indépendante du transport HTTP). // 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 { if cfg.TTL == 0 {
cfg.TTL = TTL cfg.TTL = TTL
} }
if cfg.MaxDemos == 0 {
cfg.MaxDemos = MaxConcurrentDemos
}
if cfg.BaseDomain == "" { if cfg.BaseDomain == "" {
cfg.BaseDomain = "demo.omnex.app" 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. // 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 // username (optionnel) rattache la démo à un client existant. Un client n'a
// droit qu'à une seule démo active à la fois. // droit qu'à une seule démo active à la fois. cfg (bot Telegram, stockage)
func (s *Service) Create(leadID, username string) (Demo, error) { // 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) normalizedUsername := auth.NormalizeUsername(username)
if normalizedUsername != "" { if normalizedUsername != "" {
existing, err := s.store.ListByUsername(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() id := uuid.NewString()
demo := Demo{ demo := Demo{
ID: id, ID: id,
@@ -95,12 +86,11 @@ func (s *Service) Create(leadID, username string) (Demo, error) {
} }
demo.URL = "https://" + demo.Namespace + "." + s.cfg.BaseDomain + portSuffix 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) resources, err := s.pool.Borrow(id)
if err != nil { if err != nil {
if errors.Is(err, ErrPoolExhausted) {
return Demo{}, ErrCapacityReached
}
return Demo{}, err 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). // Lance le déploiement de manière asynchrone (worker inline via goroutine).
go func() { 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) log.Printf("Provisioning échoué pour %s: %v", created.ID, err)
created.Status = StatusFailed created.Status = StatusFailed
_, _ = s.store.Update(created) _, _ = s.store.Update(created)
@@ -144,6 +134,45 @@ func (s *Service) ListForUser(username string) ([]Demo, error) {
return s.store.ListByUsername(auth.NormalizeUsername(username)) 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. // Delete déclenche le teardown et libère le pool.
func (s *Service) Delete(id string) (Demo, error) { func (s *Service) Delete(id string) (Demo, error) {
d, ok := s.store.Get(id) d, ok := s.store.Get(id)
+7
View File
@@ -18,6 +18,13 @@ func restConfig(cfg *config.Config) (*rest.Config, error) {
return rest.InClusterConfig() 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) { func NewClient(cfg *config.Config) (*kubernetes.Clientset, error) {
k8sCfg, err := restConfig(cfg) k8sCfg, err := restConfig(cfg)
if err != nil { if err != nil {
+15 -2
View File
@@ -2,21 +2,24 @@ package sub
import ( import (
"crypto/rand" "crypto/rand"
"log"
"net/http" "net/http"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/omnex/control-plane/api/internal/auth" "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. // Handler gère les requêtes HTTP pour les codes de souscription.
type Handler struct { type Handler struct {
store Store store Store
demos *demos.Service // optionnel : nil => pas de transfert démo automatique
} }
// NewHandler crée un nouveau Handler. // NewHandler crée un nouveau Handler.
func NewHandler(store Store) *Handler { func NewHandler(store Store, demoSvc *demos.Service) *Handler {
return &Handler{store: store} return &Handler{store: store, demos: demoSvc}
} }
// createCodeRequest représente la requête pour créer un code. // 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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "code invalide"})
return 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"}) c.JSON(http.StatusOK, gin.H{"success": "vous passez en abonnement premium"})
} }
+16 -2
View File
@@ -39,20 +39,34 @@ env:
REDIS_HOST: redis REDIS_HOST: redis
REDIS_PORT: "6379" REDIS_PORT: "6379"
API_PORT: "8080" 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 # Valeurs sensibles — à surcharger via --set-string ou un Secret Manager externe
secrets: secrets:
DB_PASSWORD: "" DB_PASSWORD: ""
SESSION_SECRET: "" SESSION_SECRET: ""
USER_JWT_SECRET: "" USER_JWT_SECRET: ""
USER_JWT_SECRET_OLD: ""
ADMIN_JWT_SECRET: "" ADMIN_JWT_SECRET: ""
ADMIN_JWT_SECRET_OLD: ""
REDIS_PASSWORD: "" REDIS_PASSWORD: ""
TOMTOM_API_KEY: "" TOMTOM_API_KEY: ""
TOMTOM_API_KEY_1: ""
TOMTOM_API_KEY_2: ""
TOMTOM_API_KEY_3: ""
TELEGRAM_BOT_TOKEN: ""
TELEGRAM_WEBHOOK_URL: "" TELEGRAM_WEBHOOK_URL: ""
TELEGRAM_WEBHOOK_SECRET: "" TELEGRAM_WEBHOOK_SECRET: ""
NOWPAYMENTS_API_KEY: ""
NOWPAYMENTS_IPN_SECRET: "" NOWPAYMENTS_IPN_SECRET: ""
RUSTFS_ACCESS_KEY: ""
RUSTFS_SECRET_KEY: ""
BACKEND_LINK_SECRET: ""
podAnnotations: {} podAnnotations: {}
@@ -0,0 +1,6 @@
apiVersion: v2
name: lbtelegram
description: LoadBalancer telegram
type: application
version: 0.1.0
appVersion: "1.0.0"
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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
@@ -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
+2 -1
View File
@@ -17,7 +17,8 @@ REDIS_PASSWORD=630fa654c76f1256fea3ba3c51a718a4
OMNEX_ENV=dev OMNEX_ENV=dev
OMNEX_SEED_USERNAME=admin OMNEX_SEED_USERNAME=admin
OMNEX_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 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 KUBECONFIG=/home/xor_fakers/.kube/config
FRONTEND_IMAGE_APP=xor1234/frontend-mln:helm FRONTEND_IMAGE_APP=xor1234/frontend-mln:helm
BACKEND_IMAGE_APP=xor1234/backend-mln:helm BACKEND_IMAGE_APP=xor1234/backend-mln:helm
+8 -1
View File
@@ -53,7 +53,8 @@ services:
OMNEX_REDIS_URL: "redis://:${REDIS_PASSWORD:-omnexredis}@redis:6379/0" OMNEX_REDIS_URL: "redis://:${REDIS_PASSWORD:-omnexredis}@redis:6379/0"
OMNEX_SEED_USERNAME: ${OMNEX_SEED_USERNAME:-admin} OMNEX_SEED_USERNAME: ${OMNEX_SEED_USERNAME:-admin}
OMNEX_SEED_PASSWORD: ${OMNEX_SEED_PASSWORD} 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} FRONTEND_IMAGE_APP: ${FRONTEND_IMAGE_APP:-xor1234/frontend-mln:latest}
BACKEND_IMAGE_APP: ${BACKEND_IMAGE_APP:-xor1234/backend-mln:latest} BACKEND_IMAGE_APP: ${BACKEND_IMAGE_APP:-xor1234/backend-mln:latest}
KUBECONFIG: /kubeconfig/config KUBECONFIG: /kubeconfig/config
@@ -65,6 +66,12 @@ services:
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 10 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: ports:
- "8080:8080" - "8080:8080"
+74 -8
View File
@@ -1,8 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { import {
Box,
Button, Button,
FormControl, FormControl,
FormLabel, FormLabel,
HStack,
Input, Input,
Modal, Modal,
ModalBody, ModalBody,
@@ -14,6 +16,7 @@ import {
Radio, Radio,
RadioGroup, RadioGroup,
Stack, Stack,
Switch,
Text, Text,
useToast, useToast,
} from '@chakra-ui/react' } from '@chakra-ui/react'
@@ -32,7 +35,14 @@ interface CreateDemoModalProps {
export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUsername }: CreateDemoModalProps) { export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUsername }: CreateDemoModalProps) {
const toast = useToast() const toast = useToast()
const [username, setUsername] = useState(lockedUsername ?? '') const [username, setUsername] = useState(lockedUsername ?? '')
const [enableTelegram, setEnableTelegram] = useState(false)
const [telegramBotToken, setTelegramBotToken] = useState('') const [telegramBotToken, setTelegramBotToken] = useState('')
const [enableNowPayments, setEnableNowPayments] = useState(false)
const [nowPaymentsApiKey, setNowPaymentsApiKey] = useState('')
const [nowPaymentsIpnSecret, setNowPaymentsIpnSecret] = useState('')
const [storageDriver, setStorageDriver] = useState<StorageDriver>('local') const [storageDriver, setStorageDriver] = useState<StorageDriver>('local')
const [s3Bucket, setS3Bucket] = useState('') const [s3Bucket, setS3Bucket] = useState('')
const [s3Endpoint, setS3Endpoint] = useState('') const [s3Endpoint, setS3Endpoint] = useState('')
@@ -40,7 +50,11 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
const reset = () => { const reset = () => {
setUsername(lockedUsername ?? '') setUsername(lockedUsername ?? '')
setEnableTelegram(false)
setTelegramBotToken('') setTelegramBotToken('')
setEnableNowPayments(false)
setNowPaymentsApiKey('')
setNowPaymentsIpnSecret('')
setStorageDriver('local') setStorageDriver('local')
setS3Bucket('') setS3Bucket('')
setS3Endpoint('') setS3Endpoint('')
@@ -65,7 +79,9 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
const params: CreateDemoParams = { const params: CreateDemoParams = {
username: username.trim(), username: username.trim(),
leadId, 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,
...(storageDriver === 's3' ...(storageDriver === 's3'
? { s3Bucket: s3Bucket.trim(), s3Endpoint: s3Endpoint.trim() } ? { s3Bucket: s3Bucket.trim(), s3Endpoint: s3Endpoint.trim() }
@@ -104,8 +120,20 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
/> />
</FormControl> </FormControl>
{/* --- Telegram --- */}
<FormControl isDisabled={submitting}> <FormControl isDisabled={submitting}>
<FormLabel>Token bot Telegram</FormLabel> <HStack justify="space-between">
<FormLabel mb={0}>Bot Telegram</FormLabel>
<Switch
isChecked={enableTelegram}
onChange={(e) => setEnableTelegram(e.target.checked)}
/>
</HStack>
</FormControl>
{enableTelegram && (
<Box pl={3} borderLeftWidth="2px" borderColor="primary.500">
<FormControl isDisabled={submitting}>
<FormLabel fontSize="sm">Token bot Telegram</FormLabel>
<Input <Input
placeholder="123456:ABC-DEF..." placeholder="123456:ABC-DEF..."
value={telegramBotToken} value={telegramBotToken}
@@ -113,11 +141,49 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
type="password" type="password"
autoComplete="off" autoComplete="off"
/> />
</FormControl>
</Box>
)}
{/* --- NowPayments --- */}
<FormControl isDisabled={submitting}>
<HStack justify="space-between">
<FormLabel mb={0}>NowPayments (paiement crypto)</FormLabel>
<Switch
isChecked={enableNowPayments}
onChange={(e) => setEnableNowPayments(e.target.checked)}
/>
</HStack>
</FormControl>
{enableNowPayments && (
<Stack spacing={3} pl={3} borderLeftWidth="2px" borderColor="primary.500">
<FormControl isDisabled={submitting}>
<FormLabel fontSize="sm">Clé API NowPayments</FormLabel>
<Input
placeholder="clé API du compte marchand"
value={nowPaymentsApiKey}
onChange={(e) => setNowPaymentsApiKey(e.target.value)}
type="password"
autoComplete="off"
/>
</FormControl>
<FormControl isDisabled={submitting}>
<FormLabel fontSize="sm">Secret IPN NowPayments</FormLabel>
<Input
placeholder="secret configuré côté NowPayments"
value={nowPaymentsIpnSecret}
onChange={(e) => setNowPaymentsIpnSecret(e.target.value)}
type="password"
autoComplete="off"
/>
<Text fontSize="xs" color="gray.500" mt={1}> <Text fontSize="xs" color="gray.500" mt={1}>
Optionnel laissez vide si le bot n'est pas encore prêt. Laissez vide pour garder celui pré-généré automatiquement.
</Text> </Text>
</FormControl> </FormControl>
</Stack>
)}
{/* --- Stockage --- */}
<FormControl isDisabled={submitting}> <FormControl isDisabled={submitting}>
<FormLabel>Stockage des fichiers</FormLabel> <FormLabel>Stockage des fichiers</FormLabel>
<RadioGroup value={storageDriver} onChange={(v) => setStorageDriver(v as StorageDriver)}> <RadioGroup value={storageDriver} onChange={(v) => setStorageDriver(v as StorageDriver)}>
@@ -129,17 +195,17 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
</FormControl> </FormControl>
{storageDriver === 's3' && ( {storageDriver === 's3' && (
<Stack spacing={4} pl={1} borderLeftWidth="2px" borderColor="primary.500"> <Stack spacing={4} pl={3} borderLeftWidth="2px" borderColor="primary.500">
<FormControl isRequired isDisabled={submitting} pl={3}> <FormControl isRequired isDisabled={submitting}>
<FormLabel>Nom du bucket</FormLabel> <FormLabel fontSize="sm">Nom du bucket</FormLabel>
<Input <Input
placeholder="mon-bucket-demo" placeholder="mon-bucket-demo"
value={s3Bucket} value={s3Bucket}
onChange={(e) => setS3Bucket(e.target.value)} onChange={(e) => setS3Bucket(e.target.value)}
/> />
</FormControl> </FormControl>
<FormControl isRequired isDisabled={submitting} pl={3}> <FormControl isRequired isDisabled={submitting}>
<FormLabel>Endpoint S3</FormLabel> <FormLabel fontSize="sm">Endpoint S3</FormLabel>
<Input <Input
placeholder="https://s3.exemple.com" placeholder="https://s3.exemple.com"
value={s3Endpoint} value={s3Endpoint}
+4
View File
@@ -78,6 +78,8 @@ export interface CreateDemoParams {
username: string username: string
leadId?: string leadId?: string
telegramBotToken?: string telegramBotToken?: string
nowPaymentsApiKey?: string
nowPaymentsIpnSecret?: string
storageDriver: StorageDriver storageDriver: StorageDriver
s3Bucket?: string s3Bucket?: string
s3Endpoint?: string s3Endpoint?: string
@@ -167,6 +169,8 @@ export const api = {
username: params.username, username: params.username,
...(params.leadId ? { lead_id: params.leadId } : {}), ...(params.leadId ? { lead_id: params.leadId } : {}),
...(params.telegramBotToken ? { telegram_bot_token: params.telegramBotToken } : {}), ...(params.telegramBotToken ? { telegram_bot_token: params.telegramBotToken } : {}),
...(params.nowPaymentsApiKey ? { nowpayments_api_key: params.nowPaymentsApiKey } : {}),
...(params.nowPaymentsIpnSecret ? { nowpayments_ipn_secret: params.nowPaymentsIpnSecret } : {}),
storage_driver: params.storageDriver, storage_driver: params.storageDriver,
...(params.storageDriver === 's3' ...(params.storageDriver === 's3'
? { s3_bucket: params.s3Bucket, s3_endpoint: params.s3Endpoint } ? { s3_bucket: params.s3Bucket, s3_endpoint: params.s3Endpoint }