package demos import ( "context" "fmt" "log" "strconv" corev1 "k8s.io/api/core/v1" 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, prolongation, 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" } // 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}) } // Valeur du label app.kubernetes.io/name des pods de chaque composant d'un // projet vitrine (voir deploy/chart-vitrine/*/templates). Les pods des Jobs de // migration et de création du compte admin portent d'autres noms // (vitrine-backend-migrate / vitrine-backend-seed) : ils ne sont donc jamais // pris pour le backend. const ( vitrineLabelBackend = "vitrine-backend" vitrineLabelFrontend = "vitrine-frontend" vitrineLabelPostgreSQL = "postgresql" vitrineLabelRedis = "redis" ) // ProjectState : phase + usage CPU/mémoire live de chaque composant d'un projet // vitrine, dans le même format que GetResourceState pour les démos (le panneau // du back-office est commun). L'usage (metrics-server) est best-effort. Les // limites viennent du pod lui-même : elles suivent les values des charts sans // table à maintenir ici. func (h *HelmProvisioner) ProjectState(ctx context.Context, namespace string) (ResourceState, error) { pods, err := h.k8sClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) if err != nil { return ResourceState{}, err } return buildProjectState(pods.Items, h.podUsage(ctx, namespace)), nil } // buildProjectState range les pods d'un projet par composant (voir // ProjectState). usage : nom de pod -> [cpuMilli, memMi]. func buildProjectState(pods []corev1.Pod, usage map[string][2]int64) ResourceState { var state ResourceState for i := range pods { pod := &pods[i] var target *ComponentState switch pod.Labels["app.kubernetes.io/name"] { case vitrineLabelBackend: target = &state.API case vitrineLabelFrontend: target = &state.Web case vitrineLabelPostgreSQL: target = &state.DB case vitrineLabelRedis: target = &state.DBM default: continue } cs := ComponentState{Phase: string(pod.Status.Phase)} for _, c := range pod.Spec.Containers { cs.CPULimitMilli += c.Resources.Limits.Cpu().MilliValue() cs.MemoryLimitMi += c.Resources.Limits.Memory().Value() / (1024 * 1024) } if u, ok := usage[pod.Name]; ok { cs.CPUMilli, cs.MemoryMi = u[0], u[1] } // Pendant un rolling update deux pods coexistent : celui qui tourne // prime sur l'ancien en cours d'arrêt. if target.Phase == string(corev1.PodRunning) && cs.Phase != string(corev1.PodRunning) { continue } *target = cs } return state } 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)}, } }