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
@@ -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)
}
}