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 }