chore: update
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct{ svc *Service }
|
||||
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
type createRequest struct {
|
||||
ClientName string `json:"client_name" binding:"required,min=1,max=64"`
|
||||
// Months : durée souscrite, 1 à 12.
|
||||
Months int `json:"months" binding:"required,min=1,max=12"`
|
||||
AdminUsername string `json:"admin_username" binding:"required,min=3,max=64,alphanum"`
|
||||
AdminPassword string `json:"admin_password" binding:"required,min=12"`
|
||||
// AdminNumber : nombre maximum de comptes admin (ADMIN_NUMBER), 1 par défaut.
|
||||
AdminNumber int `json:"admin_number" binding:"omitempty,min=1,max=20"`
|
||||
}
|
||||
|
||||
type extendRequest struct {
|
||||
Months int `json:"months" binding:"required,min=1,max=12"`
|
||||
}
|
||||
|
||||
func (h *Handler) writeError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, ErrNotExtendable):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, ErrInvalidMonths), errors.Is(err, ErrInvalidClient), errors.Is(err, ErrInvalidAdmin),
|
||||
errors.Is(err, ErrWeakAdminPass), errors.Is(err, ErrInvalidAdminSize):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||
}
|
||||
}
|
||||
|
||||
// Create : POST /projects — déploie un projet pour la durée souscrite
|
||||
// (202 Accepted, déploiement asynchrone).
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
var req createRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||
return
|
||||
}
|
||||
p, err := h.svc.Create(CreateInput{
|
||||
ClientName: req.ClientName,
|
||||
Months: req.Months,
|
||||
AdminUsername: req.AdminUsername,
|
||||
AdminPassword: req.AdminPassword,
|
||||
AdminNumber: req.AdminNumber,
|
||||
})
|
||||
if err != nil {
|
||||
h.writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, p)
|
||||
}
|
||||
|
||||
// List : GET /projects.
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
items, err := h.svc.List()
|
||||
if err != nil {
|
||||
h.writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// Get : GET /projects/:id.
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
p, err := h.svc.Get(c.Param("id"))
|
||||
if err != nil {
|
||||
h.writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, p)
|
||||
}
|
||||
|
||||
// Extend : POST /projects/:id/extend {"months": 1..12} — renouvelle
|
||||
// l'abonnement (relance un projet suspendu).
|
||||
func (h *Handler) Extend(c *gin.Context) {
|
||||
var req extendRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": ErrInvalidMonths.Error()})
|
||||
return
|
||||
}
|
||||
p, err := h.svc.Extend(c.Param("id"), req.Months)
|
||||
if err != nil {
|
||||
h.writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, p)
|
||||
}
|
||||
|
||||
// Delete : DELETE /projects/:id — suppression immédiate (namespace, données,
|
||||
// sauvegardes).
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
p, err := h.svc.Delete(c.Param("id"))
|
||||
if err != nil {
|
||||
h.writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, p)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newTestRouter(t *testing.T) (*gin.Engine, *fakeProv) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc, prov, _ := newTestService(t)
|
||||
h := NewHandler(svc)
|
||||
r := gin.New()
|
||||
r.POST("/projects", h.Create)
|
||||
r.GET("/projects", h.List)
|
||||
r.GET("/projects/:id", h.Get)
|
||||
r.DELETE("/projects/:id", h.Delete)
|
||||
r.POST("/projects/:id/extend", h.Extend)
|
||||
return r, prov
|
||||
}
|
||||
|
||||
func do(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
const goodBody = `{"client_name":"Dupont","months":%s,"admin_username":"admin","admin_password":"un-mot-de-passe-solide"}`
|
||||
|
||||
func body(months string) string { return strings.Replace(goodBody, "%s", months, 1) }
|
||||
|
||||
func TestHandlerCreate_MonthsBounds(t *testing.T) {
|
||||
r, _ := newTestRouter(t)
|
||||
for _, months := range []string{"0", "13", "-1", `"x"`} {
|
||||
if w := do(r, "POST", "/projects", body(months)); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("months=%s : HTTP %d, attendu 400", months, w.Code)
|
||||
}
|
||||
}
|
||||
for _, months := range []string{"1", "12"} {
|
||||
w := do(r, "POST", "/projects", body(months))
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Errorf("months=%s : HTTP %d, attendu 202 (%s)", months, w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerCreate_NeverEchoesAdminPassword(t *testing.T) {
|
||||
r, prov := newTestRouter(t)
|
||||
w := do(r, "POST", "/projects", body("2"))
|
||||
if w.Code != http.StatusAccepted {
|
||||
t.Fatalf("HTTP %d: %s", w.Code, w.Body)
|
||||
}
|
||||
<-prov.provisioned
|
||||
if strings.Contains(w.Body.String(), "un-mot-de-passe-solide") {
|
||||
t.Error("le mot de passe admin figure dans la réponse")
|
||||
}
|
||||
if list := do(r, "GET", "/projects", ""); strings.Contains(list.Body.String(), "un-mot-de-passe-solide") {
|
||||
t.Error("le mot de passe admin figure dans la liste")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerCreate_RejectsWeakPasswordAndBadAdminName(t *testing.T) {
|
||||
r, _ := newTestRouter(t)
|
||||
for name, payload := range map[string]string{
|
||||
"mot de passe court": `{"client_name":"D","months":1,"admin_username":"admin","admin_password":"court"}`,
|
||||
"admin non alphanum": `{"client_name":"D","months":1,"admin_username":"ad min","admin_password":"un-mot-de-passe-solide"}`,
|
||||
"client manquant": `{"months":1,"admin_username":"admin","admin_password":"un-mot-de-passe-solide"}`,
|
||||
"trop d'admins": `{"client_name":"D","months":1,"admin_username":"admin","admin_password":"un-mot-de-passe-solide","admin_number":21}`,
|
||||
} {
|
||||
if w := do(r, "POST", "/projects", payload); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s : HTTP %d, attendu 400", name, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerExtend_BoundsAndNotFound(t *testing.T) {
|
||||
r, _ := newTestRouter(t)
|
||||
if w := do(r, "POST", "/projects/inconnu/extend", `{"months":2}`); w.Code != http.StatusNotFound {
|
||||
t.Errorf("projet inconnu : HTTP %d, attendu 404", w.Code)
|
||||
}
|
||||
if w := do(r, "POST", "/projects/inconnu/extend", `{"months":13}`); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("13 mois : HTTP %d, attendu 400", w.Code)
|
||||
}
|
||||
if w := do(r, "POST", "/projects/inconnu/extend", `{}`); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("sans durée : HTTP %d, attendu 400", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// 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.
|
||||
package projects
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
MinMonths = 1
|
||||
MaxMonths = 12
|
||||
// DefaultAdminNumber : nombre de comptes admin autorisés dans le backend
|
||||
// déployé (ADMIN_NUMBER) quand l'admin Omnex n'en précise pas.
|
||||
DefaultAdminNumber = 1
|
||||
MaxAdminNumber = 20
|
||||
)
|
||||
|
||||
// Status : état d'un projet.
|
||||
type Status string
|
||||
|
||||
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é).
|
||||
func (s Status) Extendable() bool {
|
||||
switch s {
|
||||
case StatusProvisioning, StatusReady, StatusSuspended:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
ClientName string `gorm:"size:64;not null;index" json:"client_name"`
|
||||
Status Status `gorm:"size:20;not null;index" json:"status"`
|
||||
Namespace string `gorm:"size:63;uniqueIndex" json:"namespace"`
|
||||
Host string `gorm:"size:255" json:"host"`
|
||||
URL string `gorm:"size:255" json:"url"`
|
||||
|
||||
// AdminUsername : compte admin créé dans le backend déployé (le mot de
|
||||
// passe n'est jamais persisté). AdminNumber : ADMIN_NUMBER du backend.
|
||||
AdminUsername string `gorm:"size:64" json:"admin_username"`
|
||||
AdminNumber int `json:"admin_number"`
|
||||
|
||||
// MonthsPurchased : total des mois souscrits (création + renouvellements).
|
||||
MonthsPurchased int `json:"months_purchased"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// ExpiresAt : fin de l'abonnement (le projet est alors suspendu).
|
||||
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" }
|
||||
@@ -0,0 +1,319 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/omnex/control-plane/api/internal/demos"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("projet introuvable")
|
||||
ErrNotExtendable = errors.New("projet non prolongeable dans son état actuel")
|
||||
ErrInvalidMonths = errors.New("la durée doit être comprise entre 1 et 12 mois")
|
||||
ErrInvalidClient = errors.New("nom du client requis (64 caractères maximum)")
|
||||
ErrInvalidAdmin = errors.New("identifiant admin invalide (3 à 64 caractères alphanumériques)")
|
||||
ErrWeakAdminPass = errors.New("mot de passe admin trop court (12 caractères minimum)")
|
||||
ErrInvalidAdminSize = errors.New("nombre d'admins invalide (1 à 20)")
|
||||
)
|
||||
|
||||
var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9]{3,64}$`)
|
||||
|
||||
// Provisioner : ce que le service demande au cluster. Implémenté par
|
||||
// *demos.HelmProvisioner (voir demos/project_provisioner.go).
|
||||
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).
|
||||
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
|
||||
}
|
||||
|
||||
// CreateInput : saisie de l'admin Omnex au déploiement (dashboard).
|
||||
type CreateInput struct {
|
||||
ClientName string
|
||||
Months int
|
||||
AdminUsername string
|
||||
AdminPassword string
|
||||
AdminNumber int // 0 => DefaultAdminNumber
|
||||
}
|
||||
|
||||
// Validate normalise et valide la saisie.
|
||||
func (in *CreateInput) Validate() error {
|
||||
in.ClientName = strings.TrimSpace(in.ClientName)
|
||||
if in.ClientName == "" || len(in.ClientName) > 64 {
|
||||
return ErrInvalidClient
|
||||
}
|
||||
if err := ValidateMonths(in.Months); err != nil {
|
||||
return err
|
||||
}
|
||||
if !adminUsernamePattern.MatchString(in.AdminUsername) {
|
||||
return ErrInvalidAdmin
|
||||
}
|
||||
if len(in.AdminPassword) < 12 {
|
||||
return ErrWeakAdminPass
|
||||
}
|
||||
if in.AdminNumber == 0 {
|
||||
in.AdminNumber = DefaultAdminNumber
|
||||
}
|
||||
if in.AdminNumber < 1 || in.AdminNumber > MaxAdminNumber {
|
||||
return ErrInvalidAdminSize
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateMonths : durée d'abonnement autorisée, à la création comme à chaque
|
||||
// renouvellement.
|
||||
func ValidateMonths(months int) error {
|
||||
if months < MinMonths || months > MaxMonths {
|
||||
return ErrInvalidMonths
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Service : logique métier des projets (indépendante du transport HTTP).
|
||||
type Service struct {
|
||||
store Store
|
||||
prov Provisioner
|
||||
cfg Config
|
||||
now func() time.Time // horloge injectable
|
||||
|
||||
// mu sérialise les changements d'état : évite qu'une prolongation, la
|
||||
// boucle d'expiration et la fin d'un déploiement s'écrasent mutuellement.
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewService(store Store, prov Provisioner, cfg Config) *Service {
|
||||
if cfg.Domain == "" {
|
||||
cfg.Domain = "vitrine-omnex.club"
|
||||
}
|
||||
return &Service{store: store, prov: prov, cfg: cfg, now: time.Now}
|
||||
}
|
||||
|
||||
// Create enregistre le projet pour la durée souscrite et lance son déploiement
|
||||
// en arrière-plan (le mot de passe admin n'est jamais persisté).
|
||||
func (s *Service) Create(in CreateInput) (Project, error) {
|
||||
if err := in.Validate(); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
id := uuid.NewString()
|
||||
now := s.now().UTC()
|
||||
expires := now.AddDate(0, in.Months, 0)
|
||||
ns := "vitrine-" + shortID(id)
|
||||
host := ns + "." + s.cfg.Domain
|
||||
|
||||
p := Project{
|
||||
ID: id,
|
||||
ClientName: in.ClientName,
|
||||
Status: StatusProvisioning,
|
||||
Namespace: ns,
|
||||
Host: host,
|
||||
URL: "https://" + host,
|
||||
AdminUsername: in.AdminUsername,
|
||||
AdminNumber: in.AdminNumber,
|
||||
MonthsPurchased: in.Months,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: expires,
|
||||
DeleteAt: expires.Add(s.cfg.Grace),
|
||||
}
|
||||
created, err := s.store.Create(p)
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
|
||||
spec := demos.ProjectSpec{
|
||||
Namespace: created.Namespace,
|
||||
Host: created.Host,
|
||||
URL: created.URL,
|
||||
AdminUsername: in.AdminUsername,
|
||||
AdminPassword: in.AdminPassword,
|
||||
AdminNumber: created.AdminNumber,
|
||||
}
|
||||
go func() {
|
||||
err := s.prov.ProvisionProject(spec)
|
||||
// Ne réécrit que le statut : une prolongation reçue pendant le
|
||||
// déploiement ne doit pas être écrasée par la copie initiale.
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
current, ok := s.store.Get(created.ID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Provisioning du projet %s échoué: %v", created.Namespace, err)
|
||||
current.Status = StatusFailed
|
||||
} else if current.Status == StatusProvisioning {
|
||||
current.Status = StatusReady
|
||||
}
|
||||
_, _ = s.store.Update(current)
|
||||
}()
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(id string) (Project, error) {
|
||||
p, ok := s.store.Get(id)
|
||||
if !ok {
|
||||
return Project{}, ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
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é.
|
||||
func (s *Service) Extend(id string, months int) (Project, error) {
|
||||
if err := ValidateMonths(months); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
p, ok := s.store.Get(id)
|
||||
if !ok {
|
||||
return Project{}, ErrNotFound
|
||||
}
|
||||
if !p.Status.Extendable() {
|
||||
return Project{}, ErrNotExtendable
|
||||
}
|
||||
|
||||
now := s.now().UTC()
|
||||
base := p.ExpiresAt
|
||||
if base.Before(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.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.
|
||||
func (s *Service) Delete(id string) (Project, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.deleteLocked(id)
|
||||
}
|
||||
|
||||
func (s *Service) deleteLocked(id string) (Project, error) {
|
||||
p, ok := s.store.Get(id)
|
||||
if !ok {
|
||||
return Project{}, ErrNotFound
|
||||
}
|
||||
if p.Status == StatusDeleted {
|
||||
return p, nil // idempotent
|
||||
}
|
||||
p.Status = StatusDeleting
|
||||
if _, err := s.store.Update(p); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
if err := s.prov.TeardownProject(p.Namespace); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
p.Status = StatusDeleted
|
||||
return s.store.Update(p)
|
||||
}
|
||||
|
||||
// RunExpirationLoop applique le cycle de vie à intervalle régulier jusqu'à
|
||||
// l'annulation de ctx : suspension à l'échéance, suppression à DeleteAt.
|
||||
func (s *Service) RunExpirationLoop(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.expireOverdue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) expireOverdue() {
|
||||
all, err := s.store.List()
|
||||
if err != nil {
|
||||
log.Printf("projets: liste échouée: %v", err)
|
||||
return
|
||||
}
|
||||
for _, p := range all {
|
||||
s.mu.Lock()
|
||||
s.applyLifecycle(p.ID)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// applyLifecycle relit le projet (il a pu changer depuis List) et applique la
|
||||
// transition due. À appeler avec s.mu tenu.
|
||||
func (s *Service) applyLifecycle(id string) {
|
||||
p, ok := s.store.Get(id)
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// shortID : 8 premiers caractères hex d'un uuid pour un nom de namespace court.
|
||||
func shortID(id string) string {
|
||||
clean := strings.ReplaceAll(id, "-", "")
|
||||
if len(clean) > 8 {
|
||||
clean = clean[:8]
|
||||
}
|
||||
return clean
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/omnex/control-plane/api/internal/demos"
|
||||
)
|
||||
|
||||
// fakeProv enregistre les appels au cluster ; provisioned est fermé une fois
|
||||
// le déploiement (asynchrone) terminé.
|
||||
type fakeProv struct {
|
||||
mu sync.Mutex
|
||||
specs []demos.ProjectSpec
|
||||
suspended []string
|
||||
resumed []string
|
||||
tornDown []string
|
||||
provErr error
|
||||
resumeErr error
|
||||
provisioned chan struct{}
|
||||
}
|
||||
|
||||
func newFakeProv() *fakeProv { return &fakeProv{provisioned: make(chan struct{}, 8)} }
|
||||
|
||||
func (f *fakeProv) ProvisionProject(s demos.ProjectSpec) error {
|
||||
f.mu.Lock()
|
||||
f.specs = append(f.specs, s)
|
||||
f.mu.Unlock()
|
||||
defer func() { f.provisioned <- struct{}{} }()
|
||||
return f.provErr
|
||||
}
|
||||
func (f *fakeProv) TeardownProject(ns string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
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})
|
||||
now := t0
|
||||
svc.now = func() time.Time { return now }
|
||||
return svc, prov, &now
|
||||
}
|
||||
|
||||
func validInput() CreateInput {
|
||||
return CreateInput{ClientName: "Boutique Dupont", Months: 3, AdminUsername: "admin", AdminPassword: "un-mot-de-passe-solide"}
|
||||
}
|
||||
|
||||
// createReady crée un projet et attend la fin de son déploiement simulé.
|
||||
func createReady(t *testing.T, svc *Service, prov *fakeProv, in CreateInput) Project {
|
||||
t.Helper()
|
||||
p, err := svc.Create(in)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-prov.provisioned:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("déploiement simulé non terminé")
|
||||
}
|
||||
// La goroutine de Create met le statut à jour après ProvisionProject :
|
||||
// prendre le verrou attend qu'elle ait fini.
|
||||
svc.mu.Lock()
|
||||
svc.mu.Unlock() //nolint:staticcheck // barrière de synchronisation
|
||||
got, err := svc.Get(p.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestCreate_MonthsBounds(t *testing.T) {
|
||||
svc, _, _ := newTestService(t)
|
||||
for _, months := range []int{-1, 0, 13, 100} {
|
||||
in := validInput()
|
||||
in.Months = months
|
||||
if _, err := svc.Create(in); !errors.Is(err, ErrInvalidMonths) {
|
||||
t.Errorf("months=%d : erreur %v, attendu ErrInvalidMonths", months, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_ExpiryIsCalendarMonths(t *testing.T) {
|
||||
for _, months := range []int{1, 6, 12} {
|
||||
svc, prov, _ := newTestService(t)
|
||||
in := validInput()
|
||||
in.Months = months
|
||||
p := createReady(t, svc, prov, in)
|
||||
want := t0.AddDate(0, months, 0)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_ValidatesInput(t *testing.T) {
|
||||
svc, _, _ := newTestService(t)
|
||||
cases := map[string]struct {
|
||||
mutate func(*CreateInput)
|
||||
want error
|
||||
}{
|
||||
"client vide": {func(i *CreateInput) { i.ClientName = " " }, ErrInvalidClient},
|
||||
"admin invalide": {func(i *CreateInput) { i.AdminUsername = "a b" }, ErrInvalidAdmin},
|
||||
"admin trop court": {func(i *CreateInput) { i.AdminUsername = "ab" }, ErrInvalidAdmin},
|
||||
"mot de passe": {func(i *CreateInput) { i.AdminPassword = "court" }, ErrWeakAdminPass},
|
||||
"nb admin trop": {func(i *CreateInput) { i.AdminNumber = 21 }, ErrInvalidAdminSize},
|
||||
"nb admin négatif": {func(i *CreateInput) { i.AdminNumber = -1 }, ErrInvalidAdminSize},
|
||||
}
|
||||
for name, c := range cases {
|
||||
in := validInput()
|
||||
c.mutate(&in)
|
||||
if _, err := svc.Create(in); !errors.Is(err, c.want) {
|
||||
t.Errorf("%s : erreur %v, attendu %v", name, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_PassesAdminSettingsToProvisioner(t *testing.T) {
|
||||
svc, prov, _ := newTestService(t)
|
||||
in := validInput()
|
||||
in.AdminNumber = 3
|
||||
p := createReady(t, svc, prov, in)
|
||||
|
||||
if len(prov.specs) != 1 {
|
||||
t.Fatalf("%d déploiements, attendu 1", len(prov.specs))
|
||||
}
|
||||
spec := prov.specs[0]
|
||||
if spec.AdminNumber != 3 || spec.AdminUsername != "admin" || spec.AdminPassword != in.AdminPassword {
|
||||
t.Errorf("spec inattendue : %+v", spec)
|
||||
}
|
||||
if spec.Host != p.Namespace+".vitrine-omnex.club" || spec.URL != "https://"+spec.Host {
|
||||
t.Errorf("host/URL inattendus : %q %q", spec.Host, spec.URL)
|
||||
}
|
||||
if p.Status != StatusReady {
|
||||
t.Errorf("statut %s, attendu ready", p.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_DefaultAdminNumberIsOne(t *testing.T) {
|
||||
svc, prov, _ := newTestService(t)
|
||||
p := createReady(t, svc, prov, validInput())
|
||||
if p.AdminNumber != 1 || prov.specs[0].AdminNumber != 1 {
|
||||
t.Errorf("AdminNumber=%d / spec=%d, attendu 1", p.AdminNumber, prov.specs[0].AdminNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_ProvisionFailureMarksFailed(t *testing.T) {
|
||||
svc, prov, _ := newTestService(t)
|
||||
prov.provErr = errors.New("helm en échec")
|
||||
p := createReady(t, svc, prov, validInput())
|
||||
if p.Status != StatusFailed {
|
||||
t.Errorf("statut %s, attendu failed", p.Status)
|
||||
}
|
||||
if _, err := svc.Extend(p.ID, 1); !errors.Is(err, ErrNotExtendable) {
|
||||
t.Errorf("Extend sur un projet failed : %v, attendu ErrNotExtendable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtend_AddsMonthsFromCurrentExpiry(t *testing.T) {
|
||||
svc, prov, _ := newTestService(t)
|
||||
p := createReady(t, svc, prov, validInput()) // 3 mois
|
||||
|
||||
got, err := svc.Extend(p.ID, 12)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := t0.AddDate(0, 3, 0).AddDate(0, 12, 0)
|
||||
if !got.ExpiresAt.Equal(want) {
|
||||
t.Errorf("ExpiresAt=%v, attendu %v (échéance courante + 12 mois)", got.ExpiresAt, want)
|
||||
}
|
||||
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) {
|
||||
svc, prov, _ := newTestService(t)
|
||||
p := createReady(t, svc, prov, validInput())
|
||||
for _, months := range []int{0, 13, -2} {
|
||||
if _, err := svc.Extend(p.ID, months); !errors.Is(err, ErrInvalidMonths) {
|
||||
t.Errorf("months=%d : %v, attendu ErrInvalidMonths", months, err)
|
||||
}
|
||||
}
|
||||
got, _ := svc.Get(p.ID)
|
||||
if !got.ExpiresAt.Equal(p.ExpiresAt) {
|
||||
t.Error("une prolongation refusée a modifié l'échéance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtend_UnknownProject(t *testing.T) {
|
||||
svc, _, _ := newTestService(t)
|
||||
if _, err := svc.Extend("inconnu", 1); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("erreur %v, attendu ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiration_SuspendsAtExpiryThenDeletesAfterGrace(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")
|
||||
}
|
||||
|
||||
// À l'échéance : suspendu, jamais supprimé.
|
||||
*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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtend_ResumesSuspendedProject(t *testing.T) {
|
||||
svc, prov, now := newTestService(t)
|
||||
p := createReady(t, svc, prov, validInput())
|
||||
|
||||
*now = p.ExpiresAt.Add(2 * 24 * time.Hour)
|
||||
svc.expireOverdue()
|
||||
|
||||
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) {
|
||||
t.Errorf("ExpiresAt=%v, attendu %v", got.ExpiresAt, want)
|
||||
}
|
||||
|
||||
// Renouvelé : la boucle ne doit plus le suspendre.
|
||||
svc.expireOverdue()
|
||||
if len(prov.suspended) != 1 {
|
||||
t.Errorf("re-suspension d'un projet renouvelé : %v", prov.suspended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtend_ResumeFailureKeepsProjectSuspended(t *testing.T) {
|
||||
svc, prov, now := newTestService(t)
|
||||
p := createReady(t, svc, prov, validInput())
|
||||
*now = p.ExpiresAt.Add(time.Hour)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_IsIdempotentAndImmediate(t *testing.T) {
|
||||
svc, prov, _ := newTestService(t)
|
||||
p := createReady(t, svc, prov, validInput())
|
||||
|
||||
got, err := svc.Delete(p.ID)
|
||||
if err != nil || got.Status != StatusDeleted {
|
||||
t.Fatalf("Delete: %v, statut %s", err, got.Status)
|
||||
}
|
||||
if _, err := svc.Delete(p.ID); err != nil {
|
||||
t.Fatalf("second Delete: %v", err)
|
||||
}
|
||||
if len(prov.tornDown) != 1 {
|
||||
t.Errorf("teardown appelé %d fois, attendu 1", len(prov.tornDown))
|
||||
}
|
||||
if _, err := svc.Delete("inconnu"); !errors.Is(err, ErrNotFound) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Store : persistance des projets.
|
||||
type Store interface {
|
||||
Create(p Project) (Project, error)
|
||||
Get(id string) (Project, bool)
|
||||
List() ([]Project, error)
|
||||
Update(p Project) (Project, error)
|
||||
}
|
||||
|
||||
// GormStore : Store adossé à PostgreSQL via GORM.
|
||||
type GormStore struct{ db *gorm.DB }
|
||||
|
||||
func NewGormStore(db *gorm.DB) *GormStore { return &GormStore{db: db} }
|
||||
|
||||
func (s *GormStore) Create(p Project) (Project, error) {
|
||||
if err := s.db.Create(&p).Error; err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *GormStore) Get(id string) (Project, bool) {
|
||||
var p Project
|
||||
err := s.db.Where("id = ?", id).First(&p).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) || err != nil {
|
||||
return Project{}, false
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
func (s *GormStore) List() ([]Project, error) {
|
||||
var out []Project
|
||||
if err := s.db.Order("created_at DESC").Find(&out).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *GormStore) Update(p Project) (Project, error) {
|
||||
if err := s.db.Save(&p).Error; err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// MemStore : Store en mémoire (dev/tests).
|
||||
type MemStore struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]Project
|
||||
}
|
||||
|
||||
func NewMemStore() *MemStore { return &MemStore{items: make(map[string]Project)} }
|
||||
|
||||
func (m *MemStore) Create(p Project) (Project, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.items[p.ID] = p
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *MemStore) Get(id string) (Project, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
p, ok := m.items[id]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
func (m *MemStore) List() ([]Project, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]Project, 0, len(m.items))
|
||||
for _, p := range m.items {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MemStore) Update(p Project) (Project, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.items[p.ID] = p
|
||||
return p, nil
|
||||
}
|
||||
Reference in New Issue
Block a user