@@ -22,7 +22,10 @@ import (
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8sErrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
@@ -37,6 +40,7 @@ type HelmProvisioner struct {
|
||||
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
|
||||
@@ -69,11 +73,17 @@ func NewHelmProvisioner(
|
||||
return nil, fmt.Errorf("helm exécutable non trouvé à %s: %w", helmPath, err)
|
||||
}
|
||||
|
||||
dynamicClient, err := dynamic.NewForConfig(restConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client dynamique (CRD Velero): %w", err)
|
||||
}
|
||||
|
||||
return &HelmProvisioner{
|
||||
cfg: cfg,
|
||||
k8sClient: k8sClient,
|
||||
restConfig: restConfig,
|
||||
metricsClient: metricsClient,
|
||||
dynamicClient: dynamicClient,
|
||||
chartsDir: chartsDir,
|
||||
frontendImage: cfg.FrontendImage,
|
||||
backendImage: cfg.BackendImage,
|
||||
@@ -106,6 +116,12 @@ func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource, cfg Pr
|
||||
return fmt.Errorf("création namespace %s: %w", d.Namespace, err)
|
||||
}
|
||||
|
||||
// Sauvegardes (voir ensureDemoBackupSchedule) : non-fatal, une démo doit
|
||||
// pouvoir fonctionner même si Velero est momentanément indisponible.
|
||||
if err := h.ensureDemoBackupSchedule(d.Namespace); err != nil {
|
||||
log.Printf("Warning: planning de sauvegarde échoué pour %s: %v", d.Namespace, err)
|
||||
}
|
||||
|
||||
// Isolation réseau AVANT tout workload : deny-by-default, aucune
|
||||
// communication inter-démos, ingress limité au Traefik partagé (voir
|
||||
// deploy/chart-gestion/network-policy). Sans ça, tout pod d'une démo
|
||||
@@ -258,8 +274,18 @@ func (h *HelmProvisioner) EnsureSharedInfra() error {
|
||||
}
|
||||
|
||||
// Teardown détruit une démo en supprimant son namespace (cascading) — les
|
||||
// PVC postgres/redis de la démo partent avec, puisqu'ils sont namespaced.
|
||||
// PVC postgres/redis de la démo partent avec, puisqu'ils sont namespaced —
|
||||
// et ses sauvegardes Velero (planning + Backup déjà pris, voir
|
||||
// deleteDemoBackups). Appelée aussi bien pour l'expiration TTL que pour une
|
||||
// destruction forcée par un admin (même chemin, voir Service.Delete) : dans
|
||||
// les deux cas, les sauvegardes associées disparaissent avec la démo.
|
||||
// La suppression des sauvegardes échoue en silence (journalisée) : un
|
||||
// pépin côté Velero ne doit pas bloquer la destruction de la démo
|
||||
// elle-même, qui reste l'action prioritaire demandée par l'appelant.
|
||||
func (h *HelmProvisioner) Teardown(d Demo) error {
|
||||
if err := h.deleteDemoBackups(d.Namespace); err != nil {
|
||||
log.Printf("Warning: suppression des sauvegardes échouée pour %s: %v", d.Namespace, err)
|
||||
}
|
||||
return h.deleteNamespace(d.Namespace)
|
||||
}
|
||||
|
||||
@@ -311,6 +337,11 @@ func (h *HelmProvisioner) MigrateToPremiumNamespace(d Demo, newNamespace, newURL
|
||||
return fmt.Errorf("création namespace %s: %w", newNamespace, err)
|
||||
}
|
||||
|
||||
// Sauvegardes : voir le commentaire équivalent dans Provision. Non-fatal.
|
||||
if err := h.ensureDemoBackupSchedule(newNamespace); err != nil {
|
||||
log.Printf("Warning: planning de sauvegarde échoué pour %s: %v", newNamespace, err)
|
||||
}
|
||||
|
||||
// Isolation réseau AVANT tout workload, comme pour Provision.
|
||||
if err := h.installChart(newNamespace, "network-policy", h.buildNetworkPolicyValues()); err != nil {
|
||||
h.deleteNamespace(newNamespace)
|
||||
@@ -389,6 +420,13 @@ func (h *HelmProvisioner) MigrateToPremiumNamespace(d Demo, newNamespace, newURL
|
||||
return fmt.Errorf("rollout du nouveau namespace %s: %w", newNamespace, err)
|
||||
}
|
||||
|
||||
// Sauvegardes de l'ancien namespace : plus de sens une fois migré (les
|
||||
// données sont déjà dans le nouveau namespace, qui a son propre
|
||||
// planning depuis sa création ci-dessus).
|
||||
if err := h.deleteDemoBackups(oldNamespace); err != nil {
|
||||
log.Printf("Warning: suppression des sauvegardes de l'ancien namespace %s échouée: %v", oldNamespace, err)
|
||||
}
|
||||
|
||||
if err := h.deleteNamespace(oldNamespace); err != nil {
|
||||
log.Printf("Warning: suppression ancien namespace %s échouée (nouveau namespace %s opérationnel): %v", oldNamespace, newNamespace, err)
|
||||
}
|
||||
@@ -763,6 +801,94 @@ func (h *HelmProvisioner) deleteNamespace(name string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// veleroNamespace : namespace fixe où tourne Velero (installé une fois par
|
||||
// un opérateur admin, voir deploy/velero/) — jamais créé par ce code.
|
||||
//
|
||||
// Sauvegardes des démos : un Schedule Velero PAR démo (pas un planning
|
||||
// global multi-namespace) — c'est le seul moyen de supprimer les
|
||||
// sauvegardes d'UNE démo sans toucher à celles des autres, Velero ne
|
||||
// permettant de supprimer qu'un objet Backup entier, jamais une portion
|
||||
// (voir ensureDemoBackupSchedule/deleteDemoBackups, appelés depuis
|
||||
// Provision/Teardown/MigrateToPremiumNamespace).
|
||||
const veleroNamespace = "velero"
|
||||
|
||||
var (
|
||||
veleroScheduleGVR = schema.GroupVersionResource{Group: "velero.io", Version: "v1", Resource: "schedules"}
|
||||
veleroBackupGVR = schema.GroupVersionResource{Group: "velero.io", Version: "v1", Resource: "backups"}
|
||||
)
|
||||
|
||||
// demoBackupScheduleName : nom du Schedule Velero dédié à ce namespace de
|
||||
// démo (aussi utilisé pour retrouver ses Backup via le label
|
||||
// velero.io/schedule-name que Velero pose automatiquement).
|
||||
func demoBackupScheduleName(namespace string) string {
|
||||
return "demo-backup-" + namespace
|
||||
}
|
||||
|
||||
// ensureDemoBackupSchedule crée (idempotent) le Schedule Velero dédié à ce
|
||||
// namespace de démo : sauvegarde complète (manifestes + contenu réel des
|
||||
// PVC via File System Backup) toutes les 15 min, conservée 24h — fenêtre de
|
||||
// perte de données minimale en cas de problème sur le cluster. Non-fatal en
|
||||
// cas d'erreur côté appelant (Velero indisponible ne doit pas empêcher une
|
||||
// démo de fonctionner) : voir les appels dans Provision/
|
||||
// MigrateToPremiumNamespace, qui journalisent seulement.
|
||||
func (h *HelmProvisioner) ensureDemoBackupSchedule(namespace string) error {
|
||||
obj := &unstructured.Unstructured{Object: map[string]interface{}{
|
||||
"apiVersion": "velero.io/v1",
|
||||
"kind": "Schedule",
|
||||
"metadata": map[string]interface{}{
|
||||
"name": demoBackupScheduleName(namespace),
|
||||
"namespace": veleroNamespace,
|
||||
},
|
||||
"spec": map[string]interface{}{
|
||||
"schedule": "*/15 * * * *",
|
||||
"template": map[string]interface{}{
|
||||
"includedNamespaces": []interface{}{namespace},
|
||||
"defaultVolumesToFsBackup": true,
|
||||
"storageLocation": "default",
|
||||
"ttl": "24h0m0s",
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := h.dynamicClient.Resource(veleroScheduleGVR).Namespace(veleroNamespace).
|
||||
Create(context.Background(), obj, metav1.CreateOptions{})
|
||||
if err != nil && !k8sErrors.IsAlreadyExists(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteDemoBackups supprime le Schedule Velero de cette démo ainsi que
|
||||
// toutes les sauvegardes déjà prises par ce planning (la suppression d'un
|
||||
// Backup déclenche le nettoyage asynchrone des données Kopia associées côté
|
||||
// Velero — pas de suppression directe dans le bucket S3, qui risquerait de
|
||||
// casser le dépôt Kopia partagé par les autres démos). Appelée depuis
|
||||
// Teardown (expiration TTL et destruction forcée passent toutes les deux
|
||||
// par ce même chemin) et lors d'une migration premium réussie (l'ancien
|
||||
// namespace disparaît, son planning de sauvegarde n'a plus de sens).
|
||||
func (h *HelmProvisioner) deleteDemoBackups(namespace string) error {
|
||||
name := demoBackupScheduleName(namespace)
|
||||
|
||||
list, err := h.dynamicClient.Resource(veleroBackupGVR).Namespace(veleroNamespace).
|
||||
List(context.Background(), metav1.ListOptions{LabelSelector: "velero.io/schedule-name=" + name})
|
||||
if err != nil {
|
||||
return fmt.Errorf("liste des sauvegardes de %s: %w", namespace, err)
|
||||
}
|
||||
for _, b := range list.Items {
|
||||
err := h.dynamicClient.Resource(veleroBackupGVR).Namespace(veleroNamespace).
|
||||
Delete(context.Background(), b.GetName(), metav1.DeleteOptions{})
|
||||
if err != nil && !k8sErrors.IsNotFound(err) {
|
||||
return fmt.Errorf("suppression sauvegarde %s: %w", b.GetName(), err)
|
||||
}
|
||||
}
|
||||
|
||||
err = h.dynamicClient.Resource(veleroScheduleGVR).Namespace(veleroNamespace).
|
||||
Delete(context.Background(), name, metav1.DeleteOptions{})
|
||||
if err != nil && !k8sErrors.IsNotFound(err) {
|
||||
return fmt.Errorf("suppression planning %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// installChart installe (première install, jamais réappliqué) un chart Helm
|
||||
// avec des valeurs personnalisées. Release name : "<namespace>-<chartName>".
|
||||
// Adapté aux charts par démo (namespace toujours neuf).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package demos
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
@@ -282,6 +283,45 @@ func (s *Service) Extend(id string) (Demo, error) {
|
||||
return s.store.Update(d)
|
||||
}
|
||||
|
||||
// RunExpirationLoop détruit automatiquement les démos dont le TTL est
|
||||
// dépassé, à intervalle régulier — jusqu'ici rien n'appelait Delete() sur
|
||||
// une démo expirée, le compte à rebours affiché en interface n'avait aucun
|
||||
// effet réel. À lancer une fois dans une goroutine au démarrage de l'API
|
||||
// (voir cmd/api/main.go), s'arrête proprement quand ctx est annulé (extinction).
|
||||
// Les démos premium n'expirent jamais en pratique (voir TransferToPaid,
|
||||
// ExpiresAt repoussé de 10 ans) : jamais concernées ici.
|
||||
func (s *Service) RunExpirationLoop(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.expireOverdueDemos()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) expireOverdueDemos() {
|
||||
all, err := s.store.List()
|
||||
if err != nil {
|
||||
log.Printf("expiration: liste des démos échouée: %v", err)
|
||||
return
|
||||
}
|
||||
now := s.now().UTC()
|
||||
for _, d := range all {
|
||||
if !d.Status.Active() || !d.ExpiresAt.Before(now) {
|
||||
continue
|
||||
}
|
||||
if _, err := s.Delete(d.ID); err != nil {
|
||||
log.Printf("expiration: destruction de %s (TTL dépassé) échouée: %v", d.Namespace, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("expiration: démo %s détruite (TTL dépassé)", d.Namespace)
|
||||
}
|
||||
}
|
||||
|
||||
// shortID : 8 premiers caractères hex d'un uuid pour un nom de namespace court.
|
||||
func shortID(id string) string {
|
||||
clean := ""
|
||||
|
||||
Reference in New Issue
Block a user