chore: build
ci-api / test (push) Failing after 14m25s
ci-web / test (push) Failing after 8m17s

This commit is contained in:
Xor290
2026-09-20 16:14:29 +02:00
parent 76b1412968
commit 9e11f01c2c
15 changed files with 65 additions and 359 deletions
+4 -6
View File
@@ -136,7 +136,7 @@ func main() {
metricsClient = nil metricsClient = nil
} }
helmProv, err := demos.NewHelmProvisioner(&cfg, k8sClient, restConfig, metricsClient, "/charts", "helm") helmProv, err := demos.NewHelmProvisioner(&cfg, k8sClient, restConfig, metricsClient, "/charts", "/charts-vitrine", "helm")
if err != nil { if err != nil {
log.Fatalf("helm provisioner: %v", err) log.Fatalf("helm provisioner: %v", err)
} }
@@ -169,11 +169,10 @@ func main() {
go demoSvc.RunExpirationLoop(context.Background(), 5*time.Minute) go demoSvc.RunExpirationLoop(context.Background(), 5*time.Minute)
// Projets vitrine (abonnements de 1 à 12 mois) : même cluster, mêmes // Projets vitrine (abonnements de 1 à 12 mois) : même cluster, mêmes
// sauvegardes Velero que les démos, mais suspension puis suppression après // sauvegardes Velero que les démos, mais durée choisie au déploiement puis
// un délai de grâce à l'échéance (voir internal/projects). // prolongée ; supprimés à l'échéance (voir internal/projects).
projectSvc := projects.NewService(projectStore, helmProv, projects.Config{ projectSvc := projects.NewService(projectStore, helmProv, projects.Config{
Domain: cfg.VitrineDomain, Domain: cfg.VitrineDomain,
Grace: time.Duration(cfg.VitrineGraceDays) * 24 * time.Hour,
}) })
go projectSvc.RunExpirationLoop(context.Background(), 5*time.Minute) go projectSvc.RunExpirationLoop(context.Background(), 5*time.Minute)
@@ -193,8 +192,7 @@ func main() {
ns = append(ns, d.Namespace) ns = append(ns, d.Namespace)
} }
} }
// Projets vitrine en ligne (un projet suspendu n'a plus de pods : // Projets vitrine en ligne.
// l'alerte « pod absent » serait un faux positif).
ps, err := projectSvc.List() ps, err := projectSvc.List()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -3,7 +3,6 @@ package config
import ( import (
"errors" "errors"
"os" "os"
"strconv"
"time" "time"
) )
@@ -31,13 +30,8 @@ type Config struct {
// Projets vitrine (abonnements de 1 à 12 mois, voir internal/projects). // Projets vitrine (abonnements de 1 à 12 mois, voir internal/projects).
VitrineDomain string // OMNEX_VITRINE_DOMAIN : les projets sont servis sur <namespace>.<domaine> VitrineDomain string // OMNEX_VITRINE_DOMAIN : les projets sont servis sur <namespace>.<domaine>
VitrineChartsDir string // OMNEX_VITRINE_CHARTS_DIR : charts backend/frontend/postgresql/redis/ingressroute du projet vitrine
VitrineBackendImage string // VITRINE_BACKEND_IMAGE, ex. registry/vitrine-backend:1.0.0 VitrineBackendImage string // VITRINE_BACKEND_IMAGE, ex. registry/vitrine-backend:1.0.0
VitrineFrontendImage string // VITRINE_FRONTEND_IMAGE VitrineFrontendImage string // VITRINE_FRONTEND_IMAGE
// VitrineGraceDays : jours pendant lesquels un projet dont l'abonnement
// a expiré reste suspendu (données et sauvegardes conservées) avant sa
// suppression définitive. Un renouvellement pendant ce délai le relance.
VitrineGraceDays int
} }
// Load lit la config. Fail-secure : secret JWT obligatoire ; en prod base + Redis aussi. // Load lit la config. Fail-secure : secret JWT obligatoire ; en prod base + Redis aussi.
@@ -66,17 +60,8 @@ func Load() (Config, error) {
DockerHubPassword: os.Getenv("OMNEX_DOCKERHUB_PASSWORD"), DockerHubPassword: os.Getenv("OMNEX_DOCKERHUB_PASSWORD"),
VitrineDomain: getenv("OMNEX_VITRINE_DOMAIN", "vitrine-omnex.club"), VitrineDomain: getenv("OMNEX_VITRINE_DOMAIN", "vitrine-omnex.club"),
VitrineChartsDir: getenv("OMNEX_VITRINE_CHARTS_DIR", "/charts-vitrine"),
VitrineBackendImage: os.Getenv("VITRINE_BACKEND_IMAGE"), VitrineBackendImage: os.Getenv("VITRINE_BACKEND_IMAGE"),
VitrineFrontendImage: os.Getenv("VITRINE_FRONTEND_IMAGE"), VitrineFrontendImage: os.Getenv("VITRINE_FRONTEND_IMAGE"),
VitrineGraceDays: 7,
}
if v := os.Getenv("OMNEX_VITRINE_GRACE_DAYS"); v != "" {
days, err := strconv.Atoi(v)
if err != nil || days < 0 || days > 365 {
return Config{}, errors.New("OMNEX_VITRINE_GRACE_DAYS doit être un entier entre 0 et 365")
}
cfg.VitrineGraceDays = days
} }
if cfg.Env == "prod" { if cfg.Env == "prod" {
@@ -107,21 +107,7 @@ func TestLoad_VitrineDefaults(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if cfg.VitrineDomain != "vitrine-omnex.club" || cfg.VitrineGraceDays != 7 || cfg.VitrineChartsDir != "/charts-vitrine" { if cfg.VitrineDomain != "vitrine-omnex.club" {
t.Errorf("défauts vitrine inattendus : %+v", cfg) t.Errorf("défauts vitrine inattendus : %+v", cfg)
} }
} }
func TestLoad_VitrineGraceDaysInvalid(t *testing.T) {
t.Setenv("OMNEX_JWT_SECRET", "0123456789abcdef0123456789abcdef")
for _, v := range []string{"abc", "-1", "366"} {
t.Setenv("OMNEX_VITRINE_GRACE_DAYS", v)
if _, err := Load(); err == nil {
t.Errorf("OMNEX_VITRINE_GRACE_DAYS=%q accepté", v)
}
}
t.Setenv("OMNEX_VITRINE_GRACE_DAYS", "0")
if cfg, err := Load(); err != nil || cfg.VitrineGraceDays != 0 {
t.Errorf("grâce de 0 jour refusée ou mal lue : %v %d", err, cfg.VitrineGraceDays)
}
}
@@ -55,6 +55,7 @@ type HelmProvisioner struct {
// NewHelmProvisioner crée un nouveau provisioner Helm. // NewHelmProvisioner crée un nouveau provisioner Helm.
// chartsDir : chemin absolu vers le dossier contenant les charts (backend/, frontend/) // chartsDir : chemin absolu vers le dossier contenant les charts (backend/, frontend/)
// vitrineChartsDir : idem pour les charts des projets vitrine (voir internal/projects)
// helmPath : chemin vers l'exécutable helm (optionnel, default: "helm") // helmPath : chemin vers l'exécutable helm (optionnel, default: "helm")
// metricsClient : client metrics.k8s.io pour l'usage CPU/mémoire live (optionnel, peut être nil). // metricsClient : client metrics.k8s.io pour l'usage CPU/mémoire live (optionnel, peut être nil).
func NewHelmProvisioner( func NewHelmProvisioner(
@@ -63,6 +64,7 @@ func NewHelmProvisioner(
restConfig *rest.Config, restConfig *rest.Config,
metricsClient *metricsclient.Clientset, metricsClient *metricsclient.Clientset,
chartsDir string, chartsDir string,
vitrineChartsDir string,
helmPath string, helmPath string,
) (*HelmProvisioner, error) { ) (*HelmProvisioner, error) {
if chartsDir == "" { if chartsDir == "" {
@@ -89,7 +91,7 @@ func NewHelmProvisioner(
metricsClient: metricsClient, metricsClient: metricsClient,
dynamicClient: dynamicClient, dynamicClient: dynamicClient,
chartsDir: chartsDir, chartsDir: chartsDir,
vitrineChartsDir: cfg.VitrineChartsDir, vitrineChartsDir: vitrineChartsDir,
frontendImage: cfg.FrontendImage, frontendImage: cfg.FrontendImage,
backendImage: cfg.BackendImage, backendImage: cfg.BackendImage,
lbtelegramImage: cfg.LBTelegramImage, lbtelegramImage: cfg.LBTelegramImage,
@@ -1,19 +1,16 @@
package demos package demos
import ( import (
"context"
"fmt" "fmt"
"log" "log"
"strconv" "strconv"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
) )
// Projets vitrine (voir internal/projects) : même infrastructure que les // Projets vitrine (voir internal/projects) : même infrastructure que les
// démos (namespace isolé, NetworkPolicy, ResourceQuota, Traefik + WAF // démos (namespace isolé, NetworkPolicy, ResourceQuota, Traefik + WAF
// partagés, sauvegardes Velero par namespace) mais avec les charts du // partagés, sauvegardes Velero par namespace) mais avec les charts du
// produit vitrine (h.vitrineChartsDir) et une durée d'abonnement en mois. // produit vitrine (h.vitrineChartsDir) et une durée d'abonnement en mois.
// Le cycle de vie (durée, suspension, suppression) vit dans internal/projects ; // Le cycle de vie (durée, prolongation, suppression) vit dans internal/projects ;
// ce fichier ne contient que ce qui touche au cluster. // ce fichier ne contient que ce qui touche au cluster.
// Noms de release/chart utilisés par les charts vitrine (dossier // Noms de release/chart utilisés par les charts vitrine (dossier
@@ -29,10 +26,6 @@ func vitrineFrontendService(namespace string) string {
return namespace + "-frontend-vitrine-frontend" 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 // ProjectSpec : ce dont le provisioner a besoin pour déployer un projet
// vitrine. AdminPassword ne doit jamais être persisté : il ne transite que // vitrine. AdminPassword ne doit jamais être persisté : il ne transite que
// par le fichier de valeurs Helm temporaire puis le Secret k8s du chart. // par le fichier de valeurs Helm temporaire puis le Secret k8s du chart.
@@ -128,92 +121,6 @@ func (h *HelmProvisioner) TeardownProject(namespace string) error {
return h.Teardown(Demo{Namespace: namespace}) 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{} { func (h *HelmProvisioner) buildVitrinePostgresValues(password string) map[string]interface{} {
// Nom de base et utilisateur : défauts du chart (vitrine_db / postgres), // Nom de base et utilisateur : défauts du chart (vitrine_db / postgres),
// alignés sur database.name / database.user du chart backend. // alignés sur database.name / database.user du chart backend.
@@ -14,55 +14,6 @@ import (
func ptr[T any](v T) *T { return &v } 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) { func TestVitrineValuesRenderWithCharts(t *testing.T) {
if _, err := exec.LookPath("helm"); err != nil { if _, err := exec.LookPath("helm"); err != nil {
t.Skip("helm absent du PATH") t.Skip("helm absent du PATH")
@@ -82,7 +82,7 @@ func (h *Handler) Get(c *gin.Context) {
} }
// Extend : POST /projects/:id/extend {"months": 1..12} — renouvelle // Extend : POST /projects/:id/extend {"months": 1..12} — renouvelle
// l'abonnement (relance un projet suspendu). // l'abonnement.
func (h *Handler) Extend(c *gin.Context) { func (h *Handler) Extend(c *gin.Context) {
var req extendRequest var req extendRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
+8 -13
View File
@@ -1,11 +1,11 @@
// Package projects : cycle de vie des projets vitrine déployés pour un client // Package projects : cycle de vie des projets vitrine déployés pour un client
// sur abonnement de 1 à 12 mois (un projet = un namespace isolé). // sur abonnement de 1 à 12 mois (un projet = un namespace isolé).
// //
// Contrairement aux démos (TTL fixe de 30 jours, détruites à l'échéance), un // Contrairement aux démos (TTL fixe de 30 jours), la durée d'un projet payé est
// projet payé est suspendu à l'échéance puis supprimé seulement après un // choisie au déploiement puis prolongée à chaque renouvellement ; à l'échéance
// délai de grâce : un renouvellement en retard ne détruit pas les données du // il est supprimé, sans délai de grâce. Le paiement est hors périmètre :
// client. Le paiement est hors périmètre : l'admin Omnex saisit la durée // l'admin Omnex saisit la durée souscrite au déploiement puis à chaque
// souscrite au déploiement puis à chaque renouvellement. // renouvellement.
package projects package projects
import "time" import "time"
@@ -26,16 +26,15 @@ const (
StatusPending Status = "pending" // créé, pas encore pris par le worker StatusPending Status = "pending" // créé, pas encore pris par le worker
StatusProvisioning Status = "provisioning" // déploiement en cours StatusProvisioning Status = "provisioning" // déploiement en cours
StatusReady Status = "ready" // en ligne StatusReady Status = "ready" // en ligne
StatusSuspended Status = "suspended" // abonnement échu : arrêté, données conservées jusqu'à DeleteAt
StatusDeleting Status = "deleting" // suppression en cours StatusDeleting Status = "deleting" // suppression en cours
StatusDeleted Status = "deleted" // supprimé StatusDeleted Status = "deleted" // supprimé
StatusFailed Status = "failed" // échec du déploiement StatusFailed Status = "failed" // échec du déploiement
) )
// Extendable : un abonnement peut être prolongé (et un projet suspendu relancé). // Extendable : un abonnement peut être prolongé.
func (s Status) Extendable() bool { func (s Status) Extendable() bool {
switch s { switch s {
case StatusProvisioning, StatusReady, StatusSuspended: case StatusProvisioning, StatusReady:
return true return true
} }
return false return false
@@ -58,12 +57,8 @@ type Project struct {
MonthsPurchased int `json:"months_purchased"` MonthsPurchased int `json:"months_purchased"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
// ExpiresAt : fin de l'abonnement (le projet est alors suspendu). // ExpiresAt : fin de l'abonnement (le projet est alors supprimé).
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
// DeleteAt : suppression définitive si l'abonnement n'est pas renouvelé
// (ExpiresAt + délai de grâce).
DeleteAt time.Time `json:"delete_at"`
SuspendedAt *time.Time `json:"suspended_at,omitempty"`
} }
func (Project) TableName() string { return "projects" } func (Project) TableName() string { return "projects" }
+8 -44
View File
@@ -31,8 +31,6 @@ var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9]{3,64}$`)
type Provisioner interface { type Provisioner interface {
ProvisionProject(spec demos.ProjectSpec) error ProvisionProject(spec demos.ProjectSpec) error
TeardownProject(namespace string) error TeardownProject(namespace string) error
SuspendProject(namespace string) error
ResumeProject(namespace string) error
} }
// NoopProvisioner : aucun déploiement réel (dev en mémoire, tests). // NoopProvisioner : aucun déploiement réel (dev en mémoire, tests).
@@ -40,13 +38,10 @@ type NoopProvisioner struct{}
func (NoopProvisioner) ProvisionProject(demos.ProjectSpec) error { return nil } func (NoopProvisioner) ProvisionProject(demos.ProjectSpec) error { return nil }
func (NoopProvisioner) TeardownProject(string) error { return nil } func (NoopProvisioner) TeardownProject(string) error { return nil }
func (NoopProvisioner) SuspendProject(string) error { return nil }
func (NoopProvisioner) ResumeProject(string) error { return nil }
// Config du service (injectable pour les tests). // Config du service (injectable pour les tests).
type Config struct { type Config struct {
Domain string // domaine des projets : <namespace>.<Domain> Domain string // domaine des projets : <namespace>.<Domain>
Grace time.Duration // délai entre l'échéance (suspension) et la suppression
} }
// CreateInput : saisie de l'admin Omnex au déploiement (dashboard). // CreateInput : saisie de l'admin Omnex au déploiement (dashboard).
@@ -135,7 +130,6 @@ func (s *Service) Create(in CreateInput) (Project, error) {
MonthsPurchased: in.Months, MonthsPurchased: in.Months,
CreatedAt: now, CreatedAt: now,
ExpiresAt: expires, ExpiresAt: expires,
DeleteAt: expires.Add(s.cfg.Grace),
} }
created, err := s.store.Create(p) created, err := s.store.Create(p)
if err != nil { if err != nil {
@@ -183,7 +177,7 @@ func (s *Service) Get(id string) (Project, error) {
func (s *Service) List() ([]Project, error) { return s.store.List() } func (s *Service) List() ([]Project, error) { return s.store.List() }
// Extend ajoute months mois à l'abonnement, à partir de l'échéance courante // Extend ajoute months mois à l'abonnement, à partir de l'échéance courante
// (ou de maintenant si elle est dépassée). Un projet suspendu est relancé. // (ou de maintenant si elle est dépassée).
func (s *Service) Extend(id string, months int) (Project, error) { func (s *Service) Extend(id string, months int) (Project, error) {
if err := ValidateMonths(months); err != nil { if err := ValidateMonths(months); err != nil {
return Project{}, err return Project{}, err
@@ -205,21 +199,13 @@ func (s *Service) Extend(id string, months int) (Project, error) {
base = now base = now
} }
if p.Status == StatusSuspended {
if err := s.prov.ResumeProject(p.Namespace); err != nil {
return Project{}, err
}
p.Status = StatusReady
p.SuspendedAt = nil
}
p.ExpiresAt = base.AddDate(0, months, 0) p.ExpiresAt = base.AddDate(0, months, 0)
p.DeleteAt = p.ExpiresAt.Add(s.cfg.Grace)
p.MonthsPurchased += months p.MonthsPurchased += months
return s.store.Update(p) return s.store.Update(p)
} }
// Delete détruit le projet (namespace, données, sauvegardes) sans attendre le // Delete détruit le projet (namespace, données, sauvegardes) sans attendre
// délai de grâce — action explicite de l'admin. // l'échéance — action explicite de l'admin.
func (s *Service) Delete(id string) (Project, error) { func (s *Service) Delete(id string) (Project, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -246,7 +232,7 @@ func (s *Service) deleteLocked(id string) (Project, error) {
} }
// RunExpirationLoop applique le cycle de vie à intervalle régulier jusqu'à // RunExpirationLoop applique le cycle de vie à intervalle régulier jusqu'à
// l'annulation de ctx : suspension à l'échéance, suppression à DeleteAt. // l'annulation de ctx : suppression des projets dont l'abonnement est échu.
func (s *Service) RunExpirationLoop(ctx context.Context, interval time.Duration) { func (s *Service) RunExpirationLoop(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
@@ -273,40 +259,18 @@ func (s *Service) expireOverdue() {
} }
} }
// applyLifecycle relit le projet (il a pu changer depuis List) et applique la // applyLifecycle relit le projet (il a pu changer depuis List) et le supprime
// transition due. À appeler avec s.mu tenu. // si son abonnement est échu. À appeler avec s.mu tenu.
func (s *Service) applyLifecycle(id string) { func (s *Service) applyLifecycle(id string) {
p, ok := s.store.Get(id) p, ok := s.store.Get(id)
if !ok { if !ok || p.Status != StatusReady || p.ExpiresAt.After(s.now().UTC()) {
return
}
now := s.now().UTC()
switch p.Status {
case StatusReady:
if p.ExpiresAt.After(now) {
return
}
if err := s.prov.SuspendProject(p.Namespace); err != nil {
log.Printf("projets: suspension de %s échouée: %v", p.Namespace, err)
return
}
p.Status = StatusSuspended
p.SuspendedAt = &now
if _, err := s.store.Update(p); err != nil {
log.Printf("projets: enregistrement de la suspension de %s échoué: %v", p.Namespace, err)
return
}
log.Printf("projets: %s suspendu (abonnement échu, suppression le %s)", p.Namespace, p.DeleteAt.Format(time.RFC3339))
case StatusSuspended:
if p.DeleteAt.After(now) {
return return
} }
if _, err := s.deleteLocked(p.ID); err != nil { if _, err := s.deleteLocked(p.ID); err != nil {
log.Printf("projets: suppression de %s échouée: %v", p.Namespace, err) log.Printf("projets: suppression de %s échouée: %v", p.Namespace, err)
return return
} }
log.Printf("projets: %s supprimé (délai de grâce dépassé)", p.Namespace) log.Printf("projets: %s supprimé (abonnement échu)", p.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.
@@ -14,11 +14,8 @@ import (
type fakeProv struct { type fakeProv struct {
mu sync.Mutex mu sync.Mutex
specs []demos.ProjectSpec specs []demos.ProjectSpec
suspended []string
resumed []string
tornDown []string tornDown []string
provErr error provErr error
resumeErr error
provisioned chan struct{} provisioned chan struct{}
} }
@@ -37,25 +34,13 @@ func (f *fakeProv) TeardownProject(ns string) error {
f.tornDown = append(f.tornDown, ns) f.tornDown = append(f.tornDown, ns)
return nil return nil
} }
func (f *fakeProv) SuspendProject(ns string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.suspended = append(f.suspended, ns)
return nil
}
func (f *fakeProv) ResumeProject(ns string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.resumed = append(f.resumed, ns)
return f.resumeErr
}
var t0 = time.Date(2026, time.September, 20, 12, 0, 0, 0, time.UTC) var t0 = time.Date(2026, time.September, 20, 12, 0, 0, 0, time.UTC)
func newTestService(t *testing.T) (*Service, *fakeProv, *time.Time) { func newTestService(t *testing.T) (*Service, *fakeProv, *time.Time) {
t.Helper() t.Helper()
prov := newFakeProv() prov := newFakeProv()
svc := NewService(NewMemStore(), prov, Config{Domain: "vitrine-omnex.club", Grace: 7 * 24 * time.Hour}) svc := NewService(NewMemStore(), prov, Config{Domain: "vitrine-omnex.club"})
now := t0 now := t0
svc.now = func() time.Time { return now } svc.now = func() time.Time { return now }
return svc, prov, &now return svc, prov, &now
@@ -109,9 +94,6 @@ func TestCreate_ExpiryIsCalendarMonths(t *testing.T) {
if !p.ExpiresAt.Equal(want) { if !p.ExpiresAt.Equal(want) {
t.Errorf("months=%d : ExpiresAt=%v, attendu %v", months, p.ExpiresAt, want) t.Errorf("months=%d : ExpiresAt=%v, attendu %v", months, p.ExpiresAt, want)
} }
if !p.DeleteAt.Equal(want.Add(7 * 24 * time.Hour)) {
t.Errorf("months=%d : DeleteAt=%v (grâce de 7 jours attendue)", months, p.DeleteAt)
}
if p.MonthsPurchased != months { if p.MonthsPurchased != months {
t.Errorf("MonthsPurchased=%d, attendu %d", p.MonthsPurchased, months) t.Errorf("MonthsPurchased=%d, attendu %d", p.MonthsPurchased, months)
} }
@@ -196,9 +178,6 @@ func TestExtend_AddsMonthsFromCurrentExpiry(t *testing.T) {
if got.MonthsPurchased != 15 { if got.MonthsPurchased != 15 {
t.Errorf("MonthsPurchased=%d, attendu 15", got.MonthsPurchased) t.Errorf("MonthsPurchased=%d, attendu 15", got.MonthsPurchased)
} }
if !got.DeleteAt.Equal(want.Add(7 * 24 * time.Hour)) {
t.Errorf("DeleteAt non recalculé : %v", got.DeleteAt)
}
} }
func TestExtend_MonthsBounds(t *testing.T) { func TestExtend_MonthsBounds(t *testing.T) {
@@ -222,89 +201,61 @@ func TestExtend_UnknownProject(t *testing.T) {
} }
} }
func TestExpiration_SuspendsAtExpiryThenDeletesAfterGrace(t *testing.T) { func TestExpiration_DeletesAtExpiry(t *testing.T) {
svc, prov, now := newTestService(t) svc, prov, now := newTestService(t)
p := createReady(t, svc, prov, validInput()) // 3 mois p := createReady(t, svc, prov, validInput()) // 3 mois
// Avant l'échéance : rien ne bouge. // Avant l'échéance : rien ne bouge.
*now = p.ExpiresAt.Add(-time.Hour) *now = p.ExpiresAt.Add(-time.Hour)
svc.expireOverdue() svc.expireOverdue()
if len(prov.suspended) != 0 { if len(prov.tornDown) != 0 {
t.Fatal("suspension avant l'échéance") t.Fatal("suppression avant l'échéance")
} }
// À l'échéance : suspendu, jamais supprimé. // À l'échéance : supprimé, sans délai de grâce.
*now = p.ExpiresAt.Add(time.Minute) *now = p.ExpiresAt.Add(time.Minute)
svc.expireOverdue() svc.expireOverdue()
got, _ := svc.Get(p.ID) got, _ := svc.Get(p.ID)
if got.Status != StatusSuspended || got.SuspendedAt == nil {
t.Fatalf("statut %s, attendu suspended", got.Status)
}
if len(prov.suspended) != 1 || len(prov.tornDown) != 0 {
t.Fatalf("suspendus=%v supprimés=%v", prov.suspended, prov.tornDown)
}
// Pendant la grâce : toujours suspendu, données conservées.
*now = p.ExpiresAt.Add(6 * 24 * time.Hour)
svc.expireOverdue()
if len(prov.tornDown) != 0 {
t.Fatal("suppression pendant le délai de grâce")
}
if len(prov.suspended) != 1 {
t.Errorf("suspension répétée : %v", prov.suspended)
}
// Grâce dépassée : suppression définitive.
*now = p.DeleteAt.Add(time.Minute)
svc.expireOverdue()
got, _ = svc.Get(p.ID)
if got.Status != StatusDeleted || len(prov.tornDown) != 1 || prov.tornDown[0] != p.Namespace { if got.Status != StatusDeleted || len(prov.tornDown) != 1 || prov.tornDown[0] != p.Namespace {
t.Errorf("statut %s, supprimés=%v", got.Status, prov.tornDown) t.Errorf("statut %s, supprimés=%v", got.Status, prov.tornDown)
} }
// Déjà supprimé : la boucle ne rappelle pas le cluster.
svc.expireOverdue()
if len(prov.tornDown) != 1 {
t.Errorf("teardown répété : %v", prov.tornDown)
}
} }
func TestExtend_ResumesSuspendedProject(t *testing.T) { func TestExtend_BeforeExpiryKeepsProject(t *testing.T) {
svc, prov, now := newTestService(t) svc, prov, now := newTestService(t)
p := createReady(t, svc, prov, validInput()) p := createReady(t, svc, prov, validInput())
*now = p.ExpiresAt.Add(2 * 24 * time.Hour) *now = p.ExpiresAt.Add(-time.Hour)
svc.expireOverdue()
got, err := svc.Extend(p.ID, 1) got, err := svc.Extend(p.ID, 1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got.Status != StatusReady || got.SuspendedAt != nil { if want := p.ExpiresAt.AddDate(0, 1, 0); !got.ExpiresAt.Equal(want) {
t.Errorf("statut %s suspended_at=%v, attendu ready sans suspension", got.Status, got.SuspendedAt)
}
if len(prov.resumed) != 1 || prov.resumed[0] != p.Namespace {
t.Errorf("relances=%v", prov.resumed)
}
// Échéance en retard : le nouveau mois part de maintenant, pas de l'ancienne échéance.
if want := now.AddDate(0, 1, 0); !got.ExpiresAt.Equal(want) {
t.Errorf("ExpiresAt=%v, attendu %v", got.ExpiresAt, want) t.Errorf("ExpiresAt=%v, attendu %v", got.ExpiresAt, want)
} }
// Renouvelé : la boucle ne doit plus le suspendre. // L'ancienne échéance est passée : le projet renouvelé ne doit pas être supprimé.
*now = p.ExpiresAt.Add(time.Minute)
svc.expireOverdue() svc.expireOverdue()
if len(prov.suspended) != 1 { if len(prov.tornDown) != 0 {
t.Errorf("re-suspension d'un projet renouvelé : %v", prov.suspended) t.Errorf("suppression d'un projet renouvelé : %v", prov.tornDown)
} }
} }
func TestExtend_ResumeFailureKeepsProjectSuspended(t *testing.T) { func TestExtend_DeletedProjectNotExtendable(t *testing.T) {
svc, prov, now := newTestService(t) svc, prov, now := newTestService(t)
p := createReady(t, svc, prov, validInput()) p := createReady(t, svc, prov, validInput())
*now = p.ExpiresAt.Add(time.Hour) *now = p.ExpiresAt.Add(time.Minute)
svc.expireOverdue() svc.expireOverdue()
prov.resumeErr = errors.New("api k8s indisponible") if _, err := svc.Extend(p.ID, 1); !errors.Is(err, ErrNotExtendable) {
if _, err := svc.Extend(p.ID, 1); err == nil { t.Errorf("erreur %v, attendu ErrNotExtendable", err)
t.Fatal("erreur attendue")
}
got, _ := svc.Get(p.ID)
if got.Status != StatusSuspended || !got.ExpiresAt.Equal(p.ExpiresAt) {
t.Errorf("l'échec de relance a modifié le projet : %s %v", got.Status, got.ExpiresAt)
} }
} }
@@ -326,19 +277,3 @@ func TestDelete_IsIdempotentAndImmediate(t *testing.T) {
t.Errorf("erreur %v, attendu ErrNotFound", err) t.Errorf("erreur %v, attendu ErrNotFound", err)
} }
} }
func TestZeroGraceSuspendsThenDeletesNextTick(t *testing.T) {
prov := newFakeProv()
svc := NewService(NewMemStore(), prov, Config{})
now := t0
svc.now = func() time.Time { return now }
p := createReady(t, svc, prov, validInput())
now = p.ExpiresAt.Add(time.Minute)
svc.expireOverdue() // suspension
svc.expireOverdue() // DeleteAt == ExpiresAt : déjà dépassé
got, _ := svc.Get(p.ID)
if got.Status != StatusDeleted {
t.Errorf("statut %s, attendu deleted (grâce nulle)", got.Status)
}
}
+2 -2
View File
@@ -113,8 +113,8 @@ export function CreateProjectModal({ isOpen, onClose, onCreated }: CreateProject
))} ))}
</Select> </Select>
<FormHelperText> <FormHelperText>
Durée réellement payée par le client. À l'échéance le projet est suspendu, puis supprimé Durée réellement payée par le client. À l'échéance le projet est supprimé s'il n'est pas
après le délai de grâce s'il n'est pas renouvelé. renouvelé.
</FormHelperText> </FormHelperText>
</FormControl> </FormControl>
+2 -4
View File
@@ -24,7 +24,7 @@ interface ExtendProjectModalProps {
} }
// Renouvellement d'un abonnement : 1 à 12 mois ajoutés à l'échéance courante // Renouvellement d'un abonnement : 1 à 12 mois ajoutés à l'échéance courante
// (ou à aujourd'hui si elle est dépassée). Relance un projet suspendu. // (ou à aujourd'hui si elle est dépassée).
export function ExtendProjectModal({ project, onClose, onExtended }: ExtendProjectModalProps) { export function ExtendProjectModal({ project, onClose, onExtended }: ExtendProjectModalProps) {
const toast = useToast() const toast = useToast()
const [months, setMonths] = useState(1) const [months, setMonths] = useState(1)
@@ -72,9 +72,7 @@ export function ExtendProjectModal({ project, onClose, onExtended }: ExtendProje
))} ))}
</Select> </Select>
<FormHelperText> <FormHelperText>
{project?.status === 'suspended' Les mois s'ajoutent à l'échéance actuelle.
? "Le projet est suspendu : il sera relancé et l'abonnement repart d'aujourd'hui."
: "Les mois s'ajoutent à l'échéance actuelle."}
</FormHelperText> </FormHelperText>
</FormControl> </FormControl>
</ModalBody> </ModalBody>
+2 -5
View File
@@ -58,13 +58,12 @@ export interface Demo {
expires_at: string expires_at: string
} }
// Projets vitrine : abonnement de 1 à 12 mois, suspendu à l'échéance puis // Projets vitrine : abonnement de 1 à 12 mois, supprimé à l'échéance
// supprimé après un délai de grâce (voir internal/projects côté API). // (voir internal/projects côté API).
export type ProjectStatus = export type ProjectStatus =
| 'pending' | 'pending'
| 'provisioning' | 'provisioning'
| 'ready' | 'ready'
| 'suspended'
| 'deleting' | 'deleting'
| 'deleted' | 'deleted'
| 'failed' | 'failed'
@@ -81,8 +80,6 @@ export interface Project {
months_purchased: number months_purchased: number
created_at: string created_at: string
expires_at: string expires_at: string
delete_at: string
suspended_at?: string
} }
export interface CreateProjectParams { export interface CreateProjectParams {
-3
View File
@@ -101,8 +101,6 @@ export function projectStatusColor(s: ProjectStatus): string {
case 'provisioning': case 'provisioning':
case 'pending': case 'pending':
return 'blue' return 'blue'
case 'suspended':
return 'orange'
case 'deleting': case 'deleting':
case 'failed': case 'failed':
return 'red' return 'red'
@@ -118,7 +116,6 @@ export function projectStatusLabel(s: ProjectStatus): string {
pending: 'En attente', pending: 'En attente',
provisioning: 'Déploiement…', provisioning: 'Déploiement…',
ready: 'En ligne', ready: 'En ligne',
suspended: 'Suspendu',
deleting: 'Suppression…', deleting: 'Suppression…',
deleted: 'Supprimé', deleted: 'Supprimé',
failed: 'Échec', failed: 'Échec',
+3 -12
View File
@@ -75,21 +75,12 @@ export function Projects() {
} }
} }
// Échéance affichée : temps restant, ou date de suppression si suspendu. // Échéance affichée : date de suppression et temps restant.
const deadline = (p: Project) => { const deadline = (p: Project) => (
if (p.status === 'suspended') {
return (
<Text color="orange.500" fontSize="sm">
Suppression le {formatDate(p.delete_at)}
</Text>
)
}
return (
<Text fontSize="sm"> <Text fontSize="sm">
{formatDate(p.expires_at)} · {timeRemaining(p.expires_at)} {formatDate(p.expires_at)} · {timeRemaining(p.expires_at)}
</Text> </Text>
) )
}
return ( return (
<Box p={{ base: 4, md: 8 }}> <Box p={{ base: 4, md: 8 }}>
@@ -122,7 +113,7 @@ export function Projects() {
<Tbody> <Tbody>
{projects.map((p) => { {projects.map((p) => {
const locked = busy === p.id const locked = busy === p.id
const canExtend = p.status === 'ready' || p.status === 'provisioning' || p.status === 'suspended' const canExtend = p.status === 'ready' || p.status === 'provisioning'
return ( return (
<Tr key={p.id}> <Tr key={p.id}>
<Td>{p.client_name}</Td> <Td>{p.client_name}</Td>