222 lines
5.9 KiB
Go
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
|
|
}
|