Files
omnex/control-plane/api/internal/demos/service.go
T
Xor290 09928c0d76
ci-api / test (push) Successful in 21m55s
chore: update
2026-08-02 16:24:28 +02:00

222 lines
5.9 KiB
Go

package demos
import (
"errors"
"log"
"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(leadID, 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,
LeadID: leadID,
Status: StatusPending,
Namespace: "demo-" + shortID(id),
CreatedAt: s.now().UTC(),
ExpiresAt: s.now().UTC().Add(s.cfg.TTL),
}
// Port omis dans l'URL 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.
portSuffix := ""
if s.cfg.HTTPSPort != "" && s.cfg.HTTPSPort != "443" {
portSuffix = ":" + s.cfg.HTTPSPort
}
demo.URL = "https://" + demo.Namespace + "." + s.cfg.BaseDomain + portSuffix
// 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). Le stockage (PVC postgres/redis) est déjà en place depuis
// la création de la démo (voir HelmProvisioner.Provision) : rien à basculer
// côté infra, seul le statut change. No-op 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).
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
if _, err := s.store.Update(*active); err != nil {
return err
}
return nil
}
// 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
}