Files
omnex/control-plane/api/internal/demos/helm_provisioner.go
T
Nuxgrid 4b3a5720a3
ci-api / test (push) Has been cancelled
feat: add details demo
2026-07-28 14:44:48 +02:00

374 lines
11 KiB
Go

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
}