package demos import ( "context" "fmt" "log" "strconv" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // Projets vitrine (voir internal/projects) : même infrastructure que les // démos (namespace isolé, NetworkPolicy, ResourceQuota, Traefik + WAF // partagés, sauvegardes Velero par namespace) mais avec les charts du // produit vitrine (h.vitrineChartsDir) et une durée d'abonnement en mois. // Le cycle de vie (durée, suspension, suppression) vit dans internal/projects ; // ce fichier ne contient que ce qui touche au cluster. // Noms de release/chart utilisés par les charts vitrine (dossier // deploy/chart-vitrine). Le nom de Service produit par le chart est // "-" avec release = "-" (voir // installChartFrom) : ces deux fonctions doivent rester synchronisées avec les // defaults de deploy/chart-vitrine/ingressroute/templates/_helpers.tpl. func vitrineBackendService(namespace string) string { return namespace + "-backend-vitrine-backend" } func vitrineFrontendService(namespace string) string { return namespace + "-frontend-vitrine-frontend" } // replicasBeforeSuspendAnnotation : nombre de réplicas d'une charge de travail // avant sa suspension, pour la relancer à l'identique (voir SuspendProject). const replicasBeforeSuspendAnnotation = "omnex.app/replicas-before-suspend" // ProjectSpec : ce dont le provisioner a besoin pour déployer un projet // vitrine. AdminPassword ne doit jamais être persisté : il ne transite que // par le fichier de valeurs Helm temporaire puis le Secret k8s du chart. type ProjectSpec struct { Namespace string Host string // nom d'hôte public, sans protocole (ex. vitrine-ab12cd34.vitrine-omnex.club) URL string // URL publique complète (https://) AdminUsername string AdminPassword string // AdminNumber : nombre maximum de comptes admin du backend (ADMIN_NUMBER), // choisi par l'admin Omnex au déploiement. AdminNumber int } // ProvisionProject déploie un projet vitrine : // 1. namespace + Schedule Velero (identique aux démos : voir // ensureDemoBackupSchedule), // 2. isolation réseau, quota, identifiants du registry (charts des démos), // 3. postgresql, redis, backend (migrations + compte admin via hooks Helm), // frontend puis ingressroute (charts vitrine). // // En cas d'échec, le namespace et le Schedule sont supprimés. func (h *HelmProvisioner) ProvisionProject(p ProjectSpec) error { ns := p.Namespace // Sans image, les valeurs Helm écraseraient les défauts des charts par // une chaîne vide : échec explicite avant de créer quoi que ce soit. if h.cfg.VitrineBackendImage == "" || h.cfg.VitrineFrontendImage == "" { return fmt.Errorf("VITRINE_BACKEND_IMAGE et VITRINE_FRONTEND_IMAGE doivent être définies") } if err := h.createNamespace(ns); err != nil { return fmt.Errorf("création namespace %s: %w", ns, err) } // Non-fatal : un projet doit pouvoir démarrer même si Velero est // momentanément indisponible (même règle que pour les démos). if err := h.ensureDemoBackupSchedule(ns); err != nil { log.Printf("Warning: planning de sauvegarde échoué pour %s: %v", ns, err) } fail := func(step string, err error) error { if derr := h.deleteDemoBackups(ns); derr != nil { log.Printf("Warning: nettoyage des sauvegardes de %s après échec: %v", ns, derr) } _ = h.deleteNamespace(ns) return fmt.Errorf("déploiement %s: %w", step, err) } // Isolation réseau AVANT tout workload, plafond de ressources, puis // identifiants du registry avant tout pull d'image : mêmes charts que les // démos (h.chartsDir). if err := h.installChart(ns, "network-policy", h.buildNetworkPolicyValues()); err != nil { return fail("network-policy", err) } if err := h.installChart(ns, "resource-quota", nil); err != nil { return fail("resource-quota", err) } if err := h.installChart(ns, "registry-credentials", h.buildRegistryCredentialsValues()); err != nil { return fail("registry-credentials", err) } // Secrets générés par projet, jamais partagés ni codés en dur. pgPassword := randomSecret() redisPassword := randomSecret() jwtSecret := randomSecret() steps := []struct { chart string values map[string]interface{} }{ {"postgresql", h.buildVitrinePostgresValues(pgPassword)}, {"redis", h.buildVitrineRedisValues(redisPassword)}, {"backend", h.buildVitrineBackendValues(p, pgPassword, redisPassword, jwtSecret)}, {"frontend", h.buildVitrineFrontendValues()}, {"ingressroute", h.buildVitrineIngressRouteValues(p)}, } for _, s := range steps { if err := h.installChartFrom(h.vitrineChartsDir, ns, s.chart, s.values); err != nil { return fail(s.chart, err) } } if err := h.waitForRollout(ns); err != nil { // Le déploiement Helm a réussi (--wait --atomic) : on ne détruit pas // pour un simple dépassement de délai de cette vérification. log.Printf("Warning: rollout check échoué pour %s: %v", ns, err) } return nil } // TeardownProject supprime le namespace du projet (PVC compris) et ses // sauvegardes Velero — même chemin que Teardown pour une démo. func (h *HelmProvisioner) TeardownProject(namespace string) error { return h.Teardown(Demo{Namespace: namespace}) } // SuspendProject met à 0 les Deployment et StatefulSet du namespace : le // projet n'est plus servi (Traefik répond 503) mais ses PVC, ses Secrets et // son IngressRoute restent en place. Le nombre de réplicas d'origine est // noté en annotation pour ResumeProject. Idempotent. func (h *HelmProvisioner) SuspendProject(namespace string) error { return h.scaleProject(namespace, false) } // ResumeProject relance ce que SuspendProject a arrêté, au nombre de // réplicas d'origine (1 à défaut d'annotation). Idempotent. func (h *HelmProvisioner) ResumeProject(namespace string) error { return h.scaleProject(namespace, true) } func (h *HelmProvisioner) scaleProject(namespace string, up bool) error { ctx := context.Background() deploys, err := h.k8sClient.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{}) if err != nil { return fmt.Errorf("liste des deployments de %s: %w", namespace, err) } for i := range deploys.Items { d := &deploys.Items[i] if d.Spec.Replicas == nil { one := int32(1) d.Spec.Replicas = &one } target, changed := nextReplicas(d.Spec.Replicas, &d.Annotations, up) if !changed { continue } d.Spec.Replicas = &target if _, err := h.k8sClient.AppsV1().Deployments(namespace).Update(ctx, d, metav1.UpdateOptions{}); err != nil { return fmt.Errorf("mise à l'échelle du deployment %s: %w", d.Name, err) } } sets, err := h.k8sClient.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{}) if err != nil { return fmt.Errorf("liste des statefulsets de %s: %w", namespace, err) } for i := range sets.Items { s := &sets.Items[i] if s.Spec.Replicas == nil { one := int32(1) s.Spec.Replicas = &one } target, changed := nextReplicas(s.Spec.Replicas, &s.Annotations, up) if !changed { continue } s.Spec.Replicas = &target if _, err := h.k8sClient.AppsV1().StatefulSets(namespace).Update(ctx, s, metav1.UpdateOptions{}); err != nil { return fmt.Errorf("mise à l'échelle du statefulset %s: %w", s.Name, err) } } return nil } // nextReplicas calcule le nombre de réplicas cible d'une charge de travail et // met à jour ses annotations. changed=false : rien à faire (déjà dans l'état // demandé). func nextReplicas(current *int32, annotations *map[string]string, up bool) (target int32, changed bool) { if !up { if *current == 0 { return 0, false } if *annotations == nil { *annotations = map[string]string{} } (*annotations)[replicasBeforeSuspendAnnotation] = strconv.Itoa(int(*current)) return 0, true } if *current != 0 { return *current, false } target = 1 if v, ok := (*annotations)[replicasBeforeSuspendAnnotation]; ok { if n, err := strconv.Atoi(v); err == nil && n > 0 { target = int32(n) } delete(*annotations, replicasBeforeSuspendAnnotation) } return target, true } func (h *HelmProvisioner) buildVitrinePostgresValues(password string) map[string]interface{} { // Nom de base et utilisateur : défauts du chart (vitrine_db / postgres), // alignés sur database.name / database.user du chart backend. return map[string]interface{}{ "imagePullSecrets": h.imagePullSecretsValue(), "auth": map[string]interface{}{"password": password}, } } func (h *HelmProvisioner) buildVitrineRedisValues(password string) map[string]interface{} { return map[string]interface{}{ "imagePullSecrets": h.imagePullSecretsValue(), "auth": map[string]interface{}{"password": password}, } } func (h *HelmProvisioner) buildVitrineBackendValues(p ProjectSpec, pgPassword, redisPassword, jwtSecret string) map[string]interface{} { repo, tag := parseImage(h.cfg.VitrineBackendImage) return map[string]interface{}{ "replicaCount": 1, // volumes ReadWriteOnce : un seul replica (voir le chart) "imagePullSecrets": h.imagePullSecretsValue(), "image": map[string]interface{}{ "repository": repo, "tag": tag, "pullPolicy": "Always", }, "autoscaling": map[string]interface{}{"enabled": false}, "env": map[string]interface{}{ "ADMIN_NUMBER": strconv.Itoa(p.AdminNumber), "SEED_ADMIN_USERNAME": p.AdminUsername, "MEDIA_LOCAL_BASE_URL": p.URL + "/uploads", }, "secrets": map[string]interface{}{ "JWT_SECRET": jwtSecret, "DB_PASSWORD": pgPassword, "REDIS_PASSWORD": redisPassword, "SEED_ADMIN_PASSWORD": p.AdminPassword, }, // Compte admin créé par le hook post-install du chart (jamais rejoué // à l'upgrade : cmd/seed n'est pas idempotent). "seedJob": map[string]interface{}{"enabled": true}, } } func (h *HelmProvisioner) buildVitrineFrontendValues() map[string]interface{} { repo, tag := parseImage(h.cfg.VitrineFrontendImage) return map[string]interface{}{ "replicaCount": 1, "imagePullSecrets": h.imagePullSecretsValue(), "image": map[string]interface{}{ "repository": repo, "tag": tag, "pullPolicy": "Always", }, "autoscaling": map[string]interface{}{"enabled": false}, } } func (h *HelmProvisioner) buildVitrineIngressRouteValues(p ProjectSpec) map[string]interface{} { return map[string]interface{}{ "host": p.Host, "traefikNamespace": sharedTraefikNamespace, "backend": map[string]interface{}{"serviceName": vitrineBackendService(p.Namespace)}, "frontend": map[string]interface{}{"serviceName": vitrineFrontendService(p.Namespace)}, } }