@@ -0,0 +1,172 @@
|
||||
package demos
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/omnex/control-plane/api/internal/auth"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCapacityReached = errors.New("capacité maximale de démos atteinte")
|
||||
ErrNotFound = errors.New("démo introuvable")
|
||||
ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel")
|
||||
)
|
||||
|
||||
// Config du service (injectable pour les tests).
|
||||
type Config struct {
|
||||
BaseDomain string // ex. "demo.omnex.app"
|
||||
TTL time.Duration // durée de vie
|
||||
MaxDemos int // capacité
|
||||
}
|
||||
|
||||
// Service : logique métier des démos (indépendante du transport HTTP).
|
||||
type Service struct {
|
||||
store Store
|
||||
pool Pool
|
||||
prov Provisioner
|
||||
cfg Config
|
||||
now func() time.Time // horloge injectable
|
||||
}
|
||||
|
||||
func NewService(store Store, pool Pool, prov Provisioner, cfg Config) *Service {
|
||||
if cfg.TTL == 0 {
|
||||
cfg.TTL = TTL
|
||||
}
|
||||
if cfg.MaxDemos == 0 {
|
||||
cfg.MaxDemos = MaxConcurrentDemos
|
||||
}
|
||||
if cfg.BaseDomain == "" {
|
||||
cfg.BaseDomain = "demo.omnex.app"
|
||||
}
|
||||
return &Service{store: store, pool: pool, prov: prov, cfg: cfg, now: time.Now}
|
||||
}
|
||||
|
||||
// Create réserve la capacité + le pool, persiste la démo et déclenche le worker.
|
||||
// username (optionnel) rattache la démo à un client existant.
|
||||
func (s *Service) Create(leadID, username string) (Demo, error) {
|
||||
active, err := s.store.CountActive()
|
||||
if err != nil {
|
||||
return Demo{}, err
|
||||
}
|
||||
if active >= s.cfg.MaxDemos {
|
||||
return Demo{}, ErrCapacityReached
|
||||
}
|
||||
|
||||
id := uuid.NewString()
|
||||
demo := Demo{
|
||||
ID: id,
|
||||
Username: auth.NormalizeUsername(username),
|
||||
LeadID: leadID,
|
||||
Status: StatusPending,
|
||||
Namespace: "demo-" + shortID(id),
|
||||
CreatedAt: s.now().UTC(),
|
||||
ExpiresAt: s.now().UTC().Add(s.cfg.TTL),
|
||||
}
|
||||
demo.URL = "https://" + demo.Namespace + "." + s.cfg.BaseDomain
|
||||
|
||||
// Réserve les ressources externes ; ErrPoolExhausted => capacité atteinte.
|
||||
resources, err := s.pool.Borrow(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrPoolExhausted) {
|
||||
return Demo{}, ErrCapacityReached
|
||||
}
|
||||
return Demo{}, err
|
||||
}
|
||||
|
||||
demo.Status = StatusProvisioning
|
||||
created, err := s.store.Create(demo)
|
||||
if err != nil {
|
||||
_ = s.pool.Return(id) // fail-secure : on rend ce qu'on a emprunté
|
||||
return Demo{}, err
|
||||
}
|
||||
|
||||
// Lance le déploiement de manière asynchrone (worker inline via goroutine).
|
||||
go func() {
|
||||
if err := s.prov.Provision(created, resources); err != nil {
|
||||
log.Printf("Provisioning échoué pour %s: %v", created.ID, err)
|
||||
created.Status = StatusFailed
|
||||
_, _ = s.store.Update(created)
|
||||
_ = s.pool.Return(id)
|
||||
return
|
||||
}
|
||||
created.Status = StatusReady
|
||||
_, _ = s.store.Update(created)
|
||||
}()
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(id string) (Demo, error) {
|
||||
d, ok := s.store.Get(id)
|
||||
if !ok {
|
||||
return Demo{}, ErrNotFound
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *Service) List() ([]Demo, error) {
|
||||
return s.store.List()
|
||||
}
|
||||
|
||||
// ListForUser retourne uniquement les démos rattachées à ce client.
|
||||
func (s *Service) ListForUser(username string) ([]Demo, error) {
|
||||
return s.store.ListByUsername(auth.NormalizeUsername(username))
|
||||
}
|
||||
|
||||
// Delete déclenche le teardown et libère le pool.
|
||||
func (s *Service) Delete(id string) (Demo, error) {
|
||||
d, ok := s.store.Get(id)
|
||||
if !ok {
|
||||
return Demo{}, ErrNotFound
|
||||
}
|
||||
if d.Status == StatusExpired {
|
||||
return d, nil // déjà détruite (idempotent)
|
||||
}
|
||||
d.Status = StatusExpiring
|
||||
if _, err := s.store.Update(d); err != nil {
|
||||
return Demo{}, err
|
||||
}
|
||||
if err := s.prov.Teardown(d); err != nil {
|
||||
return Demo{}, err
|
||||
}
|
||||
if err := s.pool.Return(id); err != nil {
|
||||
return Demo{}, err
|
||||
}
|
||||
d.Status = StatusExpired
|
||||
return s.store.Update(d)
|
||||
}
|
||||
|
||||
// Extend prolonge la démo de TTL à partir de son échéance courante.
|
||||
func (s *Service) Extend(id string) (Demo, error) {
|
||||
d, ok := s.store.Get(id)
|
||||
if !ok {
|
||||
return Demo{}, ErrNotFound
|
||||
}
|
||||
if !d.Status.Extendable() {
|
||||
return Demo{}, ErrNotExtendable
|
||||
}
|
||||
base := d.ExpiresAt
|
||||
if now := s.now().UTC(); base.Before(now) {
|
||||
base = now
|
||||
}
|
||||
d.ExpiresAt = base.Add(s.cfg.TTL)
|
||||
return s.store.Update(d)
|
||||
}
|
||||
|
||||
// shortID : 8 premiers caractères hex d'un uuid pour un nom de namespace court.
|
||||
func shortID(id string) string {
|
||||
clean := ""
|
||||
for _, c := range id {
|
||||
if c != '-' {
|
||||
clean += string(c)
|
||||
}
|
||||
if len(clean) == 8 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return clean
|
||||
}
|
||||
Reference in New Issue
Block a user