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" rbacv1 "k8s.io/api/rbac/v1" k8sErrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic" "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 dynamicClient dynamic.Interface // pour les CRD Velero (Schedule/Backup), voir ensureDemoBackupSchedule 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) } dynamicClient, err := dynamic.NewForConfig(restConfig) if err != nil { return nil, fmt.Errorf("client dynamique (CRD Velero): %w", err) } return &HelmProvisioner{ cfg: cfg, k8sClient: k8sClient, restConfig: restConfig, metricsClient: metricsClient, dynamicClient: dynamicClient, 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" sharedRegistryCredentialsRelease = "gestion-registry-credentials" ) // 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) } // Sauvegardes (voir ensureDemoBackupSchedule) : non-fatal, une démo doit // pouvoir fonctionner même si Velero est momentanément indisponible. if err := h.ensureDemoBackupSchedule(d.Namespace); err != nil { log.Printf("Warning: planning de sauvegarde échoué pour %s: %v", d.Namespace, err) } // Isolation réseau AVANT tout workload : deny-by-default, aucune // communication inter-démos, ingress limité au Traefik partagé (voir // deploy/chart-gestion/network-policy). Sans ça, tout pod d'une démo // peut atteindre le réseau de n'importe quelle autre démo. if err := h.installChart(d.Namespace, "network-policy", h.buildNetworkPolicyValues()); err != nil { h.deleteNamespace(d.Namespace) return fmt.Errorf("déploiement network-policy: %w", err) } // Plafond agrégé de ressources (voir deploy/chart-gestion/resource-quota) : // sans ça, l'autoscaling (HPA) du backend/frontend d'une démo peut monter // en charge sans limite globale et affamer les autres démos du même // cluster — les limites par pod ne bornent que l'individuel, pas l'agrégat. if err := h.installChart(d.Namespace, "resource-quota", nil); err != nil { h.deleteNamespace(d.Namespace) return fmt.Errorf("déploiement resource-quota: %w", err) } // Identifiants Docker Hub (voir deploy/chart-gestion/registry-credentials) : // AVANT tout chart qui pull une image, sinon les pods restent en // ImagePullBackOff si le rate-limit anonyme est déjà atteint sur ce nœud. // No-op si aucun compte n'est configuré (imagePullSecretsValue == nil). if err := h.installChart(d.Namespace, "registry-credentials", h.buildRegistryCredentialsValues()); err != nil { h.deleteNamespace(d.Namespace) return fmt.Errorf("déploiement registry-credentials: %w", err) } // Mot de passe postgres/redis généré par démo (jamais réutilisé d'une // démo à l'autre) : avec des identifiants partagés, un gap réseau // (CNI, règle manquante) donnerait un accès direct aux données de // n'importe quelle autre démo. Le NetworkPolicy ci-dessus est la // première ligne de défense, ce mot de passe unique la seconde. pgPassword := randomSecret() redisPassword := randomSecret() // 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, pgPassword) redisValues := h.buildRedisValues(d, redisPassword) // 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, pgPassword, redisPassword) 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, pgPassword, redisPassword) 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, pgPassword); 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) } // Identifiants Docker Hub (voir deploy/chart-gestion/registry-credentials) : // AVANT traefik, qui pull son image + le plugin WASM Coraza. No-op si // aucun compte n'est configuré. if err := h.upgradeInstallChart(sharedTraefikNamespace, sharedRegistryCredentialsRelease, "registry-credentials", h.buildRegistryCredentialsValues()); err != nil { return fmt.Errorf("déploiement registry-credentials partagé: %w", 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. // "deployment.imagePullSecrets", pas "imagePullSecrets" à la racine : // emplacement imposé par values.schema.json du chart officiel // traefik/traefik (voir le commentaire équivalent dans // deploy/chart-gestion/traefik/values.yaml). traefikValues := map[string]interface{}{ "traefik": map[string]interface{}{ "deployment": map[string]interface{}{ "imagePullSecrets": h.imagePullSecretsValue(), }, }, } if err := h.upgradeInstallChart(sharedTraefikNamespace, sharedTraefikRelease, "traefik", traefikValues); 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 — // et ses sauvegardes Velero (planning + Backup déjà pris, voir // deleteDemoBackups). Appelée aussi bien pour l'expiration TTL que pour une // destruction forcée par un admin (même chemin, voir Service.Delete) : dans // les deux cas, les sauvegardes associées disparaissent avec la démo. // La suppression des sauvegardes échoue en silence (journalisée) : un // pépin côté Velero ne doit pas bloquer la destruction de la démo // elle-même, qui reste l'action prioritaire demandée par l'appelant. func (h *HelmProvisioner) Teardown(d Demo) error { if err := h.deleteDemoBackups(d.Namespace); err != nil { log.Printf("Warning: suppression des sauvegardes échouée pour %s: %v", d.Namespace, err) } 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") // Mot de passe postgres actuel (pour le pg_dump) — jamais persisté nulle // part, relu directement depuis la release Helm en place. oldPostgresValues, err := h.getReleaseValues(oldNamespace, oldNamespace+"-postgresql") if err != nil { return fmt.Errorf("lecture config postgresql existante: %w", err) } oldPgPassword := asStringMap(oldPostgresValues["auth"])["password"] if err := h.createNamespace(newNamespace); err != nil { return fmt.Errorf("création namespace %s: %w", newNamespace, err) } // Sauvegardes : voir le commentaire équivalent dans Provision. Non-fatal. if err := h.ensureDemoBackupSchedule(newNamespace); err != nil { log.Printf("Warning: planning de sauvegarde échoué pour %s: %v", newNamespace, err) } // Isolation réseau AVANT tout workload, comme pour Provision. if err := h.installChart(newNamespace, "network-policy", h.buildNetworkPolicyValues()); err != nil { h.deleteNamespace(newNamespace) return fmt.Errorf("déploiement network-policy: %w", err) } if err := h.installChart(newNamespace, "resource-quota", nil); err != nil { h.deleteNamespace(newNamespace) return fmt.Errorf("déploiement resource-quota: %w", err) } if err := h.installChart(newNamespace, "registry-credentials", h.buildRegistryCredentialsValues()); err != nil { h.deleteNamespace(newNamespace) return fmt.Errorf("déploiement registry-credentials: %w", err) } newDemo := d newDemo.Namespace = newNamespace newDemo.URL = newURL // Nouveau mot de passe postgres/redis pour le nouveau namespace (pas de // réutilisation de l'ancien) : la donnée est migrée, pas l'identifiant. pgPassword := randomSecret() redisPassword := randomSecret() if err := h.installChart(newNamespace, "postgresql", h.buildPostgresValues(newDemo, pgPassword)); err != nil { h.deleteNamespace(newNamespace) return fmt.Errorf("déploiement postgresql: %w", err) } if err := h.installChart(newNamespace, "redis", h.buildRedisValues(newDemo, redisPassword)); 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, oldPgPassword, pgPassword); 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, pgPassword, redisPassword) 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, pgPassword, redisPassword) 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) } // Sauvegardes de l'ancien namespace : plus de sens une fois migré (les // données sont déjà dans le nouveau namespace, qui a son propre // planning depuis sa création ci-dessus). if err := h.deleteDemoBackups(oldNamespace); err != nil { log.Printf("Warning: suppression des sauvegardes de l'ancien namespace %s échouée: %v", oldNamespace, 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. // oldPassword/newPassword : mots de passe respectifs de chaque instance // postgres (uniques par démo, jamais réutilisés — voir Provision). func (h *HelmProvisioner) migratePostgresData(ctx context.Context, oldNamespace, newNamespace, oldPassword, newPassword 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", oldPassword, 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", newPassword, 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, // URL du webhook Telegram, et les nouveaux mots de passe postgres/redis // (uniques au nouveau namespace, voir MigrateToPremiumNamespace). func (h *HelmProvisioner) patchBackendValuesForNamespace(values map[string]interface{}, newDemo Demo, lbEnabled bool, backendLinkSecret, pgPassword, redisPassword 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) delete(env, "DB_PASSWORD") // désormais dans secrets, voir buildBackendValues delete(env, "REDIS_PASSWORD") // idem secrets["DB_PASSWORD"] = pgPassword secrets["REDIS_PASSWORD"] = redisPassword 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 (avec les nouveaux mots de passe) et URL interne du backend. func (h *HelmProvisioner) patchLBTelegramValuesForNamespace(values map[string]interface{}, newDemo Demo, backendLinkSecret, pgPassword, redisPassword 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:%s@%s-postgresql-postgresql:5432/demo_db?sslmode=disable", pgPassword, newDemo.Namespace) secrets["REDIS_URL"] = fmt.Sprintf("redis://:%s@%s-redis-redis:6379/0", redisPassword, 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, pgPassword 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", pgPassword, 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 — utilisateur/base fixes (pas des // secrets), le mot de passe est généré par démo (voir Provision et // MigrateToPremiumNamespace, jamais codé en dur ni partagé entre démos). const ( demoDBUser = "postgres" demoDBName = "demo_db" ) // 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 } // omnexServiceAccountNamespace / omnexServiceAccountName / omnexWorkloadClusterRole : // identité du ServiceAccount du control-plane (voir deploy/rbac/control-plane.yml). // omnexWorkloadClusterRole n'est JAMAIS liée cluster-wide — seulement via un // RoleBinding par namespace (voir ensureWorkloadRoleBinding), pour que le // ServiceAccount n'ait aucun accès aux namespaces qu'il ne gère pas // (cert-manager, longhorn-system, kube-system...). Le ServiceAccount ne // possède que le verbe "bind" sur cette ClusterRole précise : il ne peut pas // s'octroyer davantage (mécanisme RBAC anti-escalade de Kubernetes). const ( omnexServiceAccountNamespace = "omnex-system" omnexServiceAccountName = "omnex-control-plane" omnexWorkloadClusterRole = "omnex-demo-workload-manager" ) // createNamespace crée un namespace Kubernetes et y lie la ClusterRole // métier du control-plane (moindre privilège — voir la doc des constantes // ci-dessus). 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 h.ensureWorkloadRoleBinding(name) } // ensureWorkloadRoleBinding lie omnexWorkloadClusterRole au ServiceAccount du // control-plane, dans "namespace" uniquement — c'est ce RoleBinding (pas une // ClusterRoleBinding) qui borne les droits du control-plane à ce seul // namespace pour les Deployments/Secrets/ConfigMaps/etc. func (h *HelmProvisioner) ensureWorkloadRoleBinding(namespace string) error { rb := &rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ Name: omnexServiceAccountName, Namespace: namespace, }, RoleRef: rbacv1.RoleRef{ APIGroup: "rbac.authorization.k8s.io", Kind: "ClusterRole", Name: omnexWorkloadClusterRole, }, Subjects: []rbacv1.Subject{{ Kind: "ServiceAccount", Name: omnexServiceAccountName, Namespace: omnexServiceAccountNamespace, }}, } _, err := h.k8sClient.RbacV1().RoleBindings(namespace).Create(context.Background(), rb, 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, }) } // veleroNamespace : namespace fixe où tourne Velero (installé une fois par // un opérateur admin, voir deploy/velero/) — jamais créé par ce code. // // Sauvegardes des démos : un Schedule Velero PAR démo (pas un planning // global multi-namespace) — c'est le seul moyen de supprimer les // sauvegardes d'UNE démo sans toucher à celles des autres, Velero ne // permettant de supprimer qu'un objet Backup entier, jamais une portion // (voir ensureDemoBackupSchedule/deleteDemoBackups, appelés depuis // Provision/Teardown/MigrateToPremiumNamespace). const veleroNamespace = "velero" var ( veleroScheduleGVR = schema.GroupVersionResource{Group: "velero.io", Version: "v1", Resource: "schedules"} veleroBackupGVR = schema.GroupVersionResource{Group: "velero.io", Version: "v1", Resource: "backups"} ) // demoBackupScheduleName : nom du Schedule Velero dédié à ce namespace de // démo (aussi utilisé pour retrouver ses Backup via le label // velero.io/schedule-name que Velero pose automatiquement). func demoBackupScheduleName(namespace string) string { return "demo-backup-" + namespace } // ensureDemoBackupSchedule crée (idempotent) le Schedule Velero dédié à ce // namespace de démo : sauvegarde complète (manifestes + contenu réel des // PVC via File System Backup) toutes les 15 min, conservée 24h — fenêtre de // perte de données minimale en cas de problème sur le cluster. Non-fatal en // cas d'erreur côté appelant (Velero indisponible ne doit pas empêcher une // démo de fonctionner) : voir les appels dans Provision/ // MigrateToPremiumNamespace, qui journalisent seulement. func (h *HelmProvisioner) ensureDemoBackupSchedule(namespace string) error { obj := &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "velero.io/v1", "kind": "Schedule", "metadata": map[string]interface{}{ "name": demoBackupScheduleName(namespace), "namespace": veleroNamespace, }, "spec": map[string]interface{}{ "schedule": "*/15 * * * *", "template": map[string]interface{}{ "includedNamespaces": []interface{}{namespace}, "defaultVolumesToFsBackup": true, "storageLocation": "default", "ttl": "24h0m0s", }, }, }} _, err := h.dynamicClient.Resource(veleroScheduleGVR).Namespace(veleroNamespace). Create(context.Background(), obj, metav1.CreateOptions{}) if err != nil && !k8sErrors.IsAlreadyExists(err) { return err } return nil } // deleteDemoBackups supprime le Schedule Velero de cette démo ainsi que // toutes les sauvegardes déjà prises par ce planning (la suppression d'un // Backup déclenche le nettoyage asynchrone des données Kopia associées côté // Velero — pas de suppression directe dans le bucket S3, qui risquerait de // casser le dépôt Kopia partagé par les autres démos). Appelée depuis // Teardown (expiration TTL et destruction forcée passent toutes les deux // par ce même chemin) et lors d'une migration premium réussie (l'ancien // namespace disparaît, son planning de sauvegarde n'a plus de sens). func (h *HelmProvisioner) deleteDemoBackups(namespace string) error { name := demoBackupScheduleName(namespace) list, err := h.dynamicClient.Resource(veleroBackupGVR).Namespace(veleroNamespace). List(context.Background(), metav1.ListOptions{LabelSelector: "velero.io/schedule-name=" + name}) if err != nil { return fmt.Errorf("liste des sauvegardes de %s: %w", namespace, err) } for _, b := range list.Items { err := h.dynamicClient.Resource(veleroBackupGVR).Namespace(veleroNamespace). Delete(context.Background(), b.GetName(), metav1.DeleteOptions{}) if err != nil && !k8sErrors.IsNotFound(err) { return fmt.Errorf("suppression sauvegarde %s: %w", b.GetName(), err) } } err = h.dynamicClient.Resource(veleroScheduleGVR).Namespace(veleroNamespace). Delete(context.Background(), name, metav1.DeleteOptions{}) if err != nil && !k8sErrors.IsNotFound(err) { return fmt.Errorf("suppression planning %s: %w", name, err) } return nil } // 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", "--atomic", "--timeout", "5m", // Le ServiceAccount du control-plane n'a volontairement aucun droit // cluster-scope sur les CustomResourceDefinition (voir // deploy/rbac/control-plane.yml) : les CRD sont appliquées une fois, // à part, par un opérateur humain avec un accès admin — jamais par // ce chemin. Sans ce flag, "helm install" tente quand même de les // créer (même si elles existent déjà) et échoue en Forbidden. "--skip-crds", } 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", "--atomic", "--timeout", "5m", // Voir le commentaire équivalent dans installChart : le // ServiceAccount du control-plane n'a pas le droit de créer des CRD // (deploy/rbac/control-plane.yml) — utilisé ici pour le chart // "traefik", dont le subchart vendorisé embarque des CRD déjà // appliquées une fois à part par un opérateur admin. "--skip-crds", } 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 } // dockerHubSecretName : nom fixe du Secret créé par le chart // registry-credentials (voir buildRegistryCredentialsValues) — référencé // tel quel par imagePullSecretsValue, toujours dans le même namespace. const dockerHubSecretName = "dockerhub-pull-secret" // imagePullSecretsValue : nil si aucun compte Docker Hub n'est configuré // (OMNEX_DOCKERHUB_USERNAME/PASSWORD vides — pull anonyme, comportement par // défaut inchangé). Un map Helm avec une valeur nil pour une clé existante // du chart fait retomber sur le défaut du chart ("[]"), donc sans danger à // assigner inconditionnellement. func (h *HelmProvisioner) imagePullSecretsValue() []map[string]interface{} { if h.cfg.DockerHubUsername == "" || h.cfg.DockerHubPassword == "" { return nil } return []map[string]interface{}{{"name": dockerHubSecretName}} } // buildRegistryCredentialsValues construit les valeurs pour le chart // registry-credentials (voir Provision/EnsureSharedInfra/ // MigrateToPremiumNamespace) — nil si pas de compte configuré, auquel cas // installChart écrit un fichier de valeurs "null" (no-op, voir writeValuesFile). func (h *HelmProvisioner) buildRegistryCredentialsValues() map[string]interface{} { if h.cfg.DockerHubUsername == "" || h.cfg.DockerHubPassword == "" { return nil } return map[string]interface{}{ "secretName": dockerHubSecretName, "username": h.cfg.DockerHubUsername, "password": h.cfg.DockerHubPassword, } } 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é. // pgPassword/redisPassword : générés par démo (voir Provision), jamais // codés en dur — placés dans "secrets" (Secret k8s), pas "env" (visible en // clair sur le Deployment sans droit de lecture des Secrets). func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource, cfg ProvisionConfig, backendLinkSecret, pgPassword, redisPassword 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_USER": "postgres", "DB_NAME": "demo_db", "DB_SSLMODE": "disable", "REDIS_HOST": fmt.Sprintf("%s-redis-redis", d.Namespace), "REDIS_PORT": "6379", "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) secrets["DB_PASSWORD"] = pgPassword secrets["REDIS_PASSWORD"] = redisPassword 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 } // TomTom : seule la première clé est requise, les 3 suivantes sont des // clés de secours optionnelles (rotation automatique côté backend, voir // services/tomtom_keys.go côté gestion). if cfg.TomTomAPIKey != "" { secrets["TOMTOM_API_KEY"] = cfg.TomTomAPIKey } if cfg.TomTomAPIKey1 != "" { secrets["TOMTOM_API_KEY_1"] = cfg.TomTomAPIKey1 } if cfg.TomTomAPIKey2 != "" { secrets["TOMTOM_API_KEY_2"] = cfg.TomTomAPIKey2 } if cfg.TomTomAPIKey3 != "" { secrets["TOMTOM_API_KEY_3"] = cfg.TomTomAPIKey3 } 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, "imagePullSecrets": h.imagePullSecretsValue(), "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, "imagePullSecrets": h.imagePullSecretsValue(), "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, } } // buildNetworkPolicyValues construit les valeurs pour le chart // network-policy (isolation réseau stricte d'une démo, voir Provision). // kubeSystemNamespace/blockedEgressCIDRs gardent les défauts du chart // (deploy/chart-gestion/network-policy/values.yaml) ; seul traefikNamespace // est explicité pour rester synchronisé avec sharedTraefikNamespace. func (h *HelmProvisioner) buildNetworkPolicyValues() map[string]interface{} { return map[string]interface{}{ "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, pgPassword, redisPassword 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:%s@%s-postgresql-postgresql:5432/demo_db?sslmode=disable", pgPassword, d.Namespace) redisURL := fmt.Sprintf("redis://:%s@%s-redis-redis:6379/0", redisPassword, 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{}{ "imagePullSecrets": h.imagePullSecretsValue(), "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. password : généré par démo, // jamais partagé (voir Provision/MigrateToPremiumNamespace). func (h *HelmProvisioner) buildPostgresValues(d Demo, password string) map[string]interface{} { return map[string]interface{}{ "imagePullSecrets": h.imagePullSecretsValue(), "auth": map[string]interface{}{ "password": password, "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. password : généré par démo, // jamais partagé (voir Provision/MigrateToPremiumNamespace). func (h *HelmProvisioner) buildRedisValues(d Demo, password string) map[string]interface{} { return map[string]interface{}{ "imagePullSecrets": h.imagePullSecretsValue(), "service": map[string]interface{}{ "type": "ClusterIP", "port": 6379, }, "auth": map[string]interface{}{ "password": password, }, "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): ready, err := h.rolloutReady(ctx, namespace) if err != nil { return fmt.Errorf("rollout check: %w", err) } if ready { return nil } } } } // rolloutReady : true si tous les Deployment et StatefulSet du namespace ont // toutes leurs répliques prêtes. Remplace un ancien appel à // "kubectl rollout status deployment -n " — en plus de l'absence // du binaire kubectl dans l'image (seul helm y est installé, voir // control-plane/api/Dockerfile), cette commande était de toute façon // invalide sans nom de déploiement précis (jamais réellement exercée avant // la première migration premium en conditions réelles). func (h *HelmProvisioner) rolloutReady(ctx context.Context, namespace string) (bool, error) { deploys, err := h.k8sClient.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{}) if err != nil { return false, err } for _, d := range deploys.Items { desired := int32(1) if d.Spec.Replicas != nil { desired = *d.Spec.Replicas } if d.Status.ReadyReplicas < desired { return false, nil } } statefulSets, err := h.k8sClient.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{}) if err != nil { return false, err } for _, s := range statefulSets.Items { desired := int32(1) if s.Spec.Replicas != nil { desired = *s.Spec.Replicas } if s.Status.ReadyReplicas < desired { return false, nil } } return true, 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}, "lbtelegram": {cpuMilli: 500, memMi: 256}, } // componentKey identifie le composant (backend/frontend/postgresql/redis/ // lbtelegram) à 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" case strings.Contains(podName, "lbtelegram"): return "lbtelegram" 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 case "lbtelegram": state.LB = cs } } return state, nil }