Files
omnex/control-plane/api/internal/demos/service.go
T
Xor290 955f2325fb
ci-api / test (push) Successful in 26m55s
ci-web / test (push) Successful in 14m57s
chore: build
2026-08-18 14:46:05 +02:00

298 lines
8.5 KiB
Go

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-<client>" (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 ("<namespace>.<baseDomain>"). 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
}