package demos import ( "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "log" "os" "os/exec" "path/filepath" "strings" "time" "github.com/omnex/control-plane/api/internal/config" "go.yaml.in/yaml/v2" "golang.org/x/crypto/bcrypt" k8sCoreV1 "k8s.io/api/core/v1" k8sErrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "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" ) // HelmProvisioner : implémente Provisioner en appelant l'exécutable Helm. // Nécessite que helm soit installé dans le container (ex: dans /usr/local/bin/helm). 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 backendImage string lbtelegramImage string baseDomain string helmPath string // chemin vers l'exécutable helm (default: "helm") } // NewHelmProvisioner crée un nouveau provisioner Helm. // chartsDir : chemin absolu vers le dossier contenant les charts (backend/, frontend/) // helmPath : chemin vers l'exécutable helm (optionnel, default: "helm") // metricsClient : client metrics.k8s.io pour l'usage CPU/mémoire live (optionnel, peut être nil). func NewHelmProvisioner( cfg *config.Config, k8sClient *kubernetes.Clientset, restConfig *rest.Config, metricsClient *metricsclient.Clientset, chartsDir string, helmPath string, ) (*HelmProvisioner, error) { if chartsDir == "" { chartsDir = "/charts" } if helmPath == "" { helmPath = "helm" } // Vérifier que helm est disponible if _, err := exec.LookPath(helmPath); err != nil { return nil, fmt.Errorf("helm exécutable non trouvé à %s: %w", helmPath, err) } return &HelmProvisioner{ cfg: cfg, k8sClient: k8sClient, restConfig: restConfig, metricsClient: metricsClient, chartsDir: chartsDir, frontendImage: cfg.FrontendImage, backendImage: cfg.BackendImage, lbtelegramImage: cfg.LBTelegramImage, baseDomain: cfg.DemoDomain, helmPath: helmPath, }, nil } // sharedTraefikNamespace / sharedTraefikRelease : le routeur Traefik + WAF // Coraza est installé UNE SEULE FOIS pour toutes les démos (voir // EnsureSharedInfra), jamais par démo — chaque démo installe seulement le // chart "ingressroute" qui la raccorde à ce Traefik partagé. const ( sharedTraefikNamespace = "traefik" sharedTraefikRelease = "gestion-traefik" ) // Provision déploie une démo avec Helm : // 1. Crée le namespace // 2. Installe PostgreSQL pour la démo // 3. Installe Redis pour la démo // 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, 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) } // PVC dès la création de la démo (pas d'emptyDir) : si le client ne // souscrit pas, le teardown du namespace (TTL ou suppression admin) // supprime le PVC avec le reste. S'il souscrit, TransferToPaid n'a rien // d'autre à faire côté stockage — les données sont déjà en place. postgresValues := h.buildPostgresValues(d) redisValues := h.buildRedisValues(d) // Secret partagé backend <-> lbtelegram (authentifie les appels de // lbtelegram vers backend) — généré une seule fois si le load-balancer // Telegram est activé, vide sinon (le chart lbtelegram n'est pas installé). var backendLinkSecret string if cfg.LBTelegramEnabled() { backendLinkSecret = randomSecret() } backendValues := h.buildBackendValues(d, resources, cfg, backendLinkSecret) frontendValues := h.buildFrontendValues(d) ingressValues := h.buildIngressRouteValues(d) // Installer PostgreSQL (nécessaire pour le backend) if err := h.installChart(d.Namespace, "postgresql", postgresValues); err != nil { h.deleteNamespace(d.Namespace) return fmt.Errorf("déploiement postgresql: %w", err) } // Installer Redis (nécessaire pour le backend) if err := h.installChart(d.Namespace, "redis", redisValues); err != nil { h.deleteNamespace(d.Namespace) return fmt.Errorf("déploiement redis: %w", err) } // Installer le backend if err := h.installChart(d.Namespace, "backend", backendValues); err != nil { h.deleteNamespace(d.Namespace) return fmt.Errorf("déploiement backend: %w", err) } // Installer le frontend if err := h.installChart(d.Namespace, "frontend", frontendValues); err != nil { h.deleteNamespace(d.Namespace) return fmt.Errorf("déploiement frontend: %w", err) } // Raccorder au Traefik partagé : sans ça la démo n'est ni routée ni // protégée par le WAF, même si backend/frontend tournent. if err := h.installChart(d.Namespace, "ingressroute", ingressValues); err != nil { h.deleteNamespace(d.Namespace) return fmt.Errorf("déploiement ingressroute: %w", err) } // Load-balancer Telegram : optionnel, seulement si l'admin a renseigné // au moins un bot. if cfg.LBTelegramEnabled() { lbValues := h.buildLBTelegramValues(d, cfg, backendLinkSecret) if err := h.installChart(d.Namespace, "lbtelegram", lbValues); err != nil { h.deleteNamespace(d.Namespace) return fmt.Errorf("déploiement lbtelegram: %w", err) } } // Attendre que postgres/redis/backend/frontend soient tous Running avant // de créer le compte admin : la table "users" n'existe qu'une fois que // le backend a fait tourner sa migration au démarrage, donc tenter // l'insertion avant que le rollout ne soit confirmé échouerait // ("relation users does not exist"). if err := h.waitForRollout(d.Namespace); err != nil { log.Printf("Warning: rollout check échoué pour %s: %v — compte admin non créé", d.Namespace, err) return nil } if err := h.createGestionAdmin(context.Background(), d.Namespace, cfg.AdminUsername, cfg.AdminPassword); err != nil { log.Printf("Warning: création du compte admin échouée pour %s: %v", d.Namespace, err) } return nil } // EnsureSharedInfra installe (ou met à jour, de façon idempotente) le // Traefik partagé + WAF Coraza. À appeler une fois au démarrage de l'API, // avant toute démo — voir cmd/api/main.go. Contrairement à Provision, ceci // n'est PAS répété par démo. func (h *HelmProvisioner) EnsureSharedInfra() error { if err := h.createNamespace(sharedTraefikNamespace); err != nil { return fmt.Errorf("création namespace %s: %w", sharedTraefikNamespace, err) } // Le subchart officiel traefik/traefik est vendorisé dans le repo // (deploy/chart-gestion/traefik/charts/traefik-*.tgz + Chart.lock) : // pas de "helm dependency update" au runtime, qui échouerait de toute // façon puisque /charts est monté en lecture seule. if err := h.upgradeInstallChart(sharedTraefikNamespace, sharedTraefikRelease, "traefik", nil); err != nil { return fmt.Errorf("déploiement traefik partagé: %w", err) } log.Printf("Traefik partagé (WAF Coraza + rate-limit) prêt dans le namespace %s", sharedTraefikNamespace) return nil } // Teardown détruit une démo en supprimant son namespace (cascading) — les // PVC postgres/redis de la démo partent avec, puisqu'ils sont namespaced. func (h *HelmProvisioner) Teardown(d Demo) error { return h.deleteNamespace(d.Namespace) } // MigrateToPremiumNamespace reconstruit l'intégralité de la démo dans // newNamespace ("premium-") et y migre les données postgres — // Kubernetes ne permet pas de renommer un namespace, il faut donc // recréer toute la stack (postgres/redis/backend/frontend/ingressroute/ // lbtelegram) ailleurs. // // La configuration (bot Telegram, NowPayments, load-balancer...) n'est // jamais persistée en base (voir ProvisionConfig) : elle est relue depuis // les valeurs Helm de l'ancien déploiement ("helm get values"), qui // contiennent exactement ce qui a été installé à l'origine, secrets // inclus — jamais journalisés ni interprétés ici, seulement retransmis // tels quels au nouveau déploiement. Seuls les champs qui référencent // l'ancien namespace (DNS internes, URL du webhook Telegram) sont // recalculés pour le nouveau (voir patchBackendValuesForNamespace / // patchLBTelegramValuesForNamespace). // // L'ancien namespace n'est supprimé qu'une fois le nouveau confirmé // opérationnel (rollout réussi) : en cas d'échec à n'importe quelle étape, // la démo continue de fonctionner sous son ancienne adresse, rien n'est // perdu. No-op si newNamespace == d.Namespace (déjà migrée, ex. // renouvellement d'un client déjà premium). func (h *HelmProvisioner) MigrateToPremiumNamespace(d Demo, newNamespace, newURL string) error { oldNamespace := d.Namespace if newNamespace == oldNamespace { return nil } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() backendValues, err := h.getReleaseValues(oldNamespace, oldNamespace+"-backend") if err != nil { return fmt.Errorf("lecture config backend existante: %w", err) } lbValues, lbEnabled := h.getReleaseValuesOptional(oldNamespace, oldNamespace+"-lbtelegram") if err := h.createNamespace(newNamespace); err != nil { return fmt.Errorf("création namespace %s: %w", newNamespace, err) } newDemo := d newDemo.Namespace = newNamespace newDemo.URL = newURL if err := h.installChart(newNamespace, "postgresql", h.buildPostgresValues(newDemo)); err != nil { h.deleteNamespace(newNamespace) return fmt.Errorf("déploiement postgresql: %w", err) } if err := h.installChart(newNamespace, "redis", h.buildRedisValues(newDemo)); err != nil { h.deleteNamespace(newNamespace) return fmt.Errorf("déploiement redis: %w", err) } // Données postgres : migrées par dump/restore (le nouveau PVC est // vide). Redis (cache/sessions) n'est pas migré, reconstruit // naturellement — même convention que l'historique passage en // stockage persistant. if err := h.migratePostgresData(ctx, oldNamespace, newNamespace); err != nil { h.deleteNamespace(newNamespace) return fmt.Errorf("migration données postgres: %w", err) } var backendLinkSecret string if lbEnabled { backendLinkSecret = asStringMap(backendValues["secrets"])["BACKEND_LINK_SECRET"] if backendLinkSecret == "" { backendLinkSecret = randomSecret() } } backendPatched := h.patchBackendValuesForNamespace(backendValues, newDemo, lbEnabled, backendLinkSecret) if err := h.installChart(newNamespace, "backend", backendPatched); err != nil { h.deleteNamespace(newNamespace) return fmt.Errorf("déploiement backend: %w", err) } if err := h.installChart(newNamespace, "frontend", h.buildFrontendValues(newDemo)); err != nil { h.deleteNamespace(newNamespace) return fmt.Errorf("déploiement frontend: %w", err) } if err := h.installChart(newNamespace, "ingressroute", h.buildIngressRouteValues(newDemo)); err != nil { h.deleteNamespace(newNamespace) return fmt.Errorf("déploiement ingressroute: %w", err) } if lbEnabled { lbPatched := h.patchLBTelegramValuesForNamespace(lbValues, newDemo, backendLinkSecret) if err := h.installChart(newNamespace, "lbtelegram", lbPatched); err != nil { h.deleteNamespace(newNamespace) return fmt.Errorf("déploiement lbtelegram: %w", err) } } if err := h.waitForRollout(newNamespace); err != nil { // Le nouveau namespace n'est PAS supprimé : les données y sont déjà // migrées, un rollout en échec est réparable manuellement sans tout // reperdre. L'ancien namespace reste lui aussi en place tant que le // nouveau n'est pas confirmé opérationnel. return fmt.Errorf("rollout du nouveau namespace %s: %w", newNamespace, err) } if err := h.deleteNamespace(oldNamespace); err != nil { log.Printf("Warning: suppression ancien namespace %s échouée (nouveau namespace %s opérationnel): %v", oldNamespace, newNamespace, err) } log.Printf("Démo %s migrée vers le namespace premium %s", oldNamespace, newNamespace) return nil } // UpdateDomain change le domaine public d'une démo sans changer de // namespace ni migrer de données : réinstalle l'IngressRoute avec le // nouveau host (Traefik route sur le nom d'hôte), et si un bot Telegram // et/ou le load-balancer lbtelegram sont configurés, met à jour leurs // valeurs dépendantes du domaine (URL de webhook, GATEWAY_URL) puis force // un redémarrage des pods concernés — un Secret modifié seul ne déclenche // pas de rollout automatique côté Kubernetes. // // newURL : URL complète ("https://hôte[:port]") telle que calculée par // Service.SetDomain. Best-effort sur backend/lbtelegram : une démo encore // en cours de provisioning (releases pas encore installées) n'a que son // IngressRoute mis à jour, sans erreur bloquante. func (h *HelmProvisioner) UpdateDomain(d Demo, newURL string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() host := strings.TrimPrefix(strings.TrimPrefix(newURL, "https://"), "http://") if i := strings.IndexByte(host, '/'); i >= 0 { host = host[:i] } if i := strings.IndexByte(host, ':'); i >= 0 { host = host[:i] // Host() de Traefik ne matche pas le port } ingressValues := map[string]interface{}{ "host": host, "traefikNamespace": sharedTraefikNamespace, } if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-ingressroute", "ingressroute", ingressValues); err != nil { return fmt.Errorf("mise à jour ingressroute: %w", err) } if backendValues, err := h.getReleaseValues(d.Namespace, d.Namespace+"-backend"); err == nil { secrets := asStringMap(backendValues["secrets"]) if secrets["TELEGRAM_BOT_TOKEN"] != "" { secrets["TELEGRAM_WEBHOOK_URL"] = newURL + "/webhook/telegram" backendValues["secrets"] = secrets if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-backend", "backend", backendValues); err != nil { log.Printf("Warning: mise à jour webhook Telegram (backend) échouée pour %s: %v", d.Namespace, err) } else if err := h.restartDeployment(ctx, d.Namespace, d.Namespace+"-backend-gestion-backend"); err != nil { log.Printf("Warning: redémarrage backend échoué pour %s: %v", d.Namespace, err) } } } if lbValues, ok := h.getReleaseValuesOptional(d.Namespace, d.Namespace+"-lbtelegram"); ok { env := asStringMap(lbValues["env"]) env["GATEWAY_URL"] = "https://" + host lbValues["env"] = env lbValues["host"] = host if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-lbtelegram", "lbtelegram", lbValues); err != nil { log.Printf("Warning: mise à jour host lbtelegram échouée pour %s: %v", d.Namespace, err) } else if err := h.restartDeployment(ctx, d.Namespace, d.Namespace+"-lbtelegram-lbtelegram"); err != nil { log.Printf("Warning: redémarrage lbtelegram échoué pour %s: %v", d.Namespace, err) } } log.Printf("Domaine de %s mis à jour: %s", d.Namespace, host) return nil } // restartDeployment force un nouveau rollout (annotation datée sur le pod // template) pour qu'un Deployment reprenne en compte un Secret modifié sans // changer d'image ni de spec structurel — Kubernetes ne redémarre pas les // pods automatiquement dans ce cas. func (h *HelmProvisioner) restartDeployment(ctx context.Context, namespace, name string) error { patch := []byte(fmt.Sprintf( `{"spec":{"template":{"metadata":{"annotations":{"omnex.app/restartedAt":%q}}}}}`, time.Now().UTC().Format(time.RFC3339), )) _, err := h.k8sClient.AppsV1().Deployments(namespace).Patch(ctx, name, types.StrategicMergePatchType, patch, metav1.PatchOptions{}) return err } // migratePostgresData copie les données postgres d'un namespace à l'autre // (pg_dump / psql restore), utilisé par MigrateToPremiumNamespace. func (h *HelmProvisioner) migratePostgresData(ctx context.Context, oldNamespace, newNamespace string) error { oldPod, err := h.findPod(ctx, oldNamespace, "postgresql") if err != nil { return fmt.Errorf("pod postgresql source introuvable: %w", err) } dumpCmd := fmt.Sprintf("PGPASSWORD=%s pg_dump -h localhost -U %s %s", demoDBPass, demoDBUser, demoDBName) dump, stderr, err := h.execInPod(ctx, oldNamespace, oldPod, "postgresql", []string{"sh", "-c", dumpCmd}, nil) if err != nil { return fmt.Errorf("pg_dump: %w (%s)", err, stderr) } if strings.TrimSpace(dump) == "" { return nil } newPod, err := h.findPod(ctx, newNamespace, "postgresql") if err != nil { return fmt.Errorf("pod postgresql cible introuvable: %w", err) } restoreCmd := fmt.Sprintf("PGPASSWORD=%s psql -h localhost -U %s %s", demoDBPass, demoDBUser, demoDBName) if _, stderr, err := h.execInPod(ctx, newNamespace, newPod, "postgresql", []string{"sh", "-c", restoreCmd}, strings.NewReader(dump)); err != nil { return fmt.Errorf("restore pg_dump: %w (%s)", err, stderr) } return nil } // patchBackendValuesForNamespace réutilise les valeurs Helm existantes du // backend (image, secrets, réglages métier...) en ne recalculant que ce qui // référence le nom du namespace : DNS internes postgres/redis/lbtelegram et // URL du webhook Telegram (dépend de l'URL publique de la démo). func (h *HelmProvisioner) patchBackendValuesForNamespace(values map[string]interface{}, newDemo Demo, lbEnabled bool, backendLinkSecret string) map[string]interface{} { env := asStringMap(values["env"]) secrets := asStringMap(values["secrets"]) env["DB_HOST"] = fmt.Sprintf("%s-postgresql-postgresql", newDemo.Namespace) env["REDIS_HOST"] = fmt.Sprintf("%s-redis-redis", newDemo.Namespace) if lbEnabled { env["LBTELEGRAM_URL"] = fmt.Sprintf("http://%s-lbtelegram-lbtelegram.%s.svc.cluster.local:8081", newDemo.Namespace, newDemo.Namespace) secrets["BACKEND_LINK_SECRET"] = backendLinkSecret } if secrets["TELEGRAM_BOT_TOKEN"] != "" { secrets["TELEGRAM_WEBHOOK_URL"] = newDemo.URL + "/webhook/telegram" } values["env"] = env values["secrets"] = secrets return values } // patchLBTelegramValuesForNamespace réutilise les valeurs Helm existantes // du chart lbtelegram (bots, stratégie, tokens...) en ne recalculant que ce // qui référence le nom du namespace : host public, GATEWAY_URL, DSN // postgres/redis et URL interne du backend. func (h *HelmProvisioner) patchLBTelegramValuesForNamespace(values map[string]interface{}, newDemo Demo, backendLinkSecret string) map[string]interface{} { env := asStringMap(values["env"]) secrets := asStringMap(values["secrets"]) host := fmt.Sprintf("%s.%s", newDemo.Namespace, h.baseDomain) env["GATEWAY_URL"] = "https://" + host env["BACKEND_LINK_URL"] = fmt.Sprintf("http://%s-backend-gestion-backend.%s.svc.cluster.local:8080", newDemo.Namespace, newDemo.Namespace) secrets["BACKEND_LINK_SECRET"] = backendLinkSecret secrets["DATABASE_URL"] = fmt.Sprintf("postgres://postgres:demo-postgres-pass@%s-postgresql-postgresql:5432/demo_db?sslmode=disable", newDemo.Namespace) secrets["REDIS_URL"] = fmt.Sprintf("redis://:demo-redis-pass@%s-redis-redis:6379/0", newDemo.Namespace) values["host"] = host values["env"] = env values["secrets"] = secrets return values } // getReleaseValues récupère les valeurs Helm exactes utilisées par une // release déjà installée ("helm get values -o json"). func (h *HelmProvisioner) getReleaseValues(namespace, release string) (map[string]interface{}, error) { out, err := h.runHelmOutput("get", "values", release, "--namespace", namespace, "-o", "json") if err != nil { return nil, err } var values map[string]interface{} if err := json.Unmarshal(out, &values); err != nil { return nil, fmt.Errorf("parse valeurs helm de %s: %w", release, err) } return values, nil } // getReleaseValuesOptional : comme getReleaseValues, mais renvoie // simplement (nil, false) si la release n'existe pas (ex. lbtelegram non // activé pour cette démo) plutôt qu'une erreur. func (h *HelmProvisioner) getReleaseValuesOptional(namespace, release string) (map[string]interface{}, bool) { values, err := h.getReleaseValues(namespace, release) if err != nil { return nil, false } return values, true } // asStringMap convertit une valeur JSON décodée (map[string]interface{}) // en map[string]string, en ignorant silencieusement les clés dont la // valeur n'est pas une chaîne. func asStringMap(v interface{}) map[string]string { out := map[string]string{} m, ok := v.(map[string]interface{}) if !ok { return out } for k, val := range m { if s, ok := val.(string); ok { out[k] = s } } return out } // createGestionAdmin crée le compte admin de l'application "gestion" // déployée dans cette démo, en insérant directement dans la table "users" // (aucune route API ne le permet : CreateUser refuse explicitement de créer // un compte admin, par design de l'app). Mot de passe haché en bcrypt // (golang.org/x/crypto/bcrypt), comme le fait l'app elle-même // (bcrypt.CompareHashAndPassword côté LoginAdmin). func (h *HelmProvisioner) createGestionAdmin(ctx context.Context, namespace, username, password string) error { if username == "" || password == "" { return fmt.Errorf("username/password admin manquants") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return fmt.Errorf("hash bcrypt: %w", err) } pgPod, err := h.findPod(ctx, namespace, "postgresql") if err != nil { return fmt.Errorf("pod postgresql introuvable: %w", err) } // Échappement basique des quotes simples : le username est déjà validé // alphanumérique côté handler HTTP, le hash bcrypt ne contient jamais de // guillemet (alphabet base64 restreint ./A-Za-z0-9). escapedUsername := strings.ReplaceAll(username, "'", "''") sql := fmt.Sprintf( `INSERT INTO users (username, password, role) VALUES ('%s', '%s', 'admin') ON CONFLICT (username) DO NOTHING;`, escapedUsername, string(hash), ) shellCmd := fmt.Sprintf("PGPASSWORD=%s psql -h localhost -U %s -d %s -v ON_ERROR_STOP=1 -c %s", demoDBPass, demoDBUser, demoDBName, shellQuote(sql)) _, stderr, err := h.execInPod(ctx, namespace, pgPod, "postgresql", []string{"sh", "-c", shellCmd}, nil) if err != nil { return fmt.Errorf("insertion compte admin: %w (%s)", err, stderr) } log.Printf("Compte admin %q créé pour la démo %s", username, namespace) return nil } // shellQuote entoure une chaîne de guillemets simples pour un usage sûr dans // une commande sh -c (échappe les guillemets simples qu'elle contient). func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } // Identifiants postgres des démos — mêmes valeurs codées en dur que // buildPostgresValues/buildBackendValues (voir ces fonctions). const ( demoDBUser = "postgres" demoDBName = "demo_db" demoDBPass = "demo-postgres-pass" ) // 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{ ObjectMeta: metav1.ObjectMeta{ Name: name, Labels: map[string]string{ "omnex.app/demo": "true", }, }, } _, err := h.k8sClient.CoreV1().Namespaces().Create(context.Background(), ns, metav1.CreateOptions{}) if err != nil && !k8sErrors.IsAlreadyExists(err) { return err } return nil } // deleteNamespace supprime un namespace (avec cascading). func (h *HelmProvisioner) deleteNamespace(name string) error { propagationPolicy := metav1.DeletePropagationForeground return h.k8sClient.CoreV1().Namespaces().Delete(context.Background(), name, metav1.DeleteOptions{ PropagationPolicy: &propagationPolicy, }) } // installChart installe (première install, jamais réappliqué) un chart Helm // avec des valeurs personnalisées. Release name : "-". // Adapté aux charts par démo (namespace toujours neuf). func (h *HelmProvisioner) installChart(namespace, chartName string, values map[string]interface{}) error { release := fmt.Sprintf("%s-%s", namespace, chartName) valuesFile, cleanup, err := h.writeValuesFile(namespace, chartName, values) if err != nil { return err } defer cleanup() args := []string{ "install", release, filepath.Join(h.chartsDir, chartName), "--namespace", namespace, "--values", valuesFile, "--wait", "--timeout", "5m", } if err := h.runHelm(args...); err != nil { return fmt.Errorf("helm install %s: %w", chartName, err) } log.Printf("Chart %s installé dans %s", chartName, namespace) return nil } // upgradeInstallChart installe ou met à jour un chart de façon idempotente // (helm upgrade --install) — pour l'infra partagée (Traefik), appelée à // chaque démarrage de l'API et qui ne doit pas échouer si déjà en place. func (h *HelmProvisioner) upgradeInstallChart(namespace, release, chartName string, values map[string]interface{}) error { args := []string{ "upgrade", release, filepath.Join(h.chartsDir, chartName), "--install", "--namespace", namespace, "--wait", "--timeout", "5m", } if values != nil { valuesFile, cleanup, err := h.writeValuesFile(namespace, chartName, values) if err != nil { return err } defer cleanup() args = append(args, "--values", valuesFile) } if err := h.runHelm(args...); err != nil { return fmt.Errorf("helm upgrade --install %s: %w", chartName, err) } log.Printf("Chart %s (release %s) à jour dans %s", chartName, release, namespace) return nil } // writeValuesFile sérialise des valeurs Helm dans un fichier temporaire et // retourne son chemin + une fonction de nettoyage à appeler en defer. func (h *HelmProvisioner) writeValuesFile(namespace, chartName string, values map[string]interface{}) (string, func(), error) { valuesFile := filepath.Join("/tmp", fmt.Sprintf("values-%s-%s.yaml", namespace, chartName)) valuesYAML, err := yaml.Marshal(values) if err != nil { return "", nil, fmt.Errorf("marshal YAML: %w", err) } if err := os.WriteFile(valuesFile, valuesYAML, 0644); err != nil { return "", nil, fmt.Errorf("write values file: %w", err) } return valuesFile, func() { os.Remove(valuesFile) }, nil } // runHelm exécute une commande helm et remonte stdout/stderr en cas d'erreur. func (h *HelmProvisioner) runHelm(args ...string) error { _, err := h.runHelmOutput(args...) return err } // runHelmOutput exécute une commande helm et retourne son stdout (ex. "helm // get values -o json"). func (h *HelmProvisioner) runHelmOutput(args ...string) ([]byte, error) { cmd := exec.Command(h.helmPath, args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("%v (stdout: %s, stderr: %s)", err, stdout.String(), stderr.String()) } return stdout.Bytes(), nil } func parseImage(image string) (repo string, tag string) { if image == "" { return "", "helm" } // Chercher le dernier ":" (pour gérer les images avec port dans le repo) idx := strings.LastIndex(image, ":") if idx == -1 { return image, "helm" } return image[:idx], image[idx+1:] } // buildBackendValues construit les valeurs pour le chart backend. // backendLinkSecret : partagé avec le chart lbtelegram (voir // buildLBTelegramValues) — vide si le load-balancer Telegram n'est pas activé. func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource, cfg ProvisionConfig, backendLinkSecret string) map[string]interface{} { // Parser l'image backend pour séparer repository et tag backendRepo, backendTag := parseImage(h.backendImage) 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 } if cfg.TelegramBotUsername != "" { env["TELEGRAM_BOT_USERNAME"] = cfg.TelegramBotUsername } secrets := h.buildSecrets(resources) if cfg.TelegramBotToken != "" { secrets["TELEGRAM_BOT_TOKEN"] = cfg.TelegramBotToken // URL publique du webhook Telegram de cette démo (route backend : // POST /webhook/telegram, sans préfixe /api/v1). TELEGRAM_WEBHOOK_SECRET // reste celui généré par le pool (h.buildSecrets ci-dessus). secrets["TELEGRAM_WEBHOOK_URL"] = d.URL + "/webhook/telegram" } if cfg.NowPaymentsAPIKey != "" { secrets["NOWPAYMENTS_API_KEY"] = cfg.NowPaymentsAPIKey } 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 } if cfg.LBTelegramEnabled() { env["LBTELEGRAM_URL"] = fmt.Sprintf("http://%s-lbtelegram-lbtelegram.%s.svc.cluster.local:8081", d.Namespace, d.Namespace) env["LBTELEGRAM_BOT1_USERNAME"] = cfg.LBBot1Username env["LBTELEGRAM_BOT2_USERNAME"] = cfg.LBBot2Username secrets["BACKEND_LINK_SECRET"] = backendLinkSecret } values := map[string]interface{}{ "replicaCount": 1, "image": map[string]interface{}{ "repository": backendRepo, "tag": backendTag, "pullPolicy": "Always", }, "service": map[string]interface{}{ "type": "ClusterIP", "port": 8080, }, "autoscaling": map[string]interface{}{ "enabled": false, }, "persistence": map[string]interface{}{ "uploads": map[string]interface{}{ "enabled": uploadsEnabled, }, }, "env": env, "secrets": secrets, } return values } // buildFrontendValues construit les valeurs pour le chart frontend. func (h *HelmProvisioner) buildFrontendValues(d Demo) map[string]interface{} { // Nom réel du Service backend : "-backend" (release helm) + // "-gestion-backend" (nom du chart, ajouté par le helper fullname). backendURL := fmt.Sprintf("http://%s-backend-gestion-backend.%s.svc.cluster.local:8080", d.Namespace, d.Namespace) // Parser l'image frontend pour séparer repository et tag frontendRepo, frontendTag := parseImage(h.frontendImage) return map[string]interface{}{ "replicaCount": 1, "image": map[string]interface{}{ "repository": frontendRepo, "tag": frontendTag, "pullPolicy": "Always", }, "apiUrl": backendURL, "service": map[string]interface{}{ "type": "ClusterIP", "port": 80, }, "autoscaling": map[string]interface{}{ "enabled": false, }, } } // buildIngressRouteValues construit les valeurs pour le chart ingressroute, // qui raccorde cette démo au Traefik partagé (WAF + routage HTTPS). func (h *HelmProvisioner) buildIngressRouteValues(d Demo) map[string]interface{} { return map[string]interface{}{ "host": fmt.Sprintf("%s.%s", d.Namespace, h.baseDomain), "traefikNamespace": sharedTraefikNamespace, } } // buildLBTelegramValues construit les valeurs pour le chart lbtelegram // (load-balancer multi-bots Telegram). N'est appelé que si // cfg.LBTelegramEnabled() — au moins un bot renseigné par l'admin. Les // identifiants des bots (username/token) viennent de l'admin ; le reste // (DSN postgres/redis, secrets JWT/webhook/backend-link) est généré ici, // comme indiqué par le commentaire du chart (deploy/chart-gestion/lbtelegram/values.yaml). func (h *HelmProvisioner) buildLBTelegramValues(d Demo, cfg ProvisionConfig, backendLinkSecret string) map[string]interface{} { botCount := 0 if cfg.LBBot1Username != "" { botCount++ } if cfg.LBBot2Username != "" { botCount++ } backendURL := fmt.Sprintf("http://%s-backend-gestion-backend.%s.svc.cluster.local:8080", d.Namespace, d.Namespace) dbURL := fmt.Sprintf("postgres://postgres:demo-postgres-pass@%s-postgresql-postgresql:5432/demo_db?sslmode=disable", d.Namespace) redisURL := fmt.Sprintf("redis://:demo-redis-pass@%s-redis-redis:6379/0", d.Namespace) lbRepo, lbTag := parseImage(h.lbtelegramImage) host := fmt.Sprintf("%s.%s", d.Namespace, h.baseDomain) env := map[string]string{ "PORT": "8081", "ENV": "production", // URL publique HTTPS de cette démo : requise par lbtelegram (panic // sinon), utilisée pour enregistrer les webhooks Telegram de chaque // bot (GATEWAY_URL + "/webhook/" + botID) — voir // deploy/chart-gestion/lbtelegram/templates/ingressroute.yaml pour // le routage public correspondant. "GATEWAY_URL": "https://" + host, "BOT_COUNT": fmt.Sprintf("%d", botCount), "BOT1_USERNAME": cfg.LBBot1Username, "BOT2_USERNAME": cfg.LBBot2Username, "BACKEND_LINK_URL": backendURL, } if cfg.LBStrategy != "" { env["LB_STRATEGY"] = cfg.LBStrategy } if cfg.LBJWTTTLSeconds != "" { env["JWT_TTL_SECONDS"] = cfg.LBJWTTTLSeconds } if cfg.LBHealthCheckInterval != "" { env["HEALTH_CHECK_INTERVAL"] = cfg.LBHealthCheckInterval } return map[string]interface{}{ "image": map[string]interface{}{ "repository": lbRepo, "tag": lbTag, "pullPolicy": "Always", }, "host": host, "traefikNamespace": sharedTraefikNamespace, "env": env, "secrets": map[string]string{ "BOT1_TOKEN": cfg.LBBot1Token, "BOT2_TOKEN": cfg.LBBot2Token, "BOT1_WEBHOOK_SECRET": randomSecret(), "BOT2_WEBHOOK_SECRET": randomSecret(), "BACKEND_LINK_SECRET": backendLinkSecret, "JWT_SECRET": randomSecret(), "DATABASE_URL": dbURL, "REDIS_URL": redisURL, }, } } // randomSecret génère un secret hexadécimal aléatoire de 32 octets (256 bits). func randomSecret() string { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { // crypto/rand.Read ne devrait jamais échouer sur un système normal ; // fail-secure plutôt que de retourner un secret prévisible. panic(fmt.Sprintf("randomSecret: %v", err)) } return hex.EncodeToString(b) } // buildSecrets extrait les références des secrets des ressources externes. func (h *HelmProvisioner) buildSecrets(resources []ExternalResource) map[string]string { secrets := map[string]string{} for _, r := range resources { switch r.Service { case ServiceTelegram: secrets["TELEGRAM_WEBHOOK_SECRET"] = r.SecretRef case ServiceNowPayments: secrets["NOWPAYMENTS_IPN_SECRET"] = r.SecretRef } } return secrets } // buildPostgresValues construit les valeurs pour le chart postgresql. // Toujours persistant (PVC) : voir Provision. func (h *HelmProvisioner) buildPostgresValues(d Demo) map[string]interface{} { return map[string]interface{}{ "auth": map[string]interface{}{ "password": "demo-postgres-pass", // Mot de passe OBLIGATOIRE (champ correct pour le chart) "username": "postgres", "database": "demo_db", }, "service": map[string]interface{}{ "type": "ClusterIP", "port": 5432, }, "persistence": map[string]interface{}{ "enabled": true, }, } } // buildRedisValues construit les valeurs pour le chart redis. // Toujours persistant (PVC) : voir Provision. func (h *HelmProvisioner) buildRedisValues(d Demo) map[string]interface{} { return map[string]interface{}{ "service": map[string]interface{}{ "type": "ClusterIP", "port": 6379, }, "auth": map[string]interface{}{ "password": "demo-redis-pass", // Mot de passe simple pour les démos }, "persistence": map[string]interface{}{ "enabled": true, }, } } // waitForRollout attend que tous les déploiements dans le namespace soient prêts. func (h *HelmProvisioner) waitForRollout(namespace string) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() for { select { case <-ctx.Done(): return ctx.Err() case <-time.After(5 * time.Second): // Utiliser kubectl pour vérifier le rollout cmd := exec.Command("kubectl", "rollout", "status", "deployment", "-n", namespace, "--timeout=30s") var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { // Si c'est juste un timeout, on continue d'attendre if strings.Contains(stderr.String(), "timed out") { continue } return fmt.Errorf("rollout check: %v: %s", err, stderr.String()) } // Si la commande réussit, tous les déploiements sont prêts return nil } } } // componentResourceLimits : limites CPU/mémoire configurées dans chaque chart // (deploy/chart-gestion/*/values.yaml), utilisées pour calculer un % d'usage. var componentResourceLimits = map[string]struct { cpuMilli int64 memMi int64 }{ "backend": {cpuMilli: 500, memMi: 256}, "frontend": {cpuMilli: 200, memMi: 128}, "postgresql": {cpuMilli: 1000, memMi: 1024}, "redis": {cpuMilli: 500, memMi: 512}, } // componentKey identifie le composant (backend/frontend/postgresql/redis) à // partir du nom de pod généré par Helm (ex: "demo-xxx-backend-...-6d59f4-abcde"). func componentKey(podName string) string { switch { case strings.Contains(podName, "backend"): return "backend" case strings.Contains(podName, "frontend"): return "frontend" case strings.Contains(podName, "postgresql"): return "postgresql" case strings.Contains(podName, "redis"): return "redis" default: return "" } } // GetResourceState : phase + usage CPU/mémoire live de chaque pod d'une démo. // L'usage (metrics-server) est best-effort : s'il est indisponible, seule la // phase du pod est renseignée plutôt que de faire échouer tout l'appel. func (h *HelmProvisioner) GetResourceState( ctx context.Context, namespace string, ) (ResourceState, error) { pods, err := h.k8sClient. CoreV1(). Pods(namespace). List(ctx, metav1.ListOptions{}) if err != nil { return ResourceState{}, err } usage := map[string][2]int64{} // pod name -> [cpuMilli, memMi] if h.metricsClient != nil { if list, err := h.metricsClient.MetricsV1beta1().PodMetricses(namespace).List(ctx, metav1.ListOptions{}); err == nil { for _, m := range list.Items { var cpu, mem int64 for _, c := range m.Containers { cpu += c.Usage.Cpu().MilliValue() mem += c.Usage.Memory().Value() / (1024 * 1024) } usage[m.Name] = [2]int64{cpu, mem} } } } var state ResourceState for _, pod := range pods.Items { key := componentKey(pod.Name) if key == "" { continue } cs := ComponentState{Phase: string(pod.Status.Phase)} if limits, ok := componentResourceLimits[key]; ok { cs.CPULimitMilli = limits.cpuMilli cs.MemoryLimitMi = limits.memMi } if u, ok := usage[pod.Name]; ok { cs.CPUMilli = u[0] cs.MemoryMi = u[1] } switch key { case "backend": state.API = cs case "frontend": state.Web = cs case "postgresql": state.DB = cs case "redis": state.DBM = cs } } return state, nil }