package demos import ( "errors" "log" "strings" "time" "github.com/google/uuid" "github.com/omnex/control-plane/api/internal/auth" ) var ( ErrNotFound = errors.New("démo introuvable") ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel") ErrUserHasActiveDemo = errors.New("ce client a déjà une démo active") ) // Config du service (injectable pour les tests). type Config struct { BaseDomain string // ex. "demo.omnex.app" HTTPSPort string // port public HTTPS pour l'URL affichée ("443" = omis, sinon ex. NodePort) TTL time.Duration // durée de vie } // 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.BaseDomain == "" { cfg.BaseDomain = "demo.omnex.app" } if cfg.HTTPSPort == "" { cfg.HTTPSPort = "443" } 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. Un client n'a // droit qu'à une seule démo active à la fois. cfg (bot Telegram, stockage) // n'est jamais persisté : transmis tel quel au provisioning. func (s *Service) Create(username string, cfg ProvisionConfig) (Demo, error) { if err := cfg.Validate(); err != nil { return Demo{}, err } normalizedUsername := auth.NormalizeUsername(username) if normalizedUsername != "" { existing, err := s.store.ListByUsername(normalizedUsername) if err != nil { return Demo{}, err } for _, d := range existing { if d.Status.Active() { return Demo{}, ErrUserHasActiveDemo } } } id := uuid.NewString() demo := Demo{ ID: id, Username: normalizedUsername, Status: StatusPending, Namespace: "demo-" + shortID(id), CreatedAt: s.now().UTC(), ExpiresAt: s.now().UTC().Add(s.cfg.TTL), } demo.URL = s.urlFor(demo.Namespace) // Réserve les ressources externes (slots Telegram/NowPayments pré- // provisionnés) — contrainte réelle indépendante du nombre de démos, // non concernée par la suppression du quota. resources, err := s.pool.Borrow(id) if err != nil { 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, cfg); 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)) } // TransferToPaid marque la démo active du client comme abonnement payant // (n'expire plus) et déclenche la migration de son namespace vers // "premium-" (Kubernetes ne permet pas de renommer un namespace, // voir HelmProvisioner.MigrateToPremiumNamespace) — no-op si déjà migrée // (ex. renouvellement d'un client déjà premium). Le stockage (PVC // postgres/redis) est déjà en place depuis la création de la démo, seules // les données postgres sont recopiées vers le nouveau namespace. No-op // total si le client n'a pas de démo active — ce n'est pas une erreur (ex: // compte créé sans jamais avoir demandé de démo). La migration est lente // (dump/restore/helm) : elle tourne en tâche de fond, cette méthode ne // bloque pas. func (s *Service) TransferToPaid(username string) error { existing, err := s.store.ListByUsername(auth.NormalizeUsername(username)) if err != nil { return err } var active *Demo for i := range existing { if existing[i].Status.Active() { active = &existing[i] break } } if active == nil { return nil } active.TypeAbo = "premium" active.ExpiresAt = s.now().UTC().AddDate(10, 0, 0) // n'expire plus, en pratique updated, err := s.store.Update(*active) if err != nil { return err } newNamespace := premiumNamespace(updated.Username) if newNamespace != updated.Namespace { newURL := s.urlFor(newNamespace) go func() { if err := s.prov.MigrateToPremiumNamespace(updated, newNamespace, newURL); err != nil { log.Printf("Migration namespace premium échouée pour %s: %v", updated.Namespace, err) return } updated.Namespace = newNamespace updated.URL = newURL if _, err := s.store.Update(updated); err != nil { log.Printf("Mise à jour namespace après migration premium échouée pour %s: %v", newNamespace, err) } }() } return nil } // TransferToPaidByID passe en premium la démo désignée par son id (résout // le username propriétaire puis délègue à TransferToPaid, qui opère par // client — un client n'a qu'une démo active à la fois, cf. Create). // Retourne la démo mise à jour (TypeAbo="premium" déjà appliqué ; la // migration de namespace tourne en tâche de fond, voir TransferToPaid). func (s *Service) TransferToPaidByID(id string) (Demo, error) { d, err := s.Get(id) if err != nil { return Demo{}, err } if err := s.TransferToPaid(d.Username); err != nil { return Demo{}, err } return s.Get(id) } // urlFor construit l'URL publique d'une démo pour un namespace donné. Port // omis s'il s'agit du 443 standard ; sinon affiché explicitement (ex. // NodePort HTTPS sans LoadBalancer). Ne concerne que l'URL affichée : le // Host() de l'IngressRoute reste sans port (voir buildIngressRouteValues), // le port n'y a pas sa place pour le matching. func (s *Service) urlFor(namespace string) string { portSuffix := "" if s.cfg.HTTPSPort != "" && s.cfg.HTTPSPort != "443" { portSuffix = ":" + s.cfg.HTTPSPort } return "https://" + namespace + "." + s.cfg.BaseDomain + portSuffix } // SetDomain modifie le domaine public d'une démo (IngressRoute, et // re-branchement des webhooks Telegram si un bot est configuré — voir // HelmProvisioner.UpdateDomain). domain vide réinitialise au domaine par // défaut ("."). Contrairement à // MigrateToPremiumNamespace, c'est une opération rapide (aucune donnée à // migrer, aucun namespace à recréer) : appliquée de façon synchrone. func (s *Service) SetDomain(id, domain string) (Demo, error) { domain = strings.ToLower(strings.TrimSpace(domain)) if err := ValidateDomain(domain); err != nil { return Demo{}, err } d, ok := s.store.Get(id) if !ok { return Demo{}, ErrNotFound } newURL := domain if newURL == "" { newURL = s.urlFor(d.Namespace) } else { newURL = "https://" + domain } if err := s.prov.UpdateDomain(d, newURL); err != nil { return Demo{}, err } d.CustomDomain = domain d.URL = newURL return s.store.Update(d) } // 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 }