chore: update
ci-api / test (push) Successful in 24m6s
ci-web / test (push) Successful in 13m50s

This commit is contained in:
Xor290
2026-09-20 11:50:02 +02:00
parent cb48bb2b70
commit 76b1412968
23 changed files with 2141 additions and 89 deletions
@@ -36,17 +36,21 @@ import (
// 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
restConfig *rest.Config // pour l'exec dans les pods (pg_dump/restore)
metricsClient *metricsclient.Clientset // optionnel : nil si metrics-server indisponible
dynamicClient dynamic.Interface // pour les CRD Velero (Schedule/Backup), voir ensureDemoBackupSchedule
chartsDir string // chemin vers le dossier des charts (ex: /charts)
frontendImage string
backendImage string
lbtelegramImage string
baseDomain string
helmPath string // chemin vers l'exécutable helm (default: "helm")
cfg *config.Config
k8sClient *kubernetes.Clientset
restConfig *rest.Config // pour l'exec dans les pods (pg_dump/restore)
metricsClient *metricsclient.Clientset // optionnel : nil si metrics-server indisponible
dynamicClient dynamic.Interface // pour les CRD Velero (Schedule/Backup), voir ensureDemoBackupSchedule
chartsDir string // chemin vers le dossier des charts (ex: /charts)
// vitrineChartsDir : charts des projets vitrine (backend, frontend,
// postgresql, redis, ingressroute), distincts de chartsDir dont les
// charts de mêmes noms déploient le produit des démos.
vitrineChartsDir string
frontendImage string
backendImage string
lbtelegramImage string
baseDomain string
helmPath string // chemin vers l'exécutable helm (default: "helm")
}
// NewHelmProvisioner crée un nouveau provisioner Helm.
@@ -79,17 +83,18 @@ func NewHelmProvisioner(
}
return &HelmProvisioner{
cfg: cfg,
k8sClient: k8sClient,
restConfig: restConfig,
metricsClient: metricsClient,
dynamicClient: dynamicClient,
chartsDir: chartsDir,
frontendImage: cfg.FrontendImage,
backendImage: cfg.BackendImage,
lbtelegramImage: cfg.LBTelegramImage,
baseDomain: cfg.DemoDomain,
helmPath: helmPath,
cfg: cfg,
k8sClient: k8sClient,
restConfig: restConfig,
metricsClient: metricsClient,
dynamicClient: dynamicClient,
chartsDir: chartsDir,
vitrineChartsDir: cfg.VitrineChartsDir,
frontendImage: cfg.FrontendImage,
backendImage: cfg.BackendImage,
lbtelegramImage: cfg.LBTelegramImage,
baseDomain: cfg.DemoDomain,
helmPath: helmPath,
}, nil
}
@@ -843,10 +848,10 @@ func (h *HelmProvisioner) ensureDemoBackupSchedule(namespace string) error {
"spec": map[string]interface{}{
"schedule": "*/15 * * * *",
"template": map[string]interface{}{
"includedNamespaces": []interface{}{namespace},
"includedNamespaces": []interface{}{namespace},
"defaultVolumesToFsBackup": true,
"storageLocation": "default",
"ttl": "24h0m0s",
"storageLocation": "default",
"ttl": "24h0m0s",
},
},
}}
@@ -911,6 +916,13 @@ func (h *HelmProvisioner) deleteDemoBackups(namespace string) error {
// avec des valeurs personnalisées. Release name : "<namespace>-<chartName>".
// Adapté aux charts par démo (namespace toujours neuf).
func (h *HelmProvisioner) installChart(namespace, chartName string, values map[string]interface{}) error {
return h.installChartFrom(h.chartsDir, namespace, chartName, values)
}
// installChartFrom : comme installChart, avec un dossier de charts explicite
// (charts des démos : h.chartsDir ; charts des projets vitrine :
// h.vitrineChartsDir).
func (h *HelmProvisioner) installChartFrom(chartsDir, namespace, chartName string, values map[string]interface{}) error {
release := fmt.Sprintf("%s-%s", namespace, chartName)
valuesFile, cleanup, err := h.writeValuesFile(namespace, chartName, values)
if err != nil {
@@ -921,7 +933,7 @@ func (h *HelmProvisioner) installChart(namespace, chartName string, values map[s
args := []string{
"install",
release,
filepath.Join(h.chartsDir, chartName),
filepath.Join(chartsDir, chartName),
"--namespace", namespace,
"--values", valuesFile,
"--wait",
@@ -0,0 +1,282 @@
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
// "<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"
}
// 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://<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})
}
// 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)},
}
}
@@ -0,0 +1,139 @@
package demos
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"go.yaml.in/yaml/v2"
"github.com/omnex/control-plane/api/internal/config"
)
func ptr[T any](v T) *T { return &v }
func TestNextReplicas_SuspendThenResumeRestoresOriginalCount(t *testing.T) {
annotations := map[string]string{}
current := ptr(int32(3))
target, changed := nextReplicas(current, &annotations, false)
if !changed || target != 0 {
t.Fatalf("suspension: target=%d changed=%v", target, changed)
}
if annotations[replicasBeforeSuspendAnnotation] != "3" {
t.Fatalf("annotation=%q, attendu 3", annotations[replicasBeforeSuspendAnnotation])
}
// Déjà à 0 : suspendre à nouveau ne doit pas écraser l'annotation.
current = ptr(int32(0))
if _, changed := nextReplicas(current, &annotations, false); changed {
t.Error("suspension répétée non idempotente")
}
if annotations[replicasBeforeSuspendAnnotation] != "3" {
t.Error("l'annotation a été écrasée")
}
target, changed = nextReplicas(current, &annotations, true)
if !changed || target != 3 {
t.Fatalf("relance: target=%d changed=%v, attendu 3", target, changed)
}
if _, still := annotations[replicasBeforeSuspendAnnotation]; still {
t.Error("annotation non nettoyée après la relance")
}
}
func TestNextReplicas_ResumeWithoutAnnotationDefaultsToOne(t *testing.T) {
annotations := map[string]string{}
target, changed := nextReplicas(ptr(int32(0)), &annotations, true)
if !changed || target != 1 {
t.Errorf("target=%d changed=%v, attendu 1", target, changed)
}
}
func TestNextReplicas_ResumeRunningWorkloadIsNoop(t *testing.T) {
annotations := map[string]string{}
if _, changed := nextReplicas(ptr(int32(2)), &annotations, true); changed {
t.Error("relance d'une charge déjà active")
}
}
// TestVitrineValuesRenderWithCharts génère les valeurs comme ProvisionProject
// puis rend les charts de deploy/chart-vitrine avec helm : garantit que les
// clés attendues existent, que les validations des charts passent et que les
// noms de Services calculés côté Go sont bien ceux des charts.
func TestVitrineValuesRenderWithCharts(t *testing.T) {
if _, err := exec.LookPath("helm"); err != nil {
t.Skip("helm absent du PATH")
}
chartsDir, err := filepath.Abs("../../../../deploy/chart-vitrine")
if err != nil {
t.Fatal(err)
}
h := &HelmProvisioner{cfg: &config.Config{
VitrineBackendImage: "registry.example/vitrine-backend:1.2.3",
VitrineFrontendImage: "registry.example/vitrine-frontend:1.2.3",
}}
p := ProjectSpec{
Namespace: "vitrine-ab12cd34",
Host: "vitrine-ab12cd34.vitrine-omnex.club",
URL: "https://vitrine-ab12cd34.vitrine-omnex.club",
AdminUsername: "admin",
AdminPassword: "un-mot-de-passe-solide",
AdminNumber: 3,
}
cases := []struct {
chart string
values map[string]interface{}
wants []string
}{
{"postgresql", h.buildVitrinePostgresValues("pgpass"), []string{"name: postgres\n"}},
{"redis", h.buildVitrineRedisValues("redispass"), []string{"name: redis\n"}},
{"backend", h.buildVitrineBackendValues(p, "pgpass", "redispass", "jwt"), []string{
"name: " + vitrineBackendService(p.Namespace) + "\n",
`ADMIN_NUMBER: "3"`,
`SEED_ADMIN_USERNAME: "admin"`,
"registry.example/vitrine-backend:1.2.3",
"post-install", // seedJob activé
"postgres://postgres:pgpass@postgres:5432/vitrine_db",
}},
{"frontend", h.buildVitrineFrontendValues(), []string{
"name: " + vitrineFrontendService(p.Namespace) + "\n",
"registry.example/vitrine-frontend:1.2.3",
}},
{"ingressroute", h.buildVitrineIngressRouteValues(p), []string{
"Host(`vitrine-ab12cd34.vitrine-omnex.club`)",
"name: " + vitrineBackendService(p.Namespace) + "\n",
"name: " + vitrineFrontendService(p.Namespace) + "\n",
"tls: {}", // certificat servi par le TLSStore, pas par projet
}},
}
for _, c := range cases {
t.Run(c.chart, func(t *testing.T) {
raw, err := yaml.Marshal(c.values)
if err != nil {
t.Fatal(err)
}
valuesFile := filepath.Join(t.TempDir(), "values.yaml")
if err := writeFile(valuesFile, raw); err != nil {
t.Fatal(err)
}
// Release et namespace comme installChartFrom : "<ns>-<chart>".
out, err := exec.Command("helm", "template", p.Namespace+"-"+c.chart,
filepath.Join(chartsDir, c.chart), "--namespace", p.Namespace, "--values", valuesFile).CombinedOutput()
if err != nil {
t.Fatalf("helm template: %v\n%s", err, out)
}
for _, want := range c.wants {
if !strings.Contains(string(out), want) {
t.Errorf("rendu de %s : %q introuvable", c.chart, want)
}
}
})
}
}
func writeFile(path string, data []byte) error { return os.WriteFile(path, data, 0o600) }