From 9e11f01c2c651e4ca64f1d158278afce5ea26a02 Mon Sep 17 00:00:00 2001 From: Xor290 Date: Sun, 20 Sep 2026 16:14:29 +0200 Subject: [PATCH] chore: build --- control-plane/api/cmd/api/main.go | 10 +- control-plane/api/internal/config/config.go | 15 --- .../api/internal/config/config_test.go | 16 +-- .../api/internal/demos/helm_provisioner.go | 4 +- .../api/internal/demos/project_provisioner.go | 95 +-------------- .../demos/project_provisioner_test.go | 49 -------- .../api/internal/projects/handler.go | 2 +- control-plane/api/internal/projects/models.go | 21 ++-- .../api/internal/projects/service.go | 60 ++-------- .../api/internal/projects/service_test.go | 109 ++++-------------- web/src/components/CreateProjectModal.tsx | 4 +- web/src/components/ExtendProjectModal.tsx | 6 +- web/src/lib/api.ts | 7 +- web/src/lib/format.ts | 3 - web/src/pages/backoffice/Projects.tsx | 23 ++-- 15 files changed, 65 insertions(+), 359 deletions(-) diff --git a/control-plane/api/cmd/api/main.go b/control-plane/api/cmd/api/main.go index 59da05d..52e37c5 100644 --- a/control-plane/api/cmd/api/main.go +++ b/control-plane/api/cmd/api/main.go @@ -136,7 +136,7 @@ func main() { 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 { log.Fatalf("helm provisioner: %v", err) } @@ -169,11 +169,10 @@ func main() { go demoSvc.RunExpirationLoop(context.Background(), 5*time.Minute) // Projets vitrine (abonnements de 1 à 12 mois) : même cluster, mêmes - // sauvegardes Velero que les démos, mais suspension puis suppression après - // un délai de grâce à l'échéance (voir internal/projects). + // sauvegardes Velero que les démos, mais durée choisie au déploiement puis + // prolongée ; supprimés à l'échéance (voir internal/projects). projectSvc := projects.NewService(projectStore, helmProv, projects.Config{ Domain: cfg.VitrineDomain, - Grace: time.Duration(cfg.VitrineGraceDays) * 24 * time.Hour, }) go projectSvc.RunExpirationLoop(context.Background(), 5*time.Minute) @@ -193,8 +192,7 @@ func main() { ns = append(ns, d.Namespace) } } - // Projets vitrine en ligne (un projet suspendu n'a plus de pods : - // l'alerte « pod absent » serait un faux positif). + // Projets vitrine en ligne. ps, err := projectSvc.List() if err != nil { return nil, err diff --git a/control-plane/api/internal/config/config.go b/control-plane/api/internal/config/config.go index 2174428..55c4e36 100644 --- a/control-plane/api/internal/config/config.go +++ b/control-plane/api/internal/config/config.go @@ -3,7 +3,6 @@ package config import ( "errors" "os" - "strconv" "time" ) @@ -31,13 +30,8 @@ type Config struct { // Projets vitrine (abonnements de 1 à 12 mois, voir internal/projects). VitrineDomain string // OMNEX_VITRINE_DOMAIN : les projets sont servis sur . - 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 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. @@ -66,17 +60,8 @@ func Load() (Config, error) { DockerHubPassword: os.Getenv("OMNEX_DOCKERHUB_PASSWORD"), VitrineDomain: getenv("OMNEX_VITRINE_DOMAIN", "vitrine-omnex.club"), - VitrineChartsDir: getenv("OMNEX_VITRINE_CHARTS_DIR", "/charts-vitrine"), VitrineBackendImage: os.Getenv("VITRINE_BACKEND_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" { diff --git a/control-plane/api/internal/config/config_test.go b/control-plane/api/internal/config/config_test.go index 08efbc2..06fabe1 100644 --- a/control-plane/api/internal/config/config_test.go +++ b/control-plane/api/internal/config/config_test.go @@ -107,21 +107,7 @@ func TestLoad_VitrineDefaults(t *testing.T) { if err != nil { 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) } } - -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) - } -} diff --git a/control-plane/api/internal/demos/helm_provisioner.go b/control-plane/api/internal/demos/helm_provisioner.go index 3b199bf..afc5218 100644 --- a/control-plane/api/internal/demos/helm_provisioner.go +++ b/control-plane/api/internal/demos/helm_provisioner.go @@ -55,6 +55,7 @@ type HelmProvisioner struct { // NewHelmProvisioner crée un nouveau provisioner Helm. // 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") // metricsClient : client metrics.k8s.io pour l'usage CPU/mémoire live (optionnel, peut être nil). func NewHelmProvisioner( @@ -63,6 +64,7 @@ func NewHelmProvisioner( restConfig *rest.Config, metricsClient *metricsclient.Clientset, chartsDir string, + vitrineChartsDir string, helmPath string, ) (*HelmProvisioner, error) { if chartsDir == "" { @@ -89,7 +91,7 @@ func NewHelmProvisioner( metricsClient: metricsClient, dynamicClient: dynamicClient, chartsDir: chartsDir, - vitrineChartsDir: cfg.VitrineChartsDir, + vitrineChartsDir: vitrineChartsDir, frontendImage: cfg.FrontendImage, backendImage: cfg.BackendImage, lbtelegramImage: cfg.LBTelegramImage, diff --git a/control-plane/api/internal/demos/project_provisioner.go b/control-plane/api/internal/demos/project_provisioner.go index 659cac2..b9401c0 100644 --- a/control-plane/api/internal/demos/project_provisioner.go +++ b/control-plane/api/internal/demos/project_provisioner.go @@ -1,19 +1,16 @@ 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 ; +// Le cycle de vie (durée, prolongation, 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 @@ -29,10 +26,6 @@ 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. @@ -128,92 +121,6 @@ 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. diff --git a/control-plane/api/internal/demos/project_provisioner_test.go b/control-plane/api/internal/demos/project_provisioner_test.go index e28d44c..d62ad40 100644 --- a/control-plane/api/internal/demos/project_provisioner_test.go +++ b/control-plane/api/internal/demos/project_provisioner_test.go @@ -14,55 +14,6 @@ import ( 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") diff --git a/control-plane/api/internal/projects/handler.go b/control-plane/api/internal/projects/handler.go index 484505c..605cee5 100644 --- a/control-plane/api/internal/projects/handler.go +++ b/control-plane/api/internal/projects/handler.go @@ -82,7 +82,7 @@ func (h *Handler) Get(c *gin.Context) { } // Extend : POST /projects/:id/extend {"months": 1..12} — renouvelle -// l'abonnement (relance un projet suspendu). +// l'abonnement. func (h *Handler) Extend(c *gin.Context) { var req extendRequest if err := c.ShouldBindJSON(&req); err != nil { diff --git a/control-plane/api/internal/projects/models.go b/control-plane/api/internal/projects/models.go index 3a888b1..d810692 100644 --- a/control-plane/api/internal/projects/models.go +++ b/control-plane/api/internal/projects/models.go @@ -1,11 +1,11 @@ // 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é). // -// Contrairement aux démos (TTL fixe de 30 jours, détruites à l'échéance), un -// projet payé est suspendu à l'échéance puis supprimé seulement après un -// délai de grâce : un renouvellement en retard ne détruit pas les données du -// client. Le paiement est hors périmètre : l'admin Omnex saisit la durée -// souscrite au déploiement puis à chaque renouvellement. +// Contrairement aux démos (TTL fixe de 30 jours), la durée d'un projet payé est +// choisie au déploiement puis prolongée à chaque renouvellement ; à l'échéance +// il est supprimé, sans délai de grâce. Le paiement est hors périmètre : +// l'admin Omnex saisit la durée souscrite au déploiement puis à chaque +// renouvellement. package projects import "time" @@ -26,16 +26,15 @@ const ( StatusPending Status = "pending" // créé, pas encore pris par le worker StatusProvisioning Status = "provisioning" // déploiement en cours StatusReady Status = "ready" // en ligne - StatusSuspended Status = "suspended" // abonnement échu : arrêté, données conservées jusqu'à DeleteAt StatusDeleting Status = "deleting" // suppression en cours StatusDeleted Status = "deleted" // supprimé 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 { switch s { - case StatusProvisioning, StatusReady, StatusSuspended: + case StatusProvisioning, StatusReady: return true } return false @@ -58,12 +57,8 @@ type Project struct { MonthsPurchased int `json:"months_purchased"` 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"` - // 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" } diff --git a/control-plane/api/internal/projects/service.go b/control-plane/api/internal/projects/service.go index f3d77b5..385afce 100644 --- a/control-plane/api/internal/projects/service.go +++ b/control-plane/api/internal/projects/service.go @@ -31,8 +31,6 @@ var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9]{3,64}$`) type Provisioner interface { ProvisionProject(spec demos.ProjectSpec) error TeardownProject(namespace string) error - SuspendProject(namespace string) error - ResumeProject(namespace string) error } // 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) 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). type Config struct { - Domain string // domaine des projets : . - Grace time.Duration // délai entre l'échéance (suspension) et la suppression + Domain string // domaine des projets : . } // 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, CreatedAt: now, ExpiresAt: expires, - DeleteAt: expires.Add(s.cfg.Grace), } created, err := s.store.Create(p) 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() } // 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) { if err := ValidateMonths(months); err != nil { return Project{}, err @@ -205,21 +199,13 @@ func (s *Service) Extend(id string, months int) (Project, error) { 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.DeleteAt = p.ExpiresAt.Add(s.cfg.Grace) p.MonthsPurchased += months return s.store.Update(p) } -// Delete détruit le projet (namespace, données, sauvegardes) sans attendre le -// délai de grâce — action explicite de l'admin. +// Delete détruit le projet (namespace, données, sauvegardes) sans attendre +// l'échéance — action explicite de l'admin. func (s *Service) Delete(id string) (Project, error) { s.mu.Lock() 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'à -// 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) { ticker := time.NewTicker(interval) defer ticker.Stop() @@ -273,40 +259,18 @@ func (s *Service) expireOverdue() { } } -// applyLifecycle relit le projet (il a pu changer depuis List) et applique la -// transition due. À appeler avec s.mu tenu. +// applyLifecycle relit le projet (il a pu changer depuis List) et le supprime +// si son abonnement est échu. À appeler avec s.mu tenu. func (s *Service) applyLifecycle(id string) { 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 - } - if _, err := s.deleteLocked(p.ID); err != nil { - log.Printf("projets: suppression de %s échouée: %v", p.Namespace, err) - return - } - log.Printf("projets: %s supprimé (délai de grâce dépassé)", p.Namespace) + if _, err := s.deleteLocked(p.ID); err != nil { + log.Printf("projets: suppression de %s échouée: %v", p.Namespace, err) + return } + log.Printf("projets: %s supprimé (abonnement échu)", p.Namespace) } // shortID : 8 premiers caractères hex d'un uuid pour un nom de namespace court. diff --git a/control-plane/api/internal/projects/service_test.go b/control-plane/api/internal/projects/service_test.go index d50490f..cdf7b9d 100644 --- a/control-plane/api/internal/projects/service_test.go +++ b/control-plane/api/internal/projects/service_test.go @@ -14,11 +14,8 @@ import ( type fakeProv struct { mu sync.Mutex specs []demos.ProjectSpec - suspended []string - resumed []string tornDown []string provErr error - resumeErr error provisioned chan struct{} } @@ -37,25 +34,13 @@ func (f *fakeProv) TeardownProject(ns string) error { f.tornDown = append(f.tornDown, ns) 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) func newTestService(t *testing.T) (*Service, *fakeProv, *time.Time) { t.Helper() 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 svc.now = func() time.Time { return now } return svc, prov, &now @@ -109,9 +94,6 @@ func TestCreate_ExpiryIsCalendarMonths(t *testing.T) { if !p.ExpiresAt.Equal(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 { t.Errorf("MonthsPurchased=%d, attendu %d", p.MonthsPurchased, months) } @@ -196,9 +178,6 @@ func TestExtend_AddsMonthsFromCurrentExpiry(t *testing.T) { if got.MonthsPurchased != 15 { 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) { @@ -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) p := createReady(t, svc, prov, validInput()) // 3 mois // Avant l'échéance : rien ne bouge. *now = p.ExpiresAt.Add(-time.Hour) svc.expireOverdue() - if len(prov.suspended) != 0 { - t.Fatal("suspension avant l'échéance") + if len(prov.tornDown) != 0 { + 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) svc.expireOverdue() 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 { 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) p := createReady(t, svc, prov, validInput()) - *now = p.ExpiresAt.Add(2 * 24 * time.Hour) - svc.expireOverdue() - + *now = p.ExpiresAt.Add(-time.Hour) got, err := svc.Extend(p.ID, 1) if err != nil { t.Fatal(err) } - if got.Status != StatusReady || got.SuspendedAt != nil { - 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) { + if want := p.ExpiresAt.AddDate(0, 1, 0); !got.ExpiresAt.Equal(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() - if len(prov.suspended) != 1 { - t.Errorf("re-suspension d'un projet renouvelé : %v", prov.suspended) + if len(prov.tornDown) != 0 { + 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) p := createReady(t, svc, prov, validInput()) - *now = p.ExpiresAt.Add(time.Hour) + *now = p.ExpiresAt.Add(time.Minute) svc.expireOverdue() - prov.resumeErr = errors.New("api k8s indisponible") - if _, err := svc.Extend(p.ID, 1); err == nil { - 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) + if _, err := svc.Extend(p.ID, 1); !errors.Is(err, ErrNotExtendable) { + t.Errorf("erreur %v, attendu ErrNotExtendable", err) } } @@ -326,19 +277,3 @@ func TestDelete_IsIdempotentAndImmediate(t *testing.T) { 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) - } -} diff --git a/web/src/components/CreateProjectModal.tsx b/web/src/components/CreateProjectModal.tsx index 44e9d19..90909b9 100644 --- a/web/src/components/CreateProjectModal.tsx +++ b/web/src/components/CreateProjectModal.tsx @@ -113,8 +113,8 @@ export function CreateProjectModal({ isOpen, onClose, onCreated }: CreateProject ))} - Durée réellement payée par le client. À l'échéance le projet est suspendu, puis supprimé - après le délai de grâce s'il n'est pas renouvelé. + Durée réellement payée par le client. À l'échéance le projet est supprimé s'il n'est pas + renouvelé. diff --git a/web/src/components/ExtendProjectModal.tsx b/web/src/components/ExtendProjectModal.tsx index 5b803bc..dcf40f2 100644 --- a/web/src/components/ExtendProjectModal.tsx +++ b/web/src/components/ExtendProjectModal.tsx @@ -24,7 +24,7 @@ interface ExtendProjectModalProps { } // 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) { const toast = useToast() const [months, setMonths] = useState(1) @@ -72,9 +72,7 @@ export function ExtendProjectModal({ project, onClose, onExtended }: ExtendProje ))} - {project?.status === 'suspended' - ? "Le projet est suspendu : il sera relancé et l'abonnement repart d'aujourd'hui." - : "Les mois s'ajoutent à l'échéance actuelle."} + Les mois s'ajoutent à l'échéance actuelle. diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 3fd84c1..550739e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -58,13 +58,12 @@ export interface Demo { expires_at: string } -// Projets vitrine : abonnement de 1 à 12 mois, suspendu à l'échéance puis -// supprimé après un délai de grâce (voir internal/projects côté API). +// Projets vitrine : abonnement de 1 à 12 mois, supprimé à l'échéance +// (voir internal/projects côté API). export type ProjectStatus = | 'pending' | 'provisioning' | 'ready' - | 'suspended' | 'deleting' | 'deleted' | 'failed' @@ -81,8 +80,6 @@ export interface Project { months_purchased: number created_at: string expires_at: string - delete_at: string - suspended_at?: string } export interface CreateProjectParams { diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 45a0cc2..f3a1546 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -101,8 +101,6 @@ export function projectStatusColor(s: ProjectStatus): string { case 'provisioning': case 'pending': return 'blue' - case 'suspended': - return 'orange' case 'deleting': case 'failed': return 'red' @@ -118,7 +116,6 @@ export function projectStatusLabel(s: ProjectStatus): string { pending: 'En attente', provisioning: 'Déploiement…', ready: 'En ligne', - suspended: 'Suspendu', deleting: 'Suppression…', deleted: 'Supprimé', failed: 'Échec', diff --git a/web/src/pages/backoffice/Projects.tsx b/web/src/pages/backoffice/Projects.tsx index fa23bce..3e237c5 100644 --- a/web/src/pages/backoffice/Projects.tsx +++ b/web/src/pages/backoffice/Projects.tsx @@ -75,21 +75,12 @@ export function Projects() { } } - // Échéance affichée : temps restant, ou date de suppression si suspendu. - const deadline = (p: Project) => { - if (p.status === 'suspended') { - return ( - - Suppression le {formatDate(p.delete_at)} - - ) - } - return ( - - {formatDate(p.expires_at)} · {timeRemaining(p.expires_at)} - - ) - } + // Échéance affichée : date de suppression et temps restant. + const deadline = (p: Project) => ( + + {formatDate(p.expires_at)} · {timeRemaining(p.expires_at)} + + ) return ( @@ -122,7 +113,7 @@ export function Projects() { {projects.map((p) => { 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 ( {p.client_name}