258 lines
9.7 KiB
Go
258 lines
9.7 KiB
Go
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
|
|
// "<release>-<nom du chart>" avec release = "<namespace>-<chart>" (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://<Host>)
|
|
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)},
|
|
}
|
|
}
|