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
}
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
@@ -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 <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
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" {
@@ -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)
}
}
@@ -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,
@@ -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.
@@ -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")
@@ -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 {
+8 -13
View File
@@ -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" }
+12 -48
View File
@@ -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 : <namespace>.<Domain>
Grace time.Duration // délai entre l'échéance (suspension) et la suppression
Domain string // domaine des projets : <namespace>.<Domain>
}
// 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.
@@ -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)
}
}
+2 -2
View File
@@ -113,8 +113,8 @@ export function CreateProjectModal({ isOpen, onClose, onCreated }: CreateProject
))}
</Select>
<FormHelperText>
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é.
</FormHelperText>
</FormControl>
+2 -4
View File
@@ -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
))}
</Select>
<FormHelperText>
{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.
</FormHelperText>
</FormControl>
</ModalBody>
+2 -5
View File
@@ -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 {
-3
View File
@@ -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',
+7 -16
View File
@@ -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 (
<Text color="orange.500" fontSize="sm">
Suppression le {formatDate(p.delete_at)}
</Text>
)
}
return (
<Text fontSize="sm">
{formatDate(p.expires_at)} · {timeRemaining(p.expires_at)}
</Text>
)
}
// Échéance affichée : date de suppression et temps restant.
const deadline = (p: Project) => (
<Text fontSize="sm">
{formatDate(p.expires_at)} · {timeRemaining(p.expires_at)}
</Text>
)
return (
<Box p={{ base: 4, md: 8 }}>
@@ -122,7 +113,7 @@ export function Projects() {
<Tbody>
{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 (
<Tr key={p.id}>
<Td>{p.client_name}</Td>