chore: update
This commit is contained in:
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user