package demos import ( "bytes" "context" "fmt" "log" "os" "os/exec" "path/filepath" "strings" "time" "github.com/omnex/control-plane/api/internal/config" "go.yaml.in/yaml/v2" k8sCoreV1 "k8s.io/api/core/v1" k8sErrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" ) // 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 chartsDir string // chemin vers le dossier des charts (ex: /charts) frontendImage string backendImage 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") func NewHelmProvisioner( cfg *config.Config, k8sClient *kubernetes.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, chartsDir: chartsDir, frontendImage: cfg.FrontendImage, backendImage: cfg.BackendImage, baseDomain: cfg.DemoDomain, helmPath: helmPath, }, nil } // 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 func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource) error { // Créer le namespace if err := h.createNamespace(d.Namespace); err != nil { return fmt.Errorf("création namespace %s: %w", d.Namespace, err) } // Préparer les valeurs postgresValues := h.buildPostgresValues(d) redisValues := h.buildRedisValues(d) backendValues := h.buildBackendValues(d, resources) frontendValues := h.buildFrontendValues(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) } // Attendre que les pods soient prêts if err := h.waitForRollout(d.Namespace); err != nil { log.Printf("Warning: rollout check échoué pour %s: %v", d.Namespace, err) } return nil } // Teardown détruit une démo en supprimant son namespace (cascading). func (h *HelmProvisioner) Teardown(d Demo) error { return h.deleteNamespace(d.Namespace) } // 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 un chart Helm avec des valeurs personnalisées. func (h *HelmProvisioner) installChart(namespace, chartName string, values map[string]interface{}) error { chartPath := filepath.Join(h.chartsDir, chartName) // Créer un fichier values.yaml temporaire valuesFile := filepath.Join("/tmp", fmt.Sprintf("values-%s-%s.yaml", namespace, chartName)) valuesYAML, err := yaml.Marshal(values) if err != nil { return fmt.Errorf("marshal YAML: %w", err) } if err := os.WriteFile(valuesFile, valuesYAML, 0644); err != nil { return fmt.Errorf("write values file: %w", err) } defer os.Remove(valuesFile) // Construire la commande helm install args := []string{ "install", fmt.Sprintf("%s-%s", namespace, chartName), chartPath, "--namespace", namespace, "--values", valuesFile, "--wait", "--timeout", "5m", } cmd := exec.Command(h.helmPath, args...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("helm install %s: %v (stdout: %s, stderr: %s)", chartName, err, stdout.String(), stderr.String()) } log.Printf("Chart %s installé dans %s", chartName, namespace) return 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. func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource) map[string]interface{} { // Parser l'image backend pour séparer repository et tag backendRepo, backendTag := parseImage(h.backendImage) 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": false, }, }, "env": map[string]string{ "DB_HOST": fmt.Sprintf("%s-postgresql-postgresql", d.Namespace), "DB_PORT": "5432", "DB_PASSWORD": "demo-postgres-pass", "DB_USER": "postgres", "DB_NAME": "demo_db", "DB_SSLMODE": "disable", "REDIS_HOST": fmt.Sprintf("%s-redis-redis", d.Namespace), "REDIS_PORT": "6379", "REDIS_PASSWORD": "demo-redis-pass", // Mot de passe Redis "API_PORT": "8080", "NODE_ENV": "production", }, "secrets": h.buildSecrets(resources), } return values } // buildFrontendValues construit les valeurs pour le chart frontend. func (h *HelmProvisioner) buildFrontendValues(d Demo) map[string]interface{} { backendURL := fmt.Sprintf("http://%s-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, }, } } // 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. 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": false, }, } } // buildRedisValues construit les valeurs pour le chart redis. 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": false, // Pas de persistence pour les démos }, } } // 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 } } } 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 } var state ResourceState for _, pod := range pods.Items { status := string(pod.Status.Phase) switch { case strings.Contains(pod.Name, "backend"): state.APIState = status case strings.Contains(pod.Name, "frontend"): state.WebState = status case strings.Contains(pod.Name, "postgresql"): state.DBState = status case strings.Contains(pod.Name, "redis"): state.DBMemoryState = status } } return state, nil }