diff --git a/control-plane/api/cmd/api/main.go b/control-plane/api/cmd/api/main.go index e70d731..8a4fa7e 100644 --- a/control-plane/api/cmd/api/main.go +++ b/control-plane/api/cmd/api/main.go @@ -160,6 +160,12 @@ func main() { TTL: demos.TTL, }) + // Détruit automatiquement les démos dont le TTL est dépassé — sans ça, + // le compte à rebours affiché en interface n'a aucun effet réel (voir + // Service.RunExpirationLoop). Fire-and-forget comme les autres goroutines + // de ce fichier : pas de contexte annulable, s'arrête avec le processus. + go demoSvc.RunExpirationLoop(context.Background(), 5*time.Minute) + // Alerting : surveille les pods des démos actives (état + seuils // CPU/mémoire/PVC), notifie Discord et/ou Telegram de chaque admin ayant // configuré son propre canal (voir profil admin, /profile/alerts). diff --git a/control-plane/api/internal/demos/helm_provisioner.go b/control-plane/api/internal/demos/helm_provisioner.go index a0d880a..02f766c 100644 --- a/control-plane/api/internal/demos/helm_provisioner.go +++ b/control-plane/api/internal/demos/helm_provisioner.go @@ -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 : "-". // Adapté aux charts par démo (namespace toujours neuf). diff --git a/control-plane/api/internal/demos/service.go b/control-plane/api/internal/demos/service.go index 29f494d..e1df01f 100644 --- a/control-plane/api/internal/demos/service.go +++ b/control-plane/api/internal/demos/service.go @@ -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 := "" diff --git a/docker/app/.gitkeep b/docker/app/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker/app/omnex-plateform-app-production-20260809-1336.apk b/docker/app/omnex-plateform-app-production-20260809-1336.apk new file mode 100644 index 0000000..72a8b70 Binary files /dev/null and b/docker/app/omnex-plateform-app-production-20260809-1336.apk differ diff --git a/docker/app/omnex-plateform-client-production-20260809-1538.apk b/docker/app/omnex-plateform-client-production-20260809-1538.apk new file mode 100644 index 0000000..479a537 Binary files /dev/null and b/docker/app/omnex-plateform-client-production-20260809-1538.apk differ diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4429c79..f914403 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -77,13 +77,21 @@ services: LBTELEGRAM_IMAGE_APP: ${LBTELEGRAM_IMAGE_APP:-xor1234/lbtelegram:helm} KUBECONFIG: /kubeconfig/config OMNEX_APP_DOWNLOADS_DIR: /app-downloads + # Compte Docker Hub authentifié (lecture seule) injecté dans chaque + # namespace de démo — passe le rate-limit de pull anonyme (100/6h par + # IP) à 200/6h. Vide = pull anonyme (comportement inchangé). + OMNEX_DOCKERHUB_USERNAME: ${OMNEX_DOCKERHUB_USERNAME:-} + OMNEX_DOCKERHUB_PASSWORD: ${OMNEX_DOCKERHUB_PASSWORD:-} volumes: - ../deploy/chart-gestion:/charts:ro - ${KUBECONFIG_HOST_PATH:-/home/xor_fakers/.kube/config}:/kubeconfig/config:ro # Dossier où déposer manuellement les .apk (Admin Panel / Client) à # rendre téléchargeables aux clients ayant une démo ou un abonnement - # actif — voir control-plane/api/internal/downloads. - - ../app:/app-downloads:ro + # actif — voir control-plane/api/internal/downloads. Vit dans + # docker/ (comme waf/, nginx/, certs/) : sur le serveur, seuls + # docker-compose.yml et waf/ sont synchronisés par Ansible, le + # reste (dont app/) est géré manuellement dans docker_dir. + - ./app:/app-downloads:ro healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"] interval: 5s diff --git a/docker/waf/nginx.conf b/docker/waf/nginx.conf index 1007dae..8027f40 100644 --- a/docker/waf/nginx.conf +++ b/docker/waf/nginx.conf @@ -47,7 +47,12 @@ server { add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "no-referrer" always; - add_header Content-Security-Policy "default-src 'none'; frame-ancestors 'none'" always; + # Le SPA (Vite/React) est servi en même origine (proxifié ci-dessous) et + # utilise Chakra UI/Emotion, qui injecte des