chore: build
ci-api / test (push) Successful in 26m18s

This commit is contained in:
Xor290
2026-08-18 16:37:00 +02:00
parent 955f2325fb
commit aa1aa60f00
8 changed files with 189 additions and 4 deletions
+6
View File
@@ -160,6 +160,12 @@ func main() {
TTL: demos.TTL, 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 // Alerting : surveille les pods des démos actives (état + seuils
// CPU/mémoire/PVC), notifie Discord et/ou Telegram de chaque admin ayant // CPU/mémoire/PVC), notifie Discord et/ou Telegram de chaque admin ayant
// configuré son propre canal (voir profil admin, /profile/alerts). // configuré son propre canal (voir profil admin, /profile/alerts).
@@ -22,7 +22,10 @@ import (
rbacv1 "k8s.io/api/rbac/v1" rbacv1 "k8s.io/api/rbac/v1"
k8sErrors "k8s.io/apimachinery/pkg/api/errors" k8sErrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 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/apimachinery/pkg/types"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest" "k8s.io/client-go/rest"
@@ -37,6 +40,7 @@ type HelmProvisioner struct {
k8sClient *kubernetes.Clientset k8sClient *kubernetes.Clientset
restConfig *rest.Config // pour l'exec dans les pods (pg_dump/restore) restConfig *rest.Config // pour l'exec dans les pods (pg_dump/restore)
metricsClient *metricsclient.Clientset // optionnel : nil si metrics-server indisponible 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) chartsDir string // chemin vers le dossier des charts (ex: /charts)
frontendImage string frontendImage string
backendImage string backendImage string
@@ -69,11 +73,17 @@ func NewHelmProvisioner(
return nil, fmt.Errorf("helm exécutable non trouvé à %s: %w", helmPath, err) 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{ return &HelmProvisioner{
cfg: cfg, cfg: cfg,
k8sClient: k8sClient, k8sClient: k8sClient,
restConfig: restConfig, restConfig: restConfig,
metricsClient: metricsClient, metricsClient: metricsClient,
dynamicClient: dynamicClient,
chartsDir: chartsDir, chartsDir: chartsDir,
frontendImage: cfg.FrontendImage, frontendImage: cfg.FrontendImage,
backendImage: cfg.BackendImage, 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) 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 // Isolation réseau AVANT tout workload : deny-by-default, aucune
// communication inter-démos, ingress limité au Traefik partagé (voir // communication inter-démos, ingress limité au Traefik partagé (voir
// deploy/chart-gestion/network-policy). Sans ça, tout pod d'une démo // 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 // 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 { 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) 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) 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. // Isolation réseau AVANT tout workload, comme pour Provision.
if err := h.installChart(newNamespace, "network-policy", h.buildNetworkPolicyValues()); err != nil { if err := h.installChart(newNamespace, "network-policy", h.buildNetworkPolicyValues()); err != nil {
h.deleteNamespace(newNamespace) 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) 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 { 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) 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 // installChart installe (première install, jamais réappliqué) un chart Helm
// avec des valeurs personnalisées. Release name : "<namespace>-<chartName>". // avec des valeurs personnalisées. Release name : "<namespace>-<chartName>".
// Adapté aux charts par démo (namespace toujours neuf). // Adapté aux charts par démo (namespace toujours neuf).
@@ -1,6 +1,7 @@
package demos package demos
import ( import (
"context"
"errors" "errors"
"log" "log"
"strings" "strings"
@@ -282,6 +283,45 @@ func (s *Service) Extend(id string) (Demo, error) {
return s.store.Update(d) 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. // shortID : 8 premiers caractères hex d'un uuid pour un nom de namespace court.
func shortID(id string) string { func shortID(id string) string {
clean := "" clean := ""
View File
+10 -2
View File
@@ -77,13 +77,21 @@ services:
LBTELEGRAM_IMAGE_APP: ${LBTELEGRAM_IMAGE_APP:-xor1234/lbtelegram:helm} LBTELEGRAM_IMAGE_APP: ${LBTELEGRAM_IMAGE_APP:-xor1234/lbtelegram:helm}
KUBECONFIG: /kubeconfig/config KUBECONFIG: /kubeconfig/config
OMNEX_APP_DOWNLOADS_DIR: /app-downloads 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: volumes:
- ../deploy/chart-gestion:/charts:ro - ../deploy/chart-gestion:/charts:ro
- ${KUBECONFIG_HOST_PATH:-/home/xor_fakers/.kube/config}:/kubeconfig/config:ro - ${KUBECONFIG_HOST_PATH:-/home/xor_fakers/.kube/config}:/kubeconfig/config:ro
# Dossier où déposer manuellement les .apk (Admin Panel / Client) à # Dossier où déposer manuellement les .apk (Admin Panel / Client) à
# rendre téléchargeables aux clients ayant une démo ou un abonnement # rendre téléchargeables aux clients ayant une démo ou un abonnement
# actif — voir control-plane/api/internal/downloads. # actif — voir control-plane/api/internal/downloads. Vit dans
- ../app:/app-downloads:ro # 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: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"] test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
interval: 5s interval: 5s
+6 -1
View File
@@ -47,7 +47,12 @@ server {
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" 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 <style> au runtime (CSS-in-JS)
# d'où "style-src 'unsafe-inline'". Pas de script inline dans
# web/index.html (un seul <script type="module" src="/src/main.tsx">),
# donc script-src reste strict à 'self'.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
# --------------------------------------------------- # ---------------------------------------------------
# Limites et timeouts # Limites et timeouts