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