Files
omnex/control-plane/api/internal/projects/service.go
T
Xor290 9e11f01c2c
ci-api / test (push) Failing after 14m25s
ci-web / test (push) Failing after 8m17s
chore: build
2026-09-20 16:14:29 +02:00

284 lines
7.5 KiB
Go

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
}
// 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 }
// Config du service (injectable pour les tests).
type Config struct {
Domain string // domaine des projets : <namespace>.<Domain>
}
// 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() }
// 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
}