chore: build
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user