package projects import ( "context" "errors" "log" "regexp" "strings" "sync" "time" "github.com/google/uuid" "github.com/omnex/control-plane/api/internal/demos" ) var ( ErrNotFound = errors.New("projet introuvable") ErrNotExtendable = errors.New("projet non prolongeable dans son état actuel") ErrInvalidMonths = errors.New("la durée doit être comprise entre 1 et 12 mois") ErrInvalidClient = errors.New("nom du client requis (64 caractères maximum)") ErrInvalidAdmin = errors.New("identifiant admin invalide (3 à 64 caractères alphanumériques)") ErrWeakAdminPass = errors.New("mot de passe admin trop court (12 caractères minimum)") ErrInvalidAdminSize = errors.New("nombre d'admins invalide (1 à 20)") ) var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9]{3,64}$`) // Provisioner : ce que le service demande au cluster. Implémenté par // *demos.HelmProvisioner (voir demos/project_provisioner.go). type Provisioner interface { ProvisionProject(spec demos.ProjectSpec) error TeardownProject(namespace string) error ProjectState(ctx context.Context, namespace string) (demos.ResourceState, error) } // NoopProvisioner : aucun déploiement réel (dev en mémoire, tests). type NoopProvisioner struct{} func (NoopProvisioner) ProvisionProject(demos.ProjectSpec) error { return nil } func (NoopProvisioner) TeardownProject(string) error { return nil } func (NoopProvisioner) ProjectState(context.Context, string) (demos.ResourceState, error) { return demos.ResourceState{}, nil } // Config du service (injectable pour les tests). type Config struct { Domain string // domaine des projets : . } // CreateInput : saisie de l'admin Omnex au déploiement (dashboard). type CreateInput struct { ClientName string Months int AdminUsername string AdminPassword string AdminNumber int // 0 => DefaultAdminNumber } // Validate normalise et valide la saisie. func (in *CreateInput) Validate() error { in.ClientName = strings.TrimSpace(in.ClientName) if in.ClientName == "" || len(in.ClientName) > 64 { return ErrInvalidClient } if err := ValidateMonths(in.Months); err != nil { return err } if !adminUsernamePattern.MatchString(in.AdminUsername) { return ErrInvalidAdmin } if len(in.AdminPassword) < 12 { return ErrWeakAdminPass } if in.AdminNumber == 0 { in.AdminNumber = DefaultAdminNumber } if in.AdminNumber < 1 || in.AdminNumber > MaxAdminNumber { return ErrInvalidAdminSize } return nil } // ValidateMonths : durée d'abonnement autorisée, à la création comme à chaque // renouvellement. func ValidateMonths(months int) error { if months < MinMonths || months > MaxMonths { return ErrInvalidMonths } return nil } // Service : logique métier des projets (indépendante du transport HTTP). type Service struct { store Store prov Provisioner cfg Config now func() time.Time // horloge injectable // mu sérialise les changements d'état : évite qu'une prolongation, la // boucle d'expiration et la fin d'un déploiement s'écrasent mutuellement. mu sync.Mutex } func NewService(store Store, prov Provisioner, cfg Config) *Service { if cfg.Domain == "" { cfg.Domain = "vitrine-omnex.club" } return &Service{store: store, prov: prov, cfg: cfg, now: time.Now} } // Create enregistre le projet pour la durée souscrite et lance son déploiement // en arrière-plan (le mot de passe admin n'est jamais persisté). func (s *Service) Create(in CreateInput) (Project, error) { if err := in.Validate(); err != nil { return Project{}, err } id := uuid.NewString() now := s.now().UTC() expires := now.AddDate(0, in.Months, 0) ns := "vitrine-" + shortID(id) host := ns + "." + s.cfg.Domain p := Project{ ID: id, ClientName: in.ClientName, Status: StatusProvisioning, Namespace: ns, Host: host, URL: "https://" + host, AdminUsername: in.AdminUsername, AdminNumber: in.AdminNumber, MonthsPurchased: in.Months, CreatedAt: now, ExpiresAt: expires, } created, err := s.store.Create(p) if err != nil { return Project{}, err } spec := demos.ProjectSpec{ Namespace: created.Namespace, Host: created.Host, URL: created.URL, AdminUsername: in.AdminUsername, AdminPassword: in.AdminPassword, AdminNumber: created.AdminNumber, } go func() { err := s.prov.ProvisionProject(spec) // Ne réécrit que le statut : une prolongation reçue pendant le // déploiement ne doit pas être écrasée par la copie initiale. s.mu.Lock() defer s.mu.Unlock() current, ok := s.store.Get(created.ID) if !ok { return } if err != nil { log.Printf("Provisioning du projet %s échoué: %v", created.Namespace, err) current.Status = StatusFailed } else if current.Status == StatusProvisioning { current.Status = StatusReady } _, _ = s.store.Update(current) }() return created, nil } func (s *Service) Get(id string) (Project, error) { p, ok := s.store.Get(id) if !ok { return Project{}, ErrNotFound } return p, nil } func (s *Service) List() ([]Project, error) { return s.store.List() } // State : état live des composants du projet (phase + CPU/mémoire). Un projet // qui n'a pas (ou plus) de pods (échec, suppression) renvoie un état vide sans // interroger le cluster. func (s *Service) State(ctx context.Context, id string) (Project, demos.ResourceState, error) { p, ok := s.store.Get(id) if !ok { return Project{}, demos.ResourceState{}, ErrNotFound } if p.Status != StatusProvisioning && p.Status != StatusReady { return p, demos.ResourceState{}, nil } state, err := s.prov.ProjectState(ctx, p.Namespace) if err != nil { return p, demos.ResourceState{}, err } return p, state, nil } // Extend ajoute months mois à l'abonnement, à partir de l'échéance courante // (ou de maintenant si elle est dépassée). func (s *Service) Extend(id string, months int) (Project, error) { if err := ValidateMonths(months); err != nil { return Project{}, err } s.mu.Lock() defer s.mu.Unlock() p, ok := s.store.Get(id) if !ok { return Project{}, ErrNotFound } if !p.Status.Extendable() { return Project{}, ErrNotExtendable } now := s.now().UTC() base := p.ExpiresAt if base.Before(now) { base = now } p.ExpiresAt = base.AddDate(0, months, 0) p.MonthsPurchased += months return s.store.Update(p) } // Delete détruit le projet (namespace, données, sauvegardes) sans attendre // l'échéance — action explicite de l'admin. func (s *Service) Delete(id string) (Project, error) { s.mu.Lock() defer s.mu.Unlock() return s.deleteLocked(id) } func (s *Service) deleteLocked(id string) (Project, error) { p, ok := s.store.Get(id) if !ok { return Project{}, ErrNotFound } if p.Status == StatusDeleted { return p, nil // idempotent } p.Status = StatusDeleting if _, err := s.store.Update(p); err != nil { return Project{}, err } if err := s.prov.TeardownProject(p.Namespace); err != nil { return Project{}, err } p.Status = StatusDeleted return s.store.Update(p) } // RunExpirationLoop applique le cycle de vie à intervalle régulier jusqu'à // l'annulation de ctx : suppression des projets dont l'abonnement est échu. func (s *Service) RunExpirationLoop(ctx context.Context, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: s.expireOverdue() } } } func (s *Service) expireOverdue() { all, err := s.store.List() if err != nil { log.Printf("projets: liste échouée: %v", err) return } for _, p := range all { s.mu.Lock() s.applyLifecycle(p.ID) s.mu.Unlock() } } // applyLifecycle relit le projet (il a pu changer depuis List) et le supprime // si son abonnement est échu. À appeler avec s.mu tenu. func (s *Service) applyLifecycle(id string) { p, ok := s.store.Get(id) if !ok || p.Status != StatusReady || p.ExpiresAt.After(s.now().UTC()) { return } if _, err := s.deleteLocked(p.ID); err != nil { log.Printf("projets: suppression de %s échouée: %v", p.Namespace, err) return } log.Printf("projets: %s supprimé (abonnement échu)", p.Namespace) } // shortID : 8 premiers caractères hex d'un uuid pour un nom de namespace court. func shortID(id string) string { clean := strings.ReplaceAll(id, "-", "") if len(clean) > 8 { clean = clean[:8] } return clean }