282 lines
7.9 KiB
Go
282 lines
7.9 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
|
|
}
|
|
|
|
// 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
|
|
}
|