chore: update
This commit is contained in:
@@ -19,9 +19,6 @@ OMNEX_ALLOWED_ORIGINS=http://localhost:5173
|
|||||||
# Redis (sessions). Vide en dev => sessions en mémoire volatile.
|
# Redis (sessions). Vide en dev => sessions en mémoire volatile.
|
||||||
# OMNEX_REDIS_URL=redis://:omnexredis@localhost:6379/0
|
# OMNEX_REDIS_URL=redis://:omnexredis@localhost:6379/0
|
||||||
|
|
||||||
# Seed de l'utilisateur admin de départ (optionnel) — nom d'utilisateur, pas email
|
|
||||||
OMNEX_SEED_USERNAME=admin
|
|
||||||
OMNEX_SEED_PASSWORD=change-me-strong-password
|
|
||||||
|
|
||||||
# Domaine des démos déployées dans le cluster
|
# Domaine des démos déployées dans le cluster
|
||||||
OMNEX_DEMO_DOMAIN=demo.omnex.app
|
OMNEX_DEMO_DOMAIN=demo.omnex.app
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/omnex/control-plane/api/internal/downloads"
|
"github.com/omnex/control-plane/api/internal/downloads"
|
||||||
"github.com/omnex/control-plane/api/internal/k8s"
|
"github.com/omnex/control-plane/api/internal/k8s"
|
||||||
"github.com/omnex/control-plane/api/internal/profile"
|
"github.com/omnex/control-plane/api/internal/profile"
|
||||||
|
"github.com/omnex/control-plane/api/internal/projects"
|
||||||
"github.com/omnex/control-plane/api/internal/router"
|
"github.com/omnex/control-plane/api/internal/router"
|
||||||
"github.com/omnex/control-plane/api/internal/session"
|
"github.com/omnex/control-plane/api/internal/session"
|
||||||
"github.com/omnex/control-plane/api/internal/sub"
|
"github.com/omnex/control-plane/api/internal/sub"
|
||||||
@@ -78,6 +79,7 @@ func main() {
|
|||||||
|
|
||||||
var userStore auth.UserStore
|
var userStore auth.UserStore
|
||||||
var demoStore demos.Store
|
var demoStore demos.Store
|
||||||
|
var projectStore projects.Store
|
||||||
var demoPool demos.Pool
|
var demoPool demos.Pool
|
||||||
var codeStore sub.Store
|
var codeStore sub.Store
|
||||||
var profileStore profile.Store
|
var profileStore profile.Store
|
||||||
@@ -96,19 +98,19 @@ func main() {
|
|||||||
if err := db.SeedExternalPool(gdb); err != nil {
|
if err := db.SeedExternalPool(gdb); err != nil {
|
||||||
log.Fatalf("seed pool: %v", err)
|
log.Fatalf("seed pool: %v", err)
|
||||||
}
|
}
|
||||||
users := auth.NewGormStore(gdb)
|
userStore = auth.NewGormStore(gdb)
|
||||||
seedAdmin(users)
|
|
||||||
userStore = users
|
|
||||||
codeStore = sub.NewGormStore(gdb)
|
codeStore = sub.NewGormStore(gdb)
|
||||||
demoStore = demos.NewGormStore(gdb)
|
demoStore = demos.NewGormStore(gdb)
|
||||||
|
projectStore = projects.NewGormStore(gdb)
|
||||||
demoPool = demos.NewGormPool(gdb)
|
demoPool = demos.NewGormPool(gdb)
|
||||||
profileStore = profile.NewGormStore(gdb)
|
profileStore = profile.NewGormStore(gdb)
|
||||||
alertRecipients = alerts.GormRecipients(gdb)
|
alertRecipients = alerts.GormRecipients(gdb)
|
||||||
log.Printf("persistance: PostgreSQL (GORM)")
|
log.Printf("persistance: PostgreSQL (GORM)")
|
||||||
} else {
|
} else {
|
||||||
userStore = seedMemUsers()
|
userStore = memUsers{}
|
||||||
codeStore = sub.NewMemStore()
|
codeStore = sub.NewMemStore()
|
||||||
demoStore = demos.NewMemStore()
|
demoStore = demos.NewMemStore()
|
||||||
|
projectStore = projects.NewMemStore()
|
||||||
demoPool = demos.NewMemPool()
|
demoPool = demos.NewMemPool()
|
||||||
log.Printf("persistance: mémoire (dev — définir OMNEX_DATABASE_URL pour PostgreSQL)")
|
log.Printf("persistance: mémoire (dev — définir OMNEX_DATABASE_URL pour PostgreSQL)")
|
||||||
}
|
}
|
||||||
@@ -166,6 +168,15 @@ func main() {
|
|||||||
// de ce fichier : pas de contexte annulable, s'arrête avec le processus.
|
// de ce fichier : pas de contexte annulable, s'arrête avec le processus.
|
||||||
go demoSvc.RunExpirationLoop(context.Background(), 5*time.Minute)
|
go demoSvc.RunExpirationLoop(context.Background(), 5*time.Minute)
|
||||||
|
|
||||||
|
// Projets vitrine (abonnements de 1 à 12 mois) : même cluster, mêmes
|
||||||
|
// sauvegardes Velero que les démos, mais suspension puis suppression après
|
||||||
|
// un délai de grâce à l'échéance (voir internal/projects).
|
||||||
|
projectSvc := projects.NewService(projectStore, helmProv, projects.Config{
|
||||||
|
Domain: cfg.VitrineDomain,
|
||||||
|
Grace: time.Duration(cfg.VitrineGraceDays) * 24 * time.Hour,
|
||||||
|
})
|
||||||
|
go projectSvc.RunExpirationLoop(context.Background(), 5*time.Minute)
|
||||||
|
|
||||||
// Alerting : surveille les pods des démos actives (état + seuils
|
// Alerting : surveille les pods des démos actives (état + seuils
|
||||||
// CPU/mémoire/PVC), notifie Discord et/ou Telegram de chaque admin ayant
|
// CPU/mémoire/PVC), notifie Discord et/ou Telegram de chaque admin ayant
|
||||||
// configuré son propre canal (voir profil admin, /profile/alerts).
|
// configuré son propre canal (voir profil admin, /profile/alerts).
|
||||||
@@ -182,6 +193,17 @@ func main() {
|
|||||||
ns = append(ns, d.Namespace)
|
ns = append(ns, d.Namespace)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Projets vitrine en ligne (un projet suspendu n'a plus de pods :
|
||||||
|
// l'alerte « pod absent » serait un faux positif).
|
||||||
|
ps, err := projectSvc.List()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, p := range ps {
|
||||||
|
if p.Status == projects.StatusReady {
|
||||||
|
ns = append(ns, p.Namespace)
|
||||||
|
}
|
||||||
|
}
|
||||||
return ns, nil
|
return ns, nil
|
||||||
})
|
})
|
||||||
go watcher.Run(context.Background())
|
go watcher.Run(context.Background())
|
||||||
@@ -196,6 +218,7 @@ func main() {
|
|||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
AuthH: auth.NewHandler(userStore, sessions, iss, cfg.Secure()),
|
AuthH: auth.NewHandler(userStore, sessions, iss, cfg.Secure()),
|
||||||
DemosH: demos.NewHandler(demoSvc, helmProv),
|
DemosH: demos.NewHandler(demoSvc, helmProv),
|
||||||
|
ProjectsH: projects.NewHandler(projectSvc),
|
||||||
SubH: sub.NewHandler(codeStore, demoSvc),
|
SubH: sub.NewHandler(codeStore, demoSvc),
|
||||||
ProfileH: profile.NewHandler(profileStore),
|
ProfileH: profile.NewHandler(profileStore),
|
||||||
DownloadsH: downloads.NewHandler(cfg.AppDownloadsDir, userStore, demoSvc),
|
DownloadsH: downloads.NewHandler(cfg.AppDownloadsDir, userStore, demoSvc),
|
||||||
@@ -225,34 +248,3 @@ func main() {
|
|||||||
log.Printf("extinction forcée après délai: %v", err)
|
log.Printf("extinction forcée après délai: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedAdmin crée l'utilisateur admin de départ depuis l'env (idempotent).
|
|
||||||
func seedAdmin(users *auth.GormStore) {
|
|
||||||
username, pass := os.Getenv("OMNEX_SEED_USERNAME"), os.Getenv("OMNEX_SEED_PASSWORD")
|
|
||||||
if username == "" || pass == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hash, err := auth.HashPassword(pass)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("seed: %v", err)
|
|
||||||
}
|
|
||||||
if err := users.EnsureUser(username, hash, auth.RoleAdmin); err != nil {
|
|
||||||
log.Fatalf("seed: %v", err)
|
|
||||||
}
|
|
||||||
log.Printf("utilisateur admin seedé: %s", username)
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedMemUsers construit un store mémoire depuis l'env (dev uniquement).
|
|
||||||
func seedMemUsers() memUsers {
|
|
||||||
users := memUsers{}
|
|
||||||
if username, pass := os.Getenv("OMNEX_SEED_USERNAME"), os.Getenv("OMNEX_SEED_PASSWORD"); username != "" && pass != "" {
|
|
||||||
hash, err := auth.HashPassword(pass)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("seed: %v", err)
|
|
||||||
}
|
|
||||||
key := strings.ToLower(strings.TrimSpace(username))
|
|
||||||
users[key] = auth.User{ID: "seed-admin", Username: key, PasswordHash: hash, Role: auth.RoleAdmin}
|
|
||||||
log.Printf("utilisateur admin seedé (mémoire): %s", key)
|
|
||||||
}
|
|
||||||
return users
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -45,12 +45,3 @@ func (s *GormStore) Create(username, passwordHash string, role Role) (User, erro
|
|||||||
}
|
}
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureUser crée l'utilisateur s'il n'existe pas encore (idempotent, pour le seed).
|
|
||||||
func (s *GormStore) EnsureUser(username, passwordHash string, role Role) error {
|
|
||||||
if _, found := s.ByUsername(username); found {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err := s.Create(username, passwordHash, role)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,30 +3,41 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Addr string
|
Addr string
|
||||||
JWTSecret []byte // OMNEX_JWT_SECRET (min 32 octets) — signe les tokens
|
JWTSecret []byte // OMNEX_JWT_SECRET (min 32 octets) — signe les tokens
|
||||||
SessionTTL time.Duration // durée de vie JWT + session Redis
|
SessionTTL time.Duration // durée de vie JWT + session Redis
|
||||||
AllowedOrigins []string // CORS (vitrine + back-office)
|
AllowedOrigins []string // CORS (vitrine + back-office)
|
||||||
Env string // "dev" | "prod"
|
Env string // "dev" | "prod"
|
||||||
DatabaseURL string // OMNEX_DATABASE_URL (DSN PostgreSQL GORM)
|
DatabaseURL string // OMNEX_DATABASE_URL (DSN PostgreSQL GORM)
|
||||||
RedisURL string // OMNEX_REDIS_URL (sessions), ex. redis://:pass@host:6379/0
|
RedisURL string // OMNEX_REDIS_URL (sessions), ex. redis://:pass@host:6379/0
|
||||||
DemoDomain string // domaine des démos
|
DemoDomain string // domaine des démos
|
||||||
DemoHTTPSPort string // port HTTPS public à afficher dans l'URL des démos (443 = omis, sinon ex. NodePort)
|
DemoHTTPSPort string // port HTTPS public à afficher dans l'URL des démos (443 = omis, sinon ex. NodePort)
|
||||||
Kubeconfig string
|
Kubeconfig string
|
||||||
FrontendImage string
|
FrontendImage string
|
||||||
BackendImage string
|
BackendImage string
|
||||||
LBTelegramImage string
|
LBTelegramImage string
|
||||||
AppDownloadsDir string // OMNEX_APP_DOWNLOADS_DIR : répertoire des .apk téléchargeables (voir internal/downloads)
|
AppDownloadsDir string // OMNEX_APP_DOWNLOADS_DIR : répertoire des .apk téléchargeables (voir internal/downloads)
|
||||||
// DockerHubUsername/Password : compte Docker Hub authentifié (droits
|
// DockerHubUsername/Password : compte Docker Hub authentifié (droits
|
||||||
// lecture seule) injecté dans chaque namespace de démo (voir
|
// lecture seule) injecté dans chaque namespace de démo (voir
|
||||||
// deploy/chart-gestion/registry-credentials) pour passer le rate-limit
|
// deploy/chart-gestion/registry-credentials) pour passer le rate-limit
|
||||||
// de pull anonyme (100/6h par IP) à 200/6h. "" = pull anonyme (défaut).
|
// de pull anonyme (100/6h par IP) à 200/6h. "" = pull anonyme (défaut).
|
||||||
DockerHubUsername string
|
DockerHubUsername string
|
||||||
DockerHubPassword string
|
DockerHubPassword string
|
||||||
|
|
||||||
|
// Projets vitrine (abonnements de 1 à 12 mois, voir internal/projects).
|
||||||
|
VitrineDomain string // OMNEX_VITRINE_DOMAIN : les projets sont servis sur <namespace>.<domaine>
|
||||||
|
VitrineChartsDir string // OMNEX_VITRINE_CHARTS_DIR : charts backend/frontend/postgresql/redis/ingressroute du projet vitrine
|
||||||
|
VitrineBackendImage string // VITRINE_BACKEND_IMAGE, ex. registry/vitrine-backend:1.0.0
|
||||||
|
VitrineFrontendImage string // VITRINE_FRONTEND_IMAGE
|
||||||
|
// VitrineGraceDays : jours pendant lesquels un projet dont l'abonnement
|
||||||
|
// a expiré reste suspendu (données et sauvegardes conservées) avant sa
|
||||||
|
// suppression définitive. Un renouvellement pendant ce délai le relance.
|
||||||
|
VitrineGraceDays int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load lit la config. Fail-secure : secret JWT obligatoire ; en prod base + Redis aussi.
|
// Load lit la config. Fail-secure : secret JWT obligatoire ; en prod base + Redis aussi.
|
||||||
@@ -53,6 +64,19 @@ func Load() (Config, error) {
|
|||||||
AppDownloadsDir: getenv("OMNEX_APP_DOWNLOADS_DIR", "/app-downloads"),
|
AppDownloadsDir: getenv("OMNEX_APP_DOWNLOADS_DIR", "/app-downloads"),
|
||||||
DockerHubUsername: os.Getenv("OMNEX_DOCKERHUB_USERNAME"),
|
DockerHubUsername: os.Getenv("OMNEX_DOCKERHUB_USERNAME"),
|
||||||
DockerHubPassword: os.Getenv("OMNEX_DOCKERHUB_PASSWORD"),
|
DockerHubPassword: os.Getenv("OMNEX_DOCKERHUB_PASSWORD"),
|
||||||
|
|
||||||
|
VitrineDomain: getenv("OMNEX_VITRINE_DOMAIN", "vitrine-omnex.club"),
|
||||||
|
VitrineChartsDir: getenv("OMNEX_VITRINE_CHARTS_DIR", "/charts-vitrine"),
|
||||||
|
VitrineBackendImage: os.Getenv("VITRINE_BACKEND_IMAGE"),
|
||||||
|
VitrineFrontendImage: os.Getenv("VITRINE_FRONTEND_IMAGE"),
|
||||||
|
VitrineGraceDays: 7,
|
||||||
|
}
|
||||||
|
if v := os.Getenv("OMNEX_VITRINE_GRACE_DAYS"); v != "" {
|
||||||
|
days, err := strconv.Atoi(v)
|
||||||
|
if err != nil || days < 0 || days > 365 {
|
||||||
|
return Config{}, errors.New("OMNEX_VITRINE_GRACE_DAYS doit être un entier entre 0 et 365")
|
||||||
|
}
|
||||||
|
cfg.VitrineGraceDays = days
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Env == "prod" {
|
if cfg.Env == "prod" {
|
||||||
|
|||||||
@@ -100,3 +100,28 @@ func TestSplitCSV(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoad_VitrineDefaults(t *testing.T) {
|
||||||
|
t.Setenv("OMNEX_JWT_SECRET", "0123456789abcdef0123456789abcdef")
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.VitrineDomain != "vitrine-omnex.club" || cfg.VitrineGraceDays != 7 || cfg.VitrineChartsDir != "/charts-vitrine" {
|
||||||
|
t.Errorf("défauts vitrine inattendus : %+v", cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_VitrineGraceDaysInvalid(t *testing.T) {
|
||||||
|
t.Setenv("OMNEX_JWT_SECRET", "0123456789abcdef0123456789abcdef")
|
||||||
|
for _, v := range []string{"abc", "-1", "366"} {
|
||||||
|
t.Setenv("OMNEX_VITRINE_GRACE_DAYS", v)
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Errorf("OMNEX_VITRINE_GRACE_DAYS=%q accepté", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Setenv("OMNEX_VITRINE_GRACE_DAYS", "0")
|
||||||
|
if cfg, err := Load(); err != nil || cfg.VitrineGraceDays != 0 {
|
||||||
|
t.Errorf("grâce de 0 jour refusée ou mal lue : %v %d", err, cfg.VitrineGraceDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/omnex/control-plane/api/internal/auth"
|
"github.com/omnex/control-plane/api/internal/auth"
|
||||||
"github.com/omnex/control-plane/api/internal/demos"
|
"github.com/omnex/control-plane/api/internal/demos"
|
||||||
|
"github.com/omnex/control-plane/api/internal/projects"
|
||||||
"github.com/omnex/control-plane/api/internal/sub"
|
"github.com/omnex/control-plane/api/internal/sub"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ func AutoMigrate(gdb *gorm.DB) error {
|
|||||||
&sub.CodeBuySub{},
|
&sub.CodeBuySub{},
|
||||||
&demos.Demo{},
|
&demos.Demo{},
|
||||||
&demos.ExternalResource{},
|
&demos.ExternalResource{},
|
||||||
|
&projects.Project{},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,17 +36,21 @@ import (
|
|||||||
// HelmProvisioner : implémente Provisioner en appelant l'exécutable Helm.
|
// HelmProvisioner : implémente Provisioner en appelant l'exécutable Helm.
|
||||||
// Nécessite que helm soit installé dans le container (ex: dans /usr/local/bin/helm).
|
// Nécessite que helm soit installé dans le container (ex: dans /usr/local/bin/helm).
|
||||||
type HelmProvisioner struct {
|
type HelmProvisioner struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
k8sClient *kubernetes.Clientset
|
k8sClient *kubernetes.Clientset
|
||||||
restConfig *rest.Config // pour l'exec dans les pods (pg_dump/restore)
|
restConfig *rest.Config // pour l'exec dans les pods (pg_dump/restore)
|
||||||
metricsClient *metricsclient.Clientset // optionnel : nil si metrics-server indisponible
|
metricsClient *metricsclient.Clientset // optionnel : nil si metrics-server indisponible
|
||||||
dynamicClient dynamic.Interface // pour les CRD Velero (Schedule/Backup), voir ensureDemoBackupSchedule
|
dynamicClient dynamic.Interface // pour les CRD Velero (Schedule/Backup), voir ensureDemoBackupSchedule
|
||||||
chartsDir string // chemin vers le dossier des charts (ex: /charts)
|
chartsDir string // chemin vers le dossier des charts (ex: /charts)
|
||||||
frontendImage string
|
// vitrineChartsDir : charts des projets vitrine (backend, frontend,
|
||||||
backendImage string
|
// postgresql, redis, ingressroute), distincts de chartsDir dont les
|
||||||
lbtelegramImage string
|
// charts de mêmes noms déploient le produit des démos.
|
||||||
baseDomain string
|
vitrineChartsDir string
|
||||||
helmPath string // chemin vers l'exécutable helm (default: "helm")
|
frontendImage string
|
||||||
|
backendImage string
|
||||||
|
lbtelegramImage string
|
||||||
|
baseDomain string
|
||||||
|
helmPath string // chemin vers l'exécutable helm (default: "helm")
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHelmProvisioner crée un nouveau provisioner Helm.
|
// NewHelmProvisioner crée un nouveau provisioner Helm.
|
||||||
@@ -79,17 +83,18 @@ func NewHelmProvisioner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &HelmProvisioner{
|
return &HelmProvisioner{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
k8sClient: k8sClient,
|
k8sClient: k8sClient,
|
||||||
restConfig: restConfig,
|
restConfig: restConfig,
|
||||||
metricsClient: metricsClient,
|
metricsClient: metricsClient,
|
||||||
dynamicClient: dynamicClient,
|
dynamicClient: dynamicClient,
|
||||||
chartsDir: chartsDir,
|
chartsDir: chartsDir,
|
||||||
frontendImage: cfg.FrontendImage,
|
vitrineChartsDir: cfg.VitrineChartsDir,
|
||||||
backendImage: cfg.BackendImage,
|
frontendImage: cfg.FrontendImage,
|
||||||
lbtelegramImage: cfg.LBTelegramImage,
|
backendImage: cfg.BackendImage,
|
||||||
baseDomain: cfg.DemoDomain,
|
lbtelegramImage: cfg.LBTelegramImage,
|
||||||
helmPath: helmPath,
|
baseDomain: cfg.DemoDomain,
|
||||||
|
helmPath: helmPath,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,10 +848,10 @@ func (h *HelmProvisioner) ensureDemoBackupSchedule(namespace string) error {
|
|||||||
"spec": map[string]interface{}{
|
"spec": map[string]interface{}{
|
||||||
"schedule": "*/15 * * * *",
|
"schedule": "*/15 * * * *",
|
||||||
"template": map[string]interface{}{
|
"template": map[string]interface{}{
|
||||||
"includedNamespaces": []interface{}{namespace},
|
"includedNamespaces": []interface{}{namespace},
|
||||||
"defaultVolumesToFsBackup": true,
|
"defaultVolumesToFsBackup": true,
|
||||||
"storageLocation": "default",
|
"storageLocation": "default",
|
||||||
"ttl": "24h0m0s",
|
"ttl": "24h0m0s",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -911,6 +916,13 @@ func (h *HelmProvisioner) deleteDemoBackups(namespace string) error {
|
|||||||
// avec des valeurs personnalisées. Release name : "<namespace>-<chartName>".
|
// avec des valeurs personnalisées. Release name : "<namespace>-<chartName>".
|
||||||
// Adapté aux charts par démo (namespace toujours neuf).
|
// Adapté aux charts par démo (namespace toujours neuf).
|
||||||
func (h *HelmProvisioner) installChart(namespace, chartName string, values map[string]interface{}) error {
|
func (h *HelmProvisioner) installChart(namespace, chartName string, values map[string]interface{}) error {
|
||||||
|
return h.installChartFrom(h.chartsDir, namespace, chartName, values)
|
||||||
|
}
|
||||||
|
|
||||||
|
// installChartFrom : comme installChart, avec un dossier de charts explicite
|
||||||
|
// (charts des démos : h.chartsDir ; charts des projets vitrine :
|
||||||
|
// h.vitrineChartsDir).
|
||||||
|
func (h *HelmProvisioner) installChartFrom(chartsDir, namespace, chartName string, values map[string]interface{}) error {
|
||||||
release := fmt.Sprintf("%s-%s", namespace, chartName)
|
release := fmt.Sprintf("%s-%s", namespace, chartName)
|
||||||
valuesFile, cleanup, err := h.writeValuesFile(namespace, chartName, values)
|
valuesFile, cleanup, err := h.writeValuesFile(namespace, chartName, values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -921,7 +933,7 @@ func (h *HelmProvisioner) installChart(namespace, chartName string, values map[s
|
|||||||
args := []string{
|
args := []string{
|
||||||
"install",
|
"install",
|
||||||
release,
|
release,
|
||||||
filepath.Join(h.chartsDir, chartName),
|
filepath.Join(chartsDir, chartName),
|
||||||
"--namespace", namespace,
|
"--namespace", namespace,
|
||||||
"--values", valuesFile,
|
"--values", valuesFile,
|
||||||
"--wait",
|
"--wait",
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package demos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Projets vitrine (voir internal/projects) : même infrastructure que les
|
||||||
|
// démos (namespace isolé, NetworkPolicy, ResourceQuota, Traefik + WAF
|
||||||
|
// partagés, sauvegardes Velero par namespace) mais avec les charts du
|
||||||
|
// produit vitrine (h.vitrineChartsDir) et une durée d'abonnement en mois.
|
||||||
|
// Le cycle de vie (durée, suspension, suppression) vit dans internal/projects ;
|
||||||
|
// ce fichier ne contient que ce qui touche au cluster.
|
||||||
|
|
||||||
|
// Noms de release/chart utilisés par les charts vitrine (dossier
|
||||||
|
// deploy/chart-vitrine). Le nom de Service produit par le chart est
|
||||||
|
// "<release>-<nom du chart>" avec release = "<namespace>-<chart>" (voir
|
||||||
|
// installChartFrom) : ces deux fonctions doivent rester synchronisées avec les
|
||||||
|
// defaults de deploy/chart-vitrine/ingressroute/templates/_helpers.tpl.
|
||||||
|
func vitrineBackendService(namespace string) string {
|
||||||
|
return namespace + "-backend-vitrine-backend"
|
||||||
|
}
|
||||||
|
|
||||||
|
func vitrineFrontendService(namespace string) string {
|
||||||
|
return namespace + "-frontend-vitrine-frontend"
|
||||||
|
}
|
||||||
|
|
||||||
|
// replicasBeforeSuspendAnnotation : nombre de réplicas d'une charge de travail
|
||||||
|
// avant sa suspension, pour la relancer à l'identique (voir SuspendProject).
|
||||||
|
const replicasBeforeSuspendAnnotation = "omnex.app/replicas-before-suspend"
|
||||||
|
|
||||||
|
// ProjectSpec : ce dont le provisioner a besoin pour déployer un projet
|
||||||
|
// vitrine. AdminPassword ne doit jamais être persisté : il ne transite que
|
||||||
|
// par le fichier de valeurs Helm temporaire puis le Secret k8s du chart.
|
||||||
|
type ProjectSpec struct {
|
||||||
|
Namespace string
|
||||||
|
Host string // nom d'hôte public, sans protocole (ex. vitrine-ab12cd34.vitrine-omnex.club)
|
||||||
|
URL string // URL publique complète (https://<Host>)
|
||||||
|
AdminUsername string
|
||||||
|
AdminPassword string
|
||||||
|
// AdminNumber : nombre maximum de comptes admin du backend (ADMIN_NUMBER),
|
||||||
|
// choisi par l'admin Omnex au déploiement.
|
||||||
|
AdminNumber int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvisionProject déploie un projet vitrine :
|
||||||
|
// 1. namespace + Schedule Velero (identique aux démos : voir
|
||||||
|
// ensureDemoBackupSchedule),
|
||||||
|
// 2. isolation réseau, quota, identifiants du registry (charts des démos),
|
||||||
|
// 3. postgresql, redis, backend (migrations + compte admin via hooks Helm),
|
||||||
|
// frontend puis ingressroute (charts vitrine).
|
||||||
|
//
|
||||||
|
// En cas d'échec, le namespace et le Schedule sont supprimés.
|
||||||
|
func (h *HelmProvisioner) ProvisionProject(p ProjectSpec) error {
|
||||||
|
ns := p.Namespace
|
||||||
|
// Sans image, les valeurs Helm écraseraient les défauts des charts par
|
||||||
|
// une chaîne vide : échec explicite avant de créer quoi que ce soit.
|
||||||
|
if h.cfg.VitrineBackendImage == "" || h.cfg.VitrineFrontendImage == "" {
|
||||||
|
return fmt.Errorf("VITRINE_BACKEND_IMAGE et VITRINE_FRONTEND_IMAGE doivent être définies")
|
||||||
|
}
|
||||||
|
if err := h.createNamespace(ns); err != nil {
|
||||||
|
return fmt.Errorf("création namespace %s: %w", ns, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-fatal : un projet doit pouvoir démarrer même si Velero est
|
||||||
|
// momentanément indisponible (même règle que pour les démos).
|
||||||
|
if err := h.ensureDemoBackupSchedule(ns); err != nil {
|
||||||
|
log.Printf("Warning: planning de sauvegarde échoué pour %s: %v", ns, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fail := func(step string, err error) error {
|
||||||
|
if derr := h.deleteDemoBackups(ns); derr != nil {
|
||||||
|
log.Printf("Warning: nettoyage des sauvegardes de %s après échec: %v", ns, derr)
|
||||||
|
}
|
||||||
|
_ = h.deleteNamespace(ns)
|
||||||
|
return fmt.Errorf("déploiement %s: %w", step, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Isolation réseau AVANT tout workload, plafond de ressources, puis
|
||||||
|
// identifiants du registry avant tout pull d'image : mêmes charts que les
|
||||||
|
// démos (h.chartsDir).
|
||||||
|
if err := h.installChart(ns, "network-policy", h.buildNetworkPolicyValues()); err != nil {
|
||||||
|
return fail("network-policy", err)
|
||||||
|
}
|
||||||
|
if err := h.installChart(ns, "resource-quota", nil); err != nil {
|
||||||
|
return fail("resource-quota", err)
|
||||||
|
}
|
||||||
|
if err := h.installChart(ns, "registry-credentials", h.buildRegistryCredentialsValues()); err != nil {
|
||||||
|
return fail("registry-credentials", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secrets générés par projet, jamais partagés ni codés en dur.
|
||||||
|
pgPassword := randomSecret()
|
||||||
|
redisPassword := randomSecret()
|
||||||
|
jwtSecret := randomSecret()
|
||||||
|
|
||||||
|
steps := []struct {
|
||||||
|
chart string
|
||||||
|
values map[string]interface{}
|
||||||
|
}{
|
||||||
|
{"postgresql", h.buildVitrinePostgresValues(pgPassword)},
|
||||||
|
{"redis", h.buildVitrineRedisValues(redisPassword)},
|
||||||
|
{"backend", h.buildVitrineBackendValues(p, pgPassword, redisPassword, jwtSecret)},
|
||||||
|
{"frontend", h.buildVitrineFrontendValues()},
|
||||||
|
{"ingressroute", h.buildVitrineIngressRouteValues(p)},
|
||||||
|
}
|
||||||
|
for _, s := range steps {
|
||||||
|
if err := h.installChartFrom(h.vitrineChartsDir, ns, s.chart, s.values); err != nil {
|
||||||
|
return fail(s.chart, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.waitForRollout(ns); err != nil {
|
||||||
|
// Le déploiement Helm a réussi (--wait --atomic) : on ne détruit pas
|
||||||
|
// pour un simple dépassement de délai de cette vérification.
|
||||||
|
log.Printf("Warning: rollout check échoué pour %s: %v", ns, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeardownProject supprime le namespace du projet (PVC compris) et ses
|
||||||
|
// sauvegardes Velero — même chemin que Teardown pour une démo.
|
||||||
|
func (h *HelmProvisioner) TeardownProject(namespace string) error {
|
||||||
|
return h.Teardown(Demo{Namespace: namespace})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuspendProject met à 0 les Deployment et StatefulSet du namespace : le
|
||||||
|
// projet n'est plus servi (Traefik répond 503) mais ses PVC, ses Secrets et
|
||||||
|
// son IngressRoute restent en place. Le nombre de réplicas d'origine est
|
||||||
|
// noté en annotation pour ResumeProject. Idempotent.
|
||||||
|
func (h *HelmProvisioner) SuspendProject(namespace string) error {
|
||||||
|
return h.scaleProject(namespace, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResumeProject relance ce que SuspendProject a arrêté, au nombre de
|
||||||
|
// réplicas d'origine (1 à défaut d'annotation). Idempotent.
|
||||||
|
func (h *HelmProvisioner) ResumeProject(namespace string) error {
|
||||||
|
return h.scaleProject(namespace, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HelmProvisioner) scaleProject(namespace string, up bool) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
deploys, err := h.k8sClient.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("liste des deployments de %s: %w", namespace, err)
|
||||||
|
}
|
||||||
|
for i := range deploys.Items {
|
||||||
|
d := &deploys.Items[i]
|
||||||
|
if d.Spec.Replicas == nil {
|
||||||
|
one := int32(1)
|
||||||
|
d.Spec.Replicas = &one
|
||||||
|
}
|
||||||
|
target, changed := nextReplicas(d.Spec.Replicas, &d.Annotations, up)
|
||||||
|
if !changed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d.Spec.Replicas = &target
|
||||||
|
if _, err := h.k8sClient.AppsV1().Deployments(namespace).Update(ctx, d, metav1.UpdateOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("mise à l'échelle du deployment %s: %w", d.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sets, err := h.k8sClient.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("liste des statefulsets de %s: %w", namespace, err)
|
||||||
|
}
|
||||||
|
for i := range sets.Items {
|
||||||
|
s := &sets.Items[i]
|
||||||
|
if s.Spec.Replicas == nil {
|
||||||
|
one := int32(1)
|
||||||
|
s.Spec.Replicas = &one
|
||||||
|
}
|
||||||
|
target, changed := nextReplicas(s.Spec.Replicas, &s.Annotations, up)
|
||||||
|
if !changed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Spec.Replicas = &target
|
||||||
|
if _, err := h.k8sClient.AppsV1().StatefulSets(namespace).Update(ctx, s, metav1.UpdateOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("mise à l'échelle du statefulset %s: %w", s.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextReplicas calcule le nombre de réplicas cible d'une charge de travail et
|
||||||
|
// met à jour ses annotations. changed=false : rien à faire (déjà dans l'état
|
||||||
|
// demandé).
|
||||||
|
func nextReplicas(current *int32, annotations *map[string]string, up bool) (target int32, changed bool) {
|
||||||
|
if !up {
|
||||||
|
if *current == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if *annotations == nil {
|
||||||
|
*annotations = map[string]string{}
|
||||||
|
}
|
||||||
|
(*annotations)[replicasBeforeSuspendAnnotation] = strconv.Itoa(int(*current))
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
|
if *current != 0 {
|
||||||
|
return *current, false
|
||||||
|
}
|
||||||
|
target = 1
|
||||||
|
if v, ok := (*annotations)[replicasBeforeSuspendAnnotation]; ok {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
target = int32(n)
|
||||||
|
}
|
||||||
|
delete(*annotations, replicasBeforeSuspendAnnotation)
|
||||||
|
}
|
||||||
|
return target, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HelmProvisioner) buildVitrinePostgresValues(password string) map[string]interface{} {
|
||||||
|
// Nom de base et utilisateur : défauts du chart (vitrine_db / postgres),
|
||||||
|
// alignés sur database.name / database.user du chart backend.
|
||||||
|
return map[string]interface{}{
|
||||||
|
"imagePullSecrets": h.imagePullSecretsValue(),
|
||||||
|
"auth": map[string]interface{}{"password": password},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HelmProvisioner) buildVitrineRedisValues(password string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"imagePullSecrets": h.imagePullSecretsValue(),
|
||||||
|
"auth": map[string]interface{}{"password": password},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HelmProvisioner) buildVitrineBackendValues(p ProjectSpec, pgPassword, redisPassword, jwtSecret string) map[string]interface{} {
|
||||||
|
repo, tag := parseImage(h.cfg.VitrineBackendImage)
|
||||||
|
return map[string]interface{}{
|
||||||
|
"replicaCount": 1, // volumes ReadWriteOnce : un seul replica (voir le chart)
|
||||||
|
"imagePullSecrets": h.imagePullSecretsValue(),
|
||||||
|
"image": map[string]interface{}{
|
||||||
|
"repository": repo,
|
||||||
|
"tag": tag,
|
||||||
|
"pullPolicy": "Always",
|
||||||
|
},
|
||||||
|
"autoscaling": map[string]interface{}{"enabled": false},
|
||||||
|
"env": map[string]interface{}{
|
||||||
|
"ADMIN_NUMBER": strconv.Itoa(p.AdminNumber),
|
||||||
|
"SEED_ADMIN_USERNAME": p.AdminUsername,
|
||||||
|
"MEDIA_LOCAL_BASE_URL": p.URL + "/uploads",
|
||||||
|
},
|
||||||
|
"secrets": map[string]interface{}{
|
||||||
|
"JWT_SECRET": jwtSecret,
|
||||||
|
"DB_PASSWORD": pgPassword,
|
||||||
|
"REDIS_PASSWORD": redisPassword,
|
||||||
|
"SEED_ADMIN_PASSWORD": p.AdminPassword,
|
||||||
|
},
|
||||||
|
// Compte admin créé par le hook post-install du chart (jamais rejoué
|
||||||
|
// à l'upgrade : cmd/seed n'est pas idempotent).
|
||||||
|
"seedJob": map[string]interface{}{"enabled": true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HelmProvisioner) buildVitrineFrontendValues() map[string]interface{} {
|
||||||
|
repo, tag := parseImage(h.cfg.VitrineFrontendImage)
|
||||||
|
return map[string]interface{}{
|
||||||
|
"replicaCount": 1,
|
||||||
|
"imagePullSecrets": h.imagePullSecretsValue(),
|
||||||
|
"image": map[string]interface{}{
|
||||||
|
"repository": repo,
|
||||||
|
"tag": tag,
|
||||||
|
"pullPolicy": "Always",
|
||||||
|
},
|
||||||
|
"autoscaling": map[string]interface{}{"enabled": false},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HelmProvisioner) buildVitrineIngressRouteValues(p ProjectSpec) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"host": p.Host,
|
||||||
|
"traefikNamespace": sharedTraefikNamespace,
|
||||||
|
"backend": map[string]interface{}{"serviceName": vitrineBackendService(p.Namespace)},
|
||||||
|
"frontend": map[string]interface{}{"serviceName": vitrineFrontendService(p.Namespace)},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package demos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.yaml.in/yaml/v2"
|
||||||
|
|
||||||
|
"github.com/omnex/control-plane/api/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|
||||||
|
func TestNextReplicas_SuspendThenResumeRestoresOriginalCount(t *testing.T) {
|
||||||
|
annotations := map[string]string{}
|
||||||
|
current := ptr(int32(3))
|
||||||
|
|
||||||
|
target, changed := nextReplicas(current, &annotations, false)
|
||||||
|
if !changed || target != 0 {
|
||||||
|
t.Fatalf("suspension: target=%d changed=%v", target, changed)
|
||||||
|
}
|
||||||
|
if annotations[replicasBeforeSuspendAnnotation] != "3" {
|
||||||
|
t.Fatalf("annotation=%q, attendu 3", annotations[replicasBeforeSuspendAnnotation])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Déjà à 0 : suspendre à nouveau ne doit pas écraser l'annotation.
|
||||||
|
current = ptr(int32(0))
|
||||||
|
if _, changed := nextReplicas(current, &annotations, false); changed {
|
||||||
|
t.Error("suspension répétée non idempotente")
|
||||||
|
}
|
||||||
|
if annotations[replicasBeforeSuspendAnnotation] != "3" {
|
||||||
|
t.Error("l'annotation a été écrasée")
|
||||||
|
}
|
||||||
|
|
||||||
|
target, changed = nextReplicas(current, &annotations, true)
|
||||||
|
if !changed || target != 3 {
|
||||||
|
t.Fatalf("relance: target=%d changed=%v, attendu 3", target, changed)
|
||||||
|
}
|
||||||
|
if _, still := annotations[replicasBeforeSuspendAnnotation]; still {
|
||||||
|
t.Error("annotation non nettoyée après la relance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextReplicas_ResumeWithoutAnnotationDefaultsToOne(t *testing.T) {
|
||||||
|
annotations := map[string]string{}
|
||||||
|
target, changed := nextReplicas(ptr(int32(0)), &annotations, true)
|
||||||
|
if !changed || target != 1 {
|
||||||
|
t.Errorf("target=%d changed=%v, attendu 1", target, changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextReplicas_ResumeRunningWorkloadIsNoop(t *testing.T) {
|
||||||
|
annotations := map[string]string{}
|
||||||
|
if _, changed := nextReplicas(ptr(int32(2)), &annotations, true); changed {
|
||||||
|
t.Error("relance d'une charge déjà active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVitrineValuesRenderWithCharts génère les valeurs comme ProvisionProject
|
||||||
|
// puis rend les charts de deploy/chart-vitrine avec helm : garantit que les
|
||||||
|
// clés attendues existent, que les validations des charts passent et que les
|
||||||
|
// noms de Services calculés côté Go sont bien ceux des charts.
|
||||||
|
func TestVitrineValuesRenderWithCharts(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("helm"); err != nil {
|
||||||
|
t.Skip("helm absent du PATH")
|
||||||
|
}
|
||||||
|
chartsDir, err := filepath.Abs("../../../../deploy/chart-vitrine")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &HelmProvisioner{cfg: &config.Config{
|
||||||
|
VitrineBackendImage: "registry.example/vitrine-backend:1.2.3",
|
||||||
|
VitrineFrontendImage: "registry.example/vitrine-frontend:1.2.3",
|
||||||
|
}}
|
||||||
|
p := ProjectSpec{
|
||||||
|
Namespace: "vitrine-ab12cd34",
|
||||||
|
Host: "vitrine-ab12cd34.vitrine-omnex.club",
|
||||||
|
URL: "https://vitrine-ab12cd34.vitrine-omnex.club",
|
||||||
|
AdminUsername: "admin",
|
||||||
|
AdminPassword: "un-mot-de-passe-solide",
|
||||||
|
AdminNumber: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
chart string
|
||||||
|
values map[string]interface{}
|
||||||
|
wants []string
|
||||||
|
}{
|
||||||
|
{"postgresql", h.buildVitrinePostgresValues("pgpass"), []string{"name: postgres\n"}},
|
||||||
|
{"redis", h.buildVitrineRedisValues("redispass"), []string{"name: redis\n"}},
|
||||||
|
{"backend", h.buildVitrineBackendValues(p, "pgpass", "redispass", "jwt"), []string{
|
||||||
|
"name: " + vitrineBackendService(p.Namespace) + "\n",
|
||||||
|
`ADMIN_NUMBER: "3"`,
|
||||||
|
`SEED_ADMIN_USERNAME: "admin"`,
|
||||||
|
"registry.example/vitrine-backend:1.2.3",
|
||||||
|
"post-install", // seedJob activé
|
||||||
|
"postgres://postgres:pgpass@postgres:5432/vitrine_db",
|
||||||
|
}},
|
||||||
|
{"frontend", h.buildVitrineFrontendValues(), []string{
|
||||||
|
"name: " + vitrineFrontendService(p.Namespace) + "\n",
|
||||||
|
"registry.example/vitrine-frontend:1.2.3",
|
||||||
|
}},
|
||||||
|
{"ingressroute", h.buildVitrineIngressRouteValues(p), []string{
|
||||||
|
"Host(`vitrine-ab12cd34.vitrine-omnex.club`)",
|
||||||
|
"name: " + vitrineBackendService(p.Namespace) + "\n",
|
||||||
|
"name: " + vitrineFrontendService(p.Namespace) + "\n",
|
||||||
|
"tls: {}", // certificat servi par le TLSStore, pas par projet
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.chart, func(t *testing.T) {
|
||||||
|
raw, err := yaml.Marshal(c.values)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
valuesFile := filepath.Join(t.TempDir(), "values.yaml")
|
||||||
|
if err := writeFile(valuesFile, raw); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Release et namespace comme installChartFrom : "<ns>-<chart>".
|
||||||
|
out, err := exec.Command("helm", "template", p.Namespace+"-"+c.chart,
|
||||||
|
filepath.Join(chartsDir, c.chart), "--namespace", p.Namespace, "--values", valuesFile).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("helm template: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
for _, want := range c.wants {
|
||||||
|
if !strings.Contains(string(out), want) {
|
||||||
|
t.Errorf("rendu de %s : %q introuvable", c.chart, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(path string, data []byte) error { return os.WriteFile(path, data, 0o600) }
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct{ svc *Service }
|
||||||
|
|
||||||
|
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||||
|
|
||||||
|
type createRequest struct {
|
||||||
|
ClientName string `json:"client_name" binding:"required,min=1,max=64"`
|
||||||
|
// Months : durée souscrite, 1 à 12.
|
||||||
|
Months int `json:"months" binding:"required,min=1,max=12"`
|
||||||
|
AdminUsername string `json:"admin_username" binding:"required,min=3,max=64,alphanum"`
|
||||||
|
AdminPassword string `json:"admin_password" binding:"required,min=12"`
|
||||||
|
// AdminNumber : nombre maximum de comptes admin (ADMIN_NUMBER), 1 par défaut.
|
||||||
|
AdminNumber int `json:"admin_number" binding:"omitempty,min=1,max=20"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type extendRequest struct {
|
||||||
|
Months int `json:"months" binding:"required,min=1,max=12"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writeError(c *gin.Context, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, ErrNotExtendable):
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, ErrInvalidMonths), errors.Is(err, ErrInvalidClient), errors.Is(err, ErrInvalidAdmin),
|
||||||
|
errors.Is(err, ErrWeakAdminPass), errors.Is(err, ErrInvalidAdminSize):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create : POST /projects — déploie un projet pour la durée souscrite
|
||||||
|
// (202 Accepted, déploiement asynchrone).
|
||||||
|
func (h *Handler) Create(c *gin.Context) {
|
||||||
|
var req createRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := h.svc.Create(CreateInput{
|
||||||
|
ClientName: req.ClientName,
|
||||||
|
Months: req.Months,
|
||||||
|
AdminUsername: req.AdminUsername,
|
||||||
|
AdminPassword: req.AdminPassword,
|
||||||
|
AdminNumber: req.AdminNumber,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List : GET /projects.
|
||||||
|
func (h *Handler) List(c *gin.Context) {
|
||||||
|
items, err := h.svc.List()
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get : GET /projects/:id.
|
||||||
|
func (h *Handler) Get(c *gin.Context) {
|
||||||
|
p, err := h.svc.Get(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend : POST /projects/:id/extend {"months": 1..12} — renouvelle
|
||||||
|
// l'abonnement (relance un projet suspendu).
|
||||||
|
func (h *Handler) Extend(c *gin.Context) {
|
||||||
|
var req extendRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": ErrInvalidMonths.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := h.svc.Extend(c.Param("id"), req.Months)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete : DELETE /projects/:id — suppression immédiate (namespace, données,
|
||||||
|
// sauvegardes).
|
||||||
|
func (h *Handler) Delete(c *gin.Context) {
|
||||||
|
p, err := h.svc.Delete(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, p)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestRouter(t *testing.T) (*gin.Engine, *fakeProv) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
svc, prov, _ := newTestService(t)
|
||||||
|
h := NewHandler(svc)
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/projects", h.Create)
|
||||||
|
r.GET("/projects", h.List)
|
||||||
|
r.GET("/projects/:id", h.Get)
|
||||||
|
r.DELETE("/projects/:id", h.Delete)
|
||||||
|
r.POST("/projects/:id/extend", h.Extend)
|
||||||
|
return r, prov
|
||||||
|
}
|
||||||
|
|
||||||
|
func do(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
const goodBody = `{"client_name":"Dupont","months":%s,"admin_username":"admin","admin_password":"un-mot-de-passe-solide"}`
|
||||||
|
|
||||||
|
func body(months string) string { return strings.Replace(goodBody, "%s", months, 1) }
|
||||||
|
|
||||||
|
func TestHandlerCreate_MonthsBounds(t *testing.T) {
|
||||||
|
r, _ := newTestRouter(t)
|
||||||
|
for _, months := range []string{"0", "13", "-1", `"x"`} {
|
||||||
|
if w := do(r, "POST", "/projects", body(months)); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("months=%s : HTTP %d, attendu 400", months, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, months := range []string{"1", "12"} {
|
||||||
|
w := do(r, "POST", "/projects", body(months))
|
||||||
|
if w.Code != http.StatusAccepted {
|
||||||
|
t.Errorf("months=%s : HTTP %d, attendu 202 (%s)", months, w.Code, w.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerCreate_NeverEchoesAdminPassword(t *testing.T) {
|
||||||
|
r, prov := newTestRouter(t)
|
||||||
|
w := do(r, "POST", "/projects", body("2"))
|
||||||
|
if w.Code != http.StatusAccepted {
|
||||||
|
t.Fatalf("HTTP %d: %s", w.Code, w.Body)
|
||||||
|
}
|
||||||
|
<-prov.provisioned
|
||||||
|
if strings.Contains(w.Body.String(), "un-mot-de-passe-solide") {
|
||||||
|
t.Error("le mot de passe admin figure dans la réponse")
|
||||||
|
}
|
||||||
|
if list := do(r, "GET", "/projects", ""); strings.Contains(list.Body.String(), "un-mot-de-passe-solide") {
|
||||||
|
t.Error("le mot de passe admin figure dans la liste")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerCreate_RejectsWeakPasswordAndBadAdminName(t *testing.T) {
|
||||||
|
r, _ := newTestRouter(t)
|
||||||
|
for name, payload := range map[string]string{
|
||||||
|
"mot de passe court": `{"client_name":"D","months":1,"admin_username":"admin","admin_password":"court"}`,
|
||||||
|
"admin non alphanum": `{"client_name":"D","months":1,"admin_username":"ad min","admin_password":"un-mot-de-passe-solide"}`,
|
||||||
|
"client manquant": `{"months":1,"admin_username":"admin","admin_password":"un-mot-de-passe-solide"}`,
|
||||||
|
"trop d'admins": `{"client_name":"D","months":1,"admin_username":"admin","admin_password":"un-mot-de-passe-solide","admin_number":21}`,
|
||||||
|
} {
|
||||||
|
if w := do(r, "POST", "/projects", payload); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("%s : HTTP %d, attendu 400", name, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerExtend_BoundsAndNotFound(t *testing.T) {
|
||||||
|
r, _ := newTestRouter(t)
|
||||||
|
if w := do(r, "POST", "/projects/inconnu/extend", `{"months":2}`); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("projet inconnu : HTTP %d, attendu 404", w.Code)
|
||||||
|
}
|
||||||
|
if w := do(r, "POST", "/projects/inconnu/extend", `{"months":13}`); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("13 mois : HTTP %d, attendu 400", w.Code)
|
||||||
|
}
|
||||||
|
if w := do(r, "POST", "/projects/inconnu/extend", `{}`); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("sans durée : HTTP %d, attendu 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Package projects : cycle de vie des projets vitrine déployés pour un client
|
||||||
|
// sur abonnement de 1 à 12 mois (un projet = un namespace isolé).
|
||||||
|
//
|
||||||
|
// Contrairement aux démos (TTL fixe de 30 jours, détruites à l'échéance), un
|
||||||
|
// projet payé est suspendu à l'échéance puis supprimé seulement après un
|
||||||
|
// délai de grâce : un renouvellement en retard ne détruit pas les données du
|
||||||
|
// client. Le paiement est hors périmètre : l'admin Omnex saisit la durée
|
||||||
|
// souscrite au déploiement puis à chaque renouvellement.
|
||||||
|
package projects
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
MinMonths = 1
|
||||||
|
MaxMonths = 12
|
||||||
|
// DefaultAdminNumber : nombre de comptes admin autorisés dans le backend
|
||||||
|
// déployé (ADMIN_NUMBER) quand l'admin Omnex n'en précise pas.
|
||||||
|
DefaultAdminNumber = 1
|
||||||
|
MaxAdminNumber = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status : état d'un projet.
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending Status = "pending" // créé, pas encore pris par le worker
|
||||||
|
StatusProvisioning Status = "provisioning" // déploiement en cours
|
||||||
|
StatusReady Status = "ready" // en ligne
|
||||||
|
StatusSuspended Status = "suspended" // abonnement échu : arrêté, données conservées jusqu'à DeleteAt
|
||||||
|
StatusDeleting Status = "deleting" // suppression en cours
|
||||||
|
StatusDeleted Status = "deleted" // supprimé
|
||||||
|
StatusFailed Status = "failed" // échec du déploiement
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extendable : un abonnement peut être prolongé (et un projet suspendu relancé).
|
||||||
|
func (s Status) Extendable() bool {
|
||||||
|
switch s {
|
||||||
|
case StatusProvisioning, StatusReady, StatusSuspended:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||||
|
ClientName string `gorm:"size:64;not null;index" json:"client_name"`
|
||||||
|
Status Status `gorm:"size:20;not null;index" json:"status"`
|
||||||
|
Namespace string `gorm:"size:63;uniqueIndex" json:"namespace"`
|
||||||
|
Host string `gorm:"size:255" json:"host"`
|
||||||
|
URL string `gorm:"size:255" json:"url"`
|
||||||
|
|
||||||
|
// AdminUsername : compte admin créé dans le backend déployé (le mot de
|
||||||
|
// passe n'est jamais persisté). AdminNumber : ADMIN_NUMBER du backend.
|
||||||
|
AdminUsername string `gorm:"size:64" json:"admin_username"`
|
||||||
|
AdminNumber int `json:"admin_number"`
|
||||||
|
|
||||||
|
// MonthsPurchased : total des mois souscrits (création + renouvellements).
|
||||||
|
MonthsPurchased int `json:"months_purchased"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
// ExpiresAt : fin de l'abonnement (le projet est alors suspendu).
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
// DeleteAt : suppression définitive si l'abonnement n'est pas renouvelé
|
||||||
|
// (ExpiresAt + délai de grâce).
|
||||||
|
DeleteAt time.Time `json:"delete_at"`
|
||||||
|
SuspendedAt *time.Time `json:"suspended_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Project) TableName() string { return "projects" }
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
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
|
||||||
|
SuspendProject(namespace string) error
|
||||||
|
ResumeProject(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 }
|
||||||
|
func (NoopProvisioner) SuspendProject(string) error { return nil }
|
||||||
|
func (NoopProvisioner) ResumeProject(string) error { return nil }
|
||||||
|
|
||||||
|
// Config du service (injectable pour les tests).
|
||||||
|
type Config struct {
|
||||||
|
Domain string // domaine des projets : <namespace>.<Domain>
|
||||||
|
Grace time.Duration // délai entre l'échéance (suspension) et la suppression
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
DeleteAt: expires.Add(s.cfg.Grace),
|
||||||
|
}
|
||||||
|
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). Un projet suspendu est relancé.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Status == StatusSuspended {
|
||||||
|
if err := s.prov.ResumeProject(p.Namespace); err != nil {
|
||||||
|
return Project{}, err
|
||||||
|
}
|
||||||
|
p.Status = StatusReady
|
||||||
|
p.SuspendedAt = nil
|
||||||
|
}
|
||||||
|
p.ExpiresAt = base.AddDate(0, months, 0)
|
||||||
|
p.DeleteAt = p.ExpiresAt.Add(s.cfg.Grace)
|
||||||
|
p.MonthsPurchased += months
|
||||||
|
return s.store.Update(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete détruit le projet (namespace, données, sauvegardes) sans attendre le
|
||||||
|
// délai de grâce — 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 : suspension à l'échéance, suppression à DeleteAt.
|
||||||
|
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 applique la
|
||||||
|
// transition due. À appeler avec s.mu tenu.
|
||||||
|
func (s *Service) applyLifecycle(id string) {
|
||||||
|
p, ok := s.store.Get(id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := s.now().UTC()
|
||||||
|
switch p.Status {
|
||||||
|
case StatusReady:
|
||||||
|
if p.ExpiresAt.After(now) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.prov.SuspendProject(p.Namespace); err != nil {
|
||||||
|
log.Printf("projets: suspension de %s échouée: %v", p.Namespace, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Status = StatusSuspended
|
||||||
|
p.SuspendedAt = &now
|
||||||
|
if _, err := s.store.Update(p); err != nil {
|
||||||
|
log.Printf("projets: enregistrement de la suspension de %s échoué: %v", p.Namespace, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("projets: %s suspendu (abonnement échu, suppression le %s)", p.Namespace, p.DeleteAt.Format(time.RFC3339))
|
||||||
|
case StatusSuspended:
|
||||||
|
if p.DeleteAt.After(now) {
|
||||||
|
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é (délai de grâce dépassé)", 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/omnex/control-plane/api/internal/demos"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeProv enregistre les appels au cluster ; provisioned est fermé une fois
|
||||||
|
// le déploiement (asynchrone) terminé.
|
||||||
|
type fakeProv struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
specs []demos.ProjectSpec
|
||||||
|
suspended []string
|
||||||
|
resumed []string
|
||||||
|
tornDown []string
|
||||||
|
provErr error
|
||||||
|
resumeErr error
|
||||||
|
provisioned chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeProv() *fakeProv { return &fakeProv{provisioned: make(chan struct{}, 8)} }
|
||||||
|
|
||||||
|
func (f *fakeProv) ProvisionProject(s demos.ProjectSpec) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
f.specs = append(f.specs, s)
|
||||||
|
f.mu.Unlock()
|
||||||
|
defer func() { f.provisioned <- struct{}{} }()
|
||||||
|
return f.provErr
|
||||||
|
}
|
||||||
|
func (f *fakeProv) TeardownProject(ns string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.tornDown = append(f.tornDown, ns)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeProv) SuspendProject(ns string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.suspended = append(f.suspended, ns)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (f *fakeProv) ResumeProject(ns string) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.resumed = append(f.resumed, ns)
|
||||||
|
return f.resumeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
var t0 = time.Date(2026, time.September, 20, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func newTestService(t *testing.T) (*Service, *fakeProv, *time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
prov := newFakeProv()
|
||||||
|
svc := NewService(NewMemStore(), prov, Config{Domain: "vitrine-omnex.club", Grace: 7 * 24 * time.Hour})
|
||||||
|
now := t0
|
||||||
|
svc.now = func() time.Time { return now }
|
||||||
|
return svc, prov, &now
|
||||||
|
}
|
||||||
|
|
||||||
|
func validInput() CreateInput {
|
||||||
|
return CreateInput{ClientName: "Boutique Dupont", Months: 3, AdminUsername: "admin", AdminPassword: "un-mot-de-passe-solide"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createReady crée un projet et attend la fin de son déploiement simulé.
|
||||||
|
func createReady(t *testing.T, svc *Service, prov *fakeProv, in CreateInput) Project {
|
||||||
|
t.Helper()
|
||||||
|
p, err := svc.Create(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create: %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-prov.provisioned:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("déploiement simulé non terminé")
|
||||||
|
}
|
||||||
|
// La goroutine de Create met le statut à jour après ProvisionProject :
|
||||||
|
// prendre le verrou attend qu'elle ait fini.
|
||||||
|
svc.mu.Lock()
|
||||||
|
svc.mu.Unlock() //nolint:staticcheck // barrière de synchronisation
|
||||||
|
got, err := svc.Get(p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate_MonthsBounds(t *testing.T) {
|
||||||
|
svc, _, _ := newTestService(t)
|
||||||
|
for _, months := range []int{-1, 0, 13, 100} {
|
||||||
|
in := validInput()
|
||||||
|
in.Months = months
|
||||||
|
if _, err := svc.Create(in); !errors.Is(err, ErrInvalidMonths) {
|
||||||
|
t.Errorf("months=%d : erreur %v, attendu ErrInvalidMonths", months, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate_ExpiryIsCalendarMonths(t *testing.T) {
|
||||||
|
for _, months := range []int{1, 6, 12} {
|
||||||
|
svc, prov, _ := newTestService(t)
|
||||||
|
in := validInput()
|
||||||
|
in.Months = months
|
||||||
|
p := createReady(t, svc, prov, in)
|
||||||
|
want := t0.AddDate(0, months, 0)
|
||||||
|
if !p.ExpiresAt.Equal(want) {
|
||||||
|
t.Errorf("months=%d : ExpiresAt=%v, attendu %v", months, p.ExpiresAt, want)
|
||||||
|
}
|
||||||
|
if !p.DeleteAt.Equal(want.Add(7 * 24 * time.Hour)) {
|
||||||
|
t.Errorf("months=%d : DeleteAt=%v (grâce de 7 jours attendue)", months, p.DeleteAt)
|
||||||
|
}
|
||||||
|
if p.MonthsPurchased != months {
|
||||||
|
t.Errorf("MonthsPurchased=%d, attendu %d", p.MonthsPurchased, months)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate_ValidatesInput(t *testing.T) {
|
||||||
|
svc, _, _ := newTestService(t)
|
||||||
|
cases := map[string]struct {
|
||||||
|
mutate func(*CreateInput)
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
"client vide": {func(i *CreateInput) { i.ClientName = " " }, ErrInvalidClient},
|
||||||
|
"admin invalide": {func(i *CreateInput) { i.AdminUsername = "a b" }, ErrInvalidAdmin},
|
||||||
|
"admin trop court": {func(i *CreateInput) { i.AdminUsername = "ab" }, ErrInvalidAdmin},
|
||||||
|
"mot de passe": {func(i *CreateInput) { i.AdminPassword = "court" }, ErrWeakAdminPass},
|
||||||
|
"nb admin trop": {func(i *CreateInput) { i.AdminNumber = 21 }, ErrInvalidAdminSize},
|
||||||
|
"nb admin négatif": {func(i *CreateInput) { i.AdminNumber = -1 }, ErrInvalidAdminSize},
|
||||||
|
}
|
||||||
|
for name, c := range cases {
|
||||||
|
in := validInput()
|
||||||
|
c.mutate(&in)
|
||||||
|
if _, err := svc.Create(in); !errors.Is(err, c.want) {
|
||||||
|
t.Errorf("%s : erreur %v, attendu %v", name, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate_PassesAdminSettingsToProvisioner(t *testing.T) {
|
||||||
|
svc, prov, _ := newTestService(t)
|
||||||
|
in := validInput()
|
||||||
|
in.AdminNumber = 3
|
||||||
|
p := createReady(t, svc, prov, in)
|
||||||
|
|
||||||
|
if len(prov.specs) != 1 {
|
||||||
|
t.Fatalf("%d déploiements, attendu 1", len(prov.specs))
|
||||||
|
}
|
||||||
|
spec := prov.specs[0]
|
||||||
|
if spec.AdminNumber != 3 || spec.AdminUsername != "admin" || spec.AdminPassword != in.AdminPassword {
|
||||||
|
t.Errorf("spec inattendue : %+v", spec)
|
||||||
|
}
|
||||||
|
if spec.Host != p.Namespace+".vitrine-omnex.club" || spec.URL != "https://"+spec.Host {
|
||||||
|
t.Errorf("host/URL inattendus : %q %q", spec.Host, spec.URL)
|
||||||
|
}
|
||||||
|
if p.Status != StatusReady {
|
||||||
|
t.Errorf("statut %s, attendu ready", p.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate_DefaultAdminNumberIsOne(t *testing.T) {
|
||||||
|
svc, prov, _ := newTestService(t)
|
||||||
|
p := createReady(t, svc, prov, validInput())
|
||||||
|
if p.AdminNumber != 1 || prov.specs[0].AdminNumber != 1 {
|
||||||
|
t.Errorf("AdminNumber=%d / spec=%d, attendu 1", p.AdminNumber, prov.specs[0].AdminNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreate_ProvisionFailureMarksFailed(t *testing.T) {
|
||||||
|
svc, prov, _ := newTestService(t)
|
||||||
|
prov.provErr = errors.New("helm en échec")
|
||||||
|
p := createReady(t, svc, prov, validInput())
|
||||||
|
if p.Status != StatusFailed {
|
||||||
|
t.Errorf("statut %s, attendu failed", p.Status)
|
||||||
|
}
|
||||||
|
if _, err := svc.Extend(p.ID, 1); !errors.Is(err, ErrNotExtendable) {
|
||||||
|
t.Errorf("Extend sur un projet failed : %v, attendu ErrNotExtendable", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtend_AddsMonthsFromCurrentExpiry(t *testing.T) {
|
||||||
|
svc, prov, _ := newTestService(t)
|
||||||
|
p := createReady(t, svc, prov, validInput()) // 3 mois
|
||||||
|
|
||||||
|
got, err := svc.Extend(p.ID, 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := t0.AddDate(0, 3, 0).AddDate(0, 12, 0)
|
||||||
|
if !got.ExpiresAt.Equal(want) {
|
||||||
|
t.Errorf("ExpiresAt=%v, attendu %v (échéance courante + 12 mois)", got.ExpiresAt, want)
|
||||||
|
}
|
||||||
|
if got.MonthsPurchased != 15 {
|
||||||
|
t.Errorf("MonthsPurchased=%d, attendu 15", got.MonthsPurchased)
|
||||||
|
}
|
||||||
|
if !got.DeleteAt.Equal(want.Add(7 * 24 * time.Hour)) {
|
||||||
|
t.Errorf("DeleteAt non recalculé : %v", got.DeleteAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtend_MonthsBounds(t *testing.T) {
|
||||||
|
svc, prov, _ := newTestService(t)
|
||||||
|
p := createReady(t, svc, prov, validInput())
|
||||||
|
for _, months := range []int{0, 13, -2} {
|
||||||
|
if _, err := svc.Extend(p.ID, months); !errors.Is(err, ErrInvalidMonths) {
|
||||||
|
t.Errorf("months=%d : %v, attendu ErrInvalidMonths", months, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, _ := svc.Get(p.ID)
|
||||||
|
if !got.ExpiresAt.Equal(p.ExpiresAt) {
|
||||||
|
t.Error("une prolongation refusée a modifié l'échéance")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtend_UnknownProject(t *testing.T) {
|
||||||
|
svc, _, _ := newTestService(t)
|
||||||
|
if _, err := svc.Extend("inconnu", 1); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("erreur %v, attendu ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiration_SuspendsAtExpiryThenDeletesAfterGrace(t *testing.T) {
|
||||||
|
svc, prov, now := newTestService(t)
|
||||||
|
p := createReady(t, svc, prov, validInput()) // 3 mois
|
||||||
|
|
||||||
|
// Avant l'échéance : rien ne bouge.
|
||||||
|
*now = p.ExpiresAt.Add(-time.Hour)
|
||||||
|
svc.expireOverdue()
|
||||||
|
if len(prov.suspended) != 0 {
|
||||||
|
t.Fatal("suspension avant l'échéance")
|
||||||
|
}
|
||||||
|
|
||||||
|
// À l'échéance : suspendu, jamais supprimé.
|
||||||
|
*now = p.ExpiresAt.Add(time.Minute)
|
||||||
|
svc.expireOverdue()
|
||||||
|
got, _ := svc.Get(p.ID)
|
||||||
|
if got.Status != StatusSuspended || got.SuspendedAt == nil {
|
||||||
|
t.Fatalf("statut %s, attendu suspended", got.Status)
|
||||||
|
}
|
||||||
|
if len(prov.suspended) != 1 || len(prov.tornDown) != 0 {
|
||||||
|
t.Fatalf("suspendus=%v supprimés=%v", prov.suspended, prov.tornDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pendant la grâce : toujours suspendu, données conservées.
|
||||||
|
*now = p.ExpiresAt.Add(6 * 24 * time.Hour)
|
||||||
|
svc.expireOverdue()
|
||||||
|
if len(prov.tornDown) != 0 {
|
||||||
|
t.Fatal("suppression pendant le délai de grâce")
|
||||||
|
}
|
||||||
|
if len(prov.suspended) != 1 {
|
||||||
|
t.Errorf("suspension répétée : %v", prov.suspended)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grâce dépassée : suppression définitive.
|
||||||
|
*now = p.DeleteAt.Add(time.Minute)
|
||||||
|
svc.expireOverdue()
|
||||||
|
got, _ = svc.Get(p.ID)
|
||||||
|
if got.Status != StatusDeleted || len(prov.tornDown) != 1 || prov.tornDown[0] != p.Namespace {
|
||||||
|
t.Errorf("statut %s, supprimés=%v", got.Status, prov.tornDown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtend_ResumesSuspendedProject(t *testing.T) {
|
||||||
|
svc, prov, now := newTestService(t)
|
||||||
|
p := createReady(t, svc, prov, validInput())
|
||||||
|
|
||||||
|
*now = p.ExpiresAt.Add(2 * 24 * time.Hour)
|
||||||
|
svc.expireOverdue()
|
||||||
|
|
||||||
|
got, err := svc.Extend(p.ID, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Status != StatusReady || got.SuspendedAt != nil {
|
||||||
|
t.Errorf("statut %s suspended_at=%v, attendu ready sans suspension", got.Status, got.SuspendedAt)
|
||||||
|
}
|
||||||
|
if len(prov.resumed) != 1 || prov.resumed[0] != p.Namespace {
|
||||||
|
t.Errorf("relances=%v", prov.resumed)
|
||||||
|
}
|
||||||
|
// Échéance en retard : le nouveau mois part de maintenant, pas de l'ancienne échéance.
|
||||||
|
if want := now.AddDate(0, 1, 0); !got.ExpiresAt.Equal(want) {
|
||||||
|
t.Errorf("ExpiresAt=%v, attendu %v", got.ExpiresAt, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renouvelé : la boucle ne doit plus le suspendre.
|
||||||
|
svc.expireOverdue()
|
||||||
|
if len(prov.suspended) != 1 {
|
||||||
|
t.Errorf("re-suspension d'un projet renouvelé : %v", prov.suspended)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtend_ResumeFailureKeepsProjectSuspended(t *testing.T) {
|
||||||
|
svc, prov, now := newTestService(t)
|
||||||
|
p := createReady(t, svc, prov, validInput())
|
||||||
|
*now = p.ExpiresAt.Add(time.Hour)
|
||||||
|
svc.expireOverdue()
|
||||||
|
|
||||||
|
prov.resumeErr = errors.New("api k8s indisponible")
|
||||||
|
if _, err := svc.Extend(p.ID, 1); err == nil {
|
||||||
|
t.Fatal("erreur attendue")
|
||||||
|
}
|
||||||
|
got, _ := svc.Get(p.ID)
|
||||||
|
if got.Status != StatusSuspended || !got.ExpiresAt.Equal(p.ExpiresAt) {
|
||||||
|
t.Errorf("l'échec de relance a modifié le projet : %s %v", got.Status, got.ExpiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDelete_IsIdempotentAndImmediate(t *testing.T) {
|
||||||
|
svc, prov, _ := newTestService(t)
|
||||||
|
p := createReady(t, svc, prov, validInput())
|
||||||
|
|
||||||
|
got, err := svc.Delete(p.ID)
|
||||||
|
if err != nil || got.Status != StatusDeleted {
|
||||||
|
t.Fatalf("Delete: %v, statut %s", err, got.Status)
|
||||||
|
}
|
||||||
|
if _, err := svc.Delete(p.ID); err != nil {
|
||||||
|
t.Fatalf("second Delete: %v", err)
|
||||||
|
}
|
||||||
|
if len(prov.tornDown) != 1 {
|
||||||
|
t.Errorf("teardown appelé %d fois, attendu 1", len(prov.tornDown))
|
||||||
|
}
|
||||||
|
if _, err := svc.Delete("inconnu"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("erreur %v, attendu ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroGraceSuspendsThenDeletesNextTick(t *testing.T) {
|
||||||
|
prov := newFakeProv()
|
||||||
|
svc := NewService(NewMemStore(), prov, Config{})
|
||||||
|
now := t0
|
||||||
|
svc.now = func() time.Time { return now }
|
||||||
|
p := createReady(t, svc, prov, validInput())
|
||||||
|
|
||||||
|
now = p.ExpiresAt.Add(time.Minute)
|
||||||
|
svc.expireOverdue() // suspension
|
||||||
|
svc.expireOverdue() // DeleteAt == ExpiresAt : déjà dépassé
|
||||||
|
got, _ := svc.Get(p.ID)
|
||||||
|
if got.Status != StatusDeleted {
|
||||||
|
t.Errorf("statut %s, attendu deleted (grâce nulle)", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package projects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store : persistance des projets.
|
||||||
|
type Store interface {
|
||||||
|
Create(p Project) (Project, error)
|
||||||
|
Get(id string) (Project, bool)
|
||||||
|
List() ([]Project, error)
|
||||||
|
Update(p Project) (Project, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GormStore : Store adossé à PostgreSQL via GORM.
|
||||||
|
type GormStore struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
func NewGormStore(db *gorm.DB) *GormStore { return &GormStore{db: db} }
|
||||||
|
|
||||||
|
func (s *GormStore) Create(p Project) (Project, error) {
|
||||||
|
if err := s.db.Create(&p).Error; err != nil {
|
||||||
|
return Project{}, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GormStore) Get(id string) (Project, bool) {
|
||||||
|
var p Project
|
||||||
|
err := s.db.Where("id = ?", id).First(&p).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) || err != nil {
|
||||||
|
return Project{}, false
|
||||||
|
}
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GormStore) List() ([]Project, error) {
|
||||||
|
var out []Project
|
||||||
|
if err := s.db.Order("created_at DESC").Find(&out).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GormStore) Update(p Project) (Project, error) {
|
||||||
|
if err := s.db.Save(&p).Error; err != nil {
|
||||||
|
return Project{}, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemStore : Store en mémoire (dev/tests).
|
||||||
|
type MemStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
items map[string]Project
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemStore() *MemStore { return &MemStore{items: make(map[string]Project)} }
|
||||||
|
|
||||||
|
func (m *MemStore) Create(p Project) (Project, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.items[p.ID] = p
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) Get(id string) (Project, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
p, ok := m.items[id]
|
||||||
|
return p, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) List() ([]Project, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
out := make([]Project, 0, len(m.items))
|
||||||
|
for _, p := range m.items {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) Update(p Project) (Project, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.items[p.ID] = p
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/omnex/control-plane/api/internal/downloads"
|
"github.com/omnex/control-plane/api/internal/downloads"
|
||||||
"github.com/omnex/control-plane/api/internal/httpx"
|
"github.com/omnex/control-plane/api/internal/httpx"
|
||||||
"github.com/omnex/control-plane/api/internal/profile"
|
"github.com/omnex/control-plane/api/internal/profile"
|
||||||
|
"github.com/omnex/control-plane/api/internal/projects"
|
||||||
"github.com/omnex/control-plane/api/internal/session"
|
"github.com/omnex/control-plane/api/internal/session"
|
||||||
"github.com/omnex/control-plane/api/internal/sub"
|
"github.com/omnex/control-plane/api/internal/sub"
|
||||||
)
|
)
|
||||||
@@ -23,6 +24,7 @@ type Deps struct {
|
|||||||
Sessions session.Manager
|
Sessions session.Manager
|
||||||
AuthH *auth.Handler
|
AuthH *auth.Handler
|
||||||
DemosH *demos.Handler
|
DemosH *demos.Handler
|
||||||
|
ProjectsH *projects.Handler
|
||||||
SubH *sub.Handler
|
SubH *sub.Handler
|
||||||
ProfileH *profile.Handler
|
ProfileH *profile.Handler
|
||||||
DownloadsH *downloads.Handler
|
DownloadsH *downloads.Handler
|
||||||
@@ -111,6 +113,13 @@ func New(d Deps) *gin.Engine {
|
|||||||
admin.POST("/demos/:id/premium", d.DemosH.TransferToPremium)
|
admin.POST("/demos/:id/premium", d.DemosH.TransferToPremium)
|
||||||
admin.POST("/demos/details", d.DemosH.ListDetails)
|
admin.POST("/demos/details", d.DemosH.ListDetails)
|
||||||
}
|
}
|
||||||
|
if d.ProjectsH != nil {
|
||||||
|
admin.POST("/projects", d.ProjectsH.Create)
|
||||||
|
admin.GET("/projects", d.ProjectsH.List)
|
||||||
|
admin.GET("/projects/:id", d.ProjectsH.Get)
|
||||||
|
admin.DELETE("/projects/:id", d.ProjectsH.Delete)
|
||||||
|
admin.POST("/projects/:id/extend", d.ProjectsH.Extend)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
+11
-1
@@ -9,6 +9,7 @@ import { Register } from './pages/Register'
|
|||||||
import { Demos } from './pages/backoffice/Demos'
|
import { Demos } from './pages/backoffice/Demos'
|
||||||
import { PremiumDemos } from './pages/backoffice/PremiumDemos'
|
import { PremiumDemos } from './pages/backoffice/PremiumDemos'
|
||||||
import { Codes } from './pages/backoffice/Codes'
|
import { Codes } from './pages/backoffice/Codes'
|
||||||
|
import { Projects } from './pages/backoffice/Projects'
|
||||||
import { Subscription } from './pages/backoffice/Subscription'
|
import { Subscription } from './pages/backoffice/Subscription'
|
||||||
import { AppDownloads } from './pages/backoffice/AppDownloads'
|
import { AppDownloads } from './pages/backoffice/AppDownloads'
|
||||||
import { Profile } from './pages/backoffice/Profile'
|
import { Profile } from './pages/backoffice/Profile'
|
||||||
@@ -21,7 +22,7 @@ import { MyDemoPlat } from './pages/backoffice/MyDemoPlat'
|
|||||||
|
|
||||||
// Chemins admin uniquement : un accès non authentifié y renvoie vers la
|
// Chemins admin uniquement : un accès non authentifié y renvoie vers la
|
||||||
// page de connexion admin plutôt que la page client.
|
// page de connexion admin plutôt que la page client.
|
||||||
const ADMIN_ONLY_PATHS = ['/app/demos', '/app/premium', '/app/codes']
|
const ADMIN_ONLY_PATHS = ['/app/demos', '/app/premium', '/app/projects', '/app/codes']
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||||
const { isAuthenticated, initializing } = useAuth()
|
const { isAuthenticated, initializing } = useAuth()
|
||||||
@@ -98,6 +99,15 @@ export function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="projects"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<Projects />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="premium"
|
path="premium"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function BackofficeLayout() {
|
|||||||
{isClient && <NavItem to="/app/myservices">{isPremium ? 'Ma plateforme' : 'Ma démo'}</NavItem>}
|
{isClient && <NavItem to="/app/myservices">{isPremium ? 'Ma plateforme' : 'Ma démo'}</NavItem>}
|
||||||
{isAdmin && <NavItem to="/app/demos">Démos</NavItem>}
|
{isAdmin && <NavItem to="/app/demos">Démos</NavItem>}
|
||||||
{isAdmin && <NavItem to="/app/premium">Premium</NavItem>}
|
{isAdmin && <NavItem to="/app/premium">Premium</NavItem>}
|
||||||
|
{isAdmin && <NavItem to="/app/projects">Projets</NavItem>}
|
||||||
{isAdmin && <NavItem to="/app/codes">Codes</NavItem>}
|
{isAdmin && <NavItem to="/app/codes">Codes</NavItem>}
|
||||||
</HStack>
|
</HStack>
|
||||||
<Spacer />
|
<Spacer />
|
||||||
@@ -134,6 +135,11 @@ export function BackofficeLayout() {
|
|||||||
Premium
|
Premium
|
||||||
</NavItem>
|
</NavItem>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<NavItem to="/app/projects" onClick={onClose} mobile>
|
||||||
|
Projets
|
||||||
|
</NavItem>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<NavItem to="/app/codes" onClick={onClose} mobile>
|
<NavItem to="/app/codes" onClick={onClose} mobile>
|
||||||
Codes
|
Codes
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
FormHelperText,
|
||||||
|
FormLabel,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
NumberDecrementStepper,
|
||||||
|
NumberIncrementStepper,
|
||||||
|
NumberInput,
|
||||||
|
NumberInputField,
|
||||||
|
NumberInputStepper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
useToast,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { api, ApiError } from '../lib/api'
|
||||||
|
import { PROJECT_MONTHS, monthsLabel } from '../lib/format'
|
||||||
|
import { PasswordInput } from './PasswordInput'
|
||||||
|
|
||||||
|
interface CreateProjectModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onCreated: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_PASSWORD = 12
|
||||||
|
|
||||||
|
// Déploiement d'un projet vitrine pour un client : durée de l'abonnement
|
||||||
|
// (1 à 12 mois), compte admin et nombre d'admins autorisés. Le paiement est
|
||||||
|
// géré en dehors d'Omnex : l'admin saisit la durée réellement souscrite.
|
||||||
|
export function CreateProjectModal({ isOpen, onClose, onCreated }: CreateProjectModalProps) {
|
||||||
|
const toast = useToast()
|
||||||
|
const [clientName, setClientName] = useState('')
|
||||||
|
const [months, setMonths] = useState(1)
|
||||||
|
const [adminUsername, setAdminUsername] = useState('')
|
||||||
|
const [adminPassword, setAdminPassword] = useState('')
|
||||||
|
const [adminNumber, setAdminNumber] = useState(1)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const usernameOk = /^[A-Za-z0-9]{3,64}$/.test(adminUsername)
|
||||||
|
const valid =
|
||||||
|
clientName.trim().length > 0 && usernameOk && adminPassword.length >= MIN_PASSWORD && adminNumber >= 1
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setClientName('')
|
||||||
|
setMonths(1)
|
||||||
|
setAdminUsername('')
|
||||||
|
setAdminPassword('')
|
||||||
|
setAdminNumber(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (saving) return
|
||||||
|
reset()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.createProject({
|
||||||
|
clientName: clientName.trim(),
|
||||||
|
months,
|
||||||
|
adminUsername,
|
||||||
|
adminPassword,
|
||||||
|
adminNumber,
|
||||||
|
})
|
||||||
|
toast({ status: 'success', title: 'Déploiement lancé', description: `Abonnement de ${monthsLabel(months)}` })
|
||||||
|
reset()
|
||||||
|
onCreated()
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof ApiError ? err.message : 'Erreur'
|
||||||
|
toast({ status: 'error', title: 'Déploiement impossible', description: msg })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={handleClose} closeOnOverlayClick={!saving}>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Déployer un projet</ModalHeader>
|
||||||
|
<ModalCloseButton isDisabled={saving} />
|
||||||
|
<ModalBody>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel fontSize="sm">Client</FormLabel>
|
||||||
|
<Input
|
||||||
|
value={clientName}
|
||||||
|
maxLength={64}
|
||||||
|
onChange={(e) => setClientName(e.target.value)}
|
||||||
|
isDisabled={saving}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel fontSize="sm">Durée de l'abonnement</FormLabel>
|
||||||
|
<Select value={months} onChange={(e) => setMonths(Number(e.target.value))} isDisabled={saving}>
|
||||||
|
{PROJECT_MONTHS.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{monthsLabel(m)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<FormHelperText>
|
||||||
|
Durée réellement payée par le client. À l'échéance le projet est suspendu, puis supprimé
|
||||||
|
après le délai de grâce s'il n'est pas renouvelé.
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl isRequired isInvalid={adminUsername !== '' && !usernameOk}>
|
||||||
|
<FormLabel fontSize="sm">Identifiant admin du projet</FormLabel>
|
||||||
|
<Input
|
||||||
|
value={adminUsername}
|
||||||
|
maxLength={64}
|
||||||
|
onChange={(e) => setAdminUsername(e.target.value)}
|
||||||
|
isDisabled={saving}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<FormHelperText>3 à 64 caractères alphanumériques.</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl isRequired isInvalid={adminPassword !== '' && adminPassword.length < MIN_PASSWORD}>
|
||||||
|
<FormLabel fontSize="sm">Mot de passe admin</FormLabel>
|
||||||
|
<PasswordInput
|
||||||
|
value={adminPassword}
|
||||||
|
onChange={(e) => setAdminPassword(e.target.value)}
|
||||||
|
isDisabled={saving}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<FormHelperText>{MIN_PASSWORD} caractères minimum. Il n'est pas conservé par Omnex.</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl isRequired>
|
||||||
|
<FormLabel fontSize="sm">Nombre d'admins autorisés</FormLabel>
|
||||||
|
<NumberInput
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={adminNumber}
|
||||||
|
onChange={(_, n) => setAdminNumber(Number.isNaN(n) ? 1 : n)}
|
||||||
|
isDisabled={saving}
|
||||||
|
>
|
||||||
|
<NumberInputField />
|
||||||
|
<NumberInputStepper>
|
||||||
|
<NumberIncrementStepper />
|
||||||
|
<NumberDecrementStepper />
|
||||||
|
</NumberInputStepper>
|
||||||
|
</NumberInput>
|
||||||
|
<FormHelperText>Nombre maximum de comptes admin dans le projet (1 à 20).</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
</Stack>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="ghost" mr={3} onClick={handleClose} isDisabled={saving}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button colorScheme="primary" onClick={handleCreate} isLoading={saving} isDisabled={!valid}>
|
||||||
|
Déployer
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
FormHelperText,
|
||||||
|
FormLabel,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
Select,
|
||||||
|
useToast,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { api, ApiError, type Project } from '../lib/api'
|
||||||
|
import { PROJECT_MONTHS, monthsLabel } from '../lib/format'
|
||||||
|
|
||||||
|
interface ExtendProjectModalProps {
|
||||||
|
project: Project | null
|
||||||
|
onClose: () => void
|
||||||
|
onExtended: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renouvellement d'un abonnement : 1 à 12 mois ajoutés à l'échéance courante
|
||||||
|
// (ou à aujourd'hui si elle est dépassée). Relance un projet suspendu.
|
||||||
|
export function ExtendProjectModal({ project, onClose, onExtended }: ExtendProjectModalProps) {
|
||||||
|
const toast = useToast()
|
||||||
|
const [months, setMonths] = useState(1)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMonths(1)
|
||||||
|
}, [project])
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (saving) return
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExtend = async () => {
|
||||||
|
if (!project) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.extendProject(project.id, months)
|
||||||
|
toast({ status: 'success', title: `Abonnement prolongé de ${monthsLabel(months)}` })
|
||||||
|
onExtended()
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof ApiError ? err.message : 'Erreur'
|
||||||
|
toast({ status: 'error', title: 'Renouvellement impossible', description: msg })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={!!project} onClose={handleClose} closeOnOverlayClick={!saving}>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Renouveler l'abonnement</ModalHeader>
|
||||||
|
<ModalCloseButton isDisabled={saving} />
|
||||||
|
<ModalBody>
|
||||||
|
<FormControl>
|
||||||
|
<FormLabel fontSize="sm">Mois à ajouter — {project?.client_name}</FormLabel>
|
||||||
|
<Select value={months} onChange={(e) => setMonths(Number(e.target.value))} isDisabled={saving}>
|
||||||
|
{PROJECT_MONTHS.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{monthsLabel(m)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<FormHelperText>
|
||||||
|
{project?.status === 'suspended'
|
||||||
|
? "Le projet est suspendu : il sera relancé et l'abonnement repart d'aujourd'hui."
|
||||||
|
: "Les mois s'ajoutent à l'échéance actuelle."}
|
||||||
|
</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="ghost" mr={3} onClick={handleClose} isDisabled={saving}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button colorScheme="primary" onClick={handleExtend} isLoading={saving}>
|
||||||
|
Renouveler
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -58,6 +58,41 @@ export interface Demo {
|
|||||||
expires_at: string
|
expires_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Projets vitrine : abonnement de 1 à 12 mois, suspendu à l'échéance puis
|
||||||
|
// supprimé après un délai de grâce (voir internal/projects côté API).
|
||||||
|
export type ProjectStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'provisioning'
|
||||||
|
| 'ready'
|
||||||
|
| 'suspended'
|
||||||
|
| 'deleting'
|
||||||
|
| 'deleted'
|
||||||
|
| 'failed'
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string
|
||||||
|
client_name: string
|
||||||
|
status: ProjectStatus
|
||||||
|
namespace: string
|
||||||
|
host: string
|
||||||
|
url: string
|
||||||
|
admin_username: string
|
||||||
|
admin_number: number
|
||||||
|
months_purchased: number
|
||||||
|
created_at: string
|
||||||
|
expires_at: string
|
||||||
|
delete_at: string
|
||||||
|
suspended_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProjectParams {
|
||||||
|
clientName: string
|
||||||
|
months: number
|
||||||
|
adminUsername: string
|
||||||
|
adminPassword: string
|
||||||
|
adminNumber: number
|
||||||
|
}
|
||||||
|
|
||||||
export type StorageDriver = 'local' | 's3'
|
export type StorageDriver = 'local' | 's3'
|
||||||
|
|
||||||
export interface CreateDemoParams {
|
export interface CreateDemoParams {
|
||||||
@@ -215,6 +250,19 @@ export const api = {
|
|||||||
request<Demo>('POST', `/demos/${id}/domain`, { domain }),
|
request<Demo>('POST', `/demos/${id}/domain`, { domain }),
|
||||||
transferDemoToPremium: (id: string) => request<Demo>('POST', `/demos/${id}/premium`),
|
transferDemoToPremium: (id: string) => request<Demo>('POST', `/demos/${id}/premium`),
|
||||||
|
|
||||||
|
listProjects: () => request<{ items: Project[] }>('GET', '/projects'),
|
||||||
|
createProject: (params: CreateProjectParams) =>
|
||||||
|
request<Project>('POST', '/projects', {
|
||||||
|
client_name: params.clientName,
|
||||||
|
months: params.months,
|
||||||
|
admin_username: params.adminUsername,
|
||||||
|
admin_password: params.adminPassword,
|
||||||
|
admin_number: params.adminNumber,
|
||||||
|
}),
|
||||||
|
extendProject: (id: string, months: number) =>
|
||||||
|
request<Project>('POST', `/projects/${id}/extend`, { months }),
|
||||||
|
deleteProject: (id: string) => request<Project>('DELETE', `/projects/${id}`),
|
||||||
|
|
||||||
listCodes: () => request<{ items: CodeBuySub[] }>('GET', '/codes'),
|
listCodes: () => request<{ items: CodeBuySub[] }>('GET', '/codes'),
|
||||||
listPremiumUsers: () => request<{ items: PremiumUser[] }>('GET', '/premium'),
|
listPremiumUsers: () => request<{ items: PremiumUser[] }>('GET', '/premium'),
|
||||||
createCode: (username: string) =>
|
createCode: (username: string) =>
|
||||||
|
|||||||
+41
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { DemoStatus } from './api'
|
import type { DemoStatus, ProjectStatus } from './api'
|
||||||
|
|
||||||
// Couleur de badge Chakra par statut de démo.
|
// Couleur de badge Chakra par statut de démo.
|
||||||
export function statusColor(s: DemoStatus): string {
|
export function statusColor(s: DemoStatus): string {
|
||||||
@@ -92,3 +92,43 @@ export function formatDate(iso: string): string {
|
|||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Couleur de badge Chakra par statut de projet vitrine.
|
||||||
|
export function projectStatusColor(s: ProjectStatus): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'ready':
|
||||||
|
return 'green'
|
||||||
|
case 'provisioning':
|
||||||
|
case 'pending':
|
||||||
|
return 'blue'
|
||||||
|
case 'suspended':
|
||||||
|
return 'orange'
|
||||||
|
case 'deleting':
|
||||||
|
case 'failed':
|
||||||
|
return 'red'
|
||||||
|
case 'deleted':
|
||||||
|
default:
|
||||||
|
return 'gray'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Libellé FR du statut d'un projet vitrine.
|
||||||
|
export function projectStatusLabel(s: ProjectStatus): string {
|
||||||
|
const map: Record<ProjectStatus, string> = {
|
||||||
|
pending: 'En attente',
|
||||||
|
provisioning: 'Déploiement…',
|
||||||
|
ready: 'En ligne',
|
||||||
|
suspended: 'Suspendu',
|
||||||
|
deleting: 'Suppression…',
|
||||||
|
deleted: 'Supprimé',
|
||||||
|
failed: 'Échec',
|
||||||
|
}
|
||||||
|
return map[s] ?? s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Durées d'abonnement proposées (1 à 12 mois, contrainte de l'API).
|
||||||
|
export const PROJECT_MONTHS = Array.from({ length: 12 }, (_, i) => i + 1)
|
||||||
|
|
||||||
|
export function monthsLabel(n: number): string {
|
||||||
|
return `${n} mois`
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
Link,
|
||||||
|
Spacer,
|
||||||
|
Spinner,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
Tbody,
|
||||||
|
Td,
|
||||||
|
Text,
|
||||||
|
Th,
|
||||||
|
Thead,
|
||||||
|
Tr,
|
||||||
|
useToast,
|
||||||
|
Wrap,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { api, ApiError, type Project } from '../../lib/api'
|
||||||
|
import { formatDate, monthsLabel, projectStatusColor, projectStatusLabel, timeRemaining } from '../../lib/format'
|
||||||
|
import { ConfirmDialog } from '../../components/ConfirmDialog'
|
||||||
|
import { CreateProjectModal } from '../../components/CreateProjectModal'
|
||||||
|
import { ExtendProjectModal } from '../../components/ExtendProjectModal'
|
||||||
|
|
||||||
|
// Un déploiement en cours => on rafraîchit régulièrement.
|
||||||
|
const POLL_MS = 5000
|
||||||
|
|
||||||
|
// Projets vitrine déployés pour des clients sur abonnement de 1 à 12 mois.
|
||||||
|
export function Projects() {
|
||||||
|
const toast = useToast()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [busy, setBusy] = useState<string | null>(null)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [toExtend, setToExtend] = useState<Project | null>(null)
|
||||||
|
const [toDelete, setToDelete] = useState<Project | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.listProjects()
|
||||||
|
setProjects((res.items ?? []).filter((p) => p.status !== 'deleted'))
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 401) navigate('/admin/login')
|
||||||
|
else toast({ status: 'error', title: 'Chargement des projets impossible' })
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [navigate, toast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
const id = setInterval(() => void load(), POLL_MS)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!toDelete) return
|
||||||
|
const p = toDelete
|
||||||
|
setBusy(p.id)
|
||||||
|
try {
|
||||||
|
await api.deleteProject(p.id)
|
||||||
|
toast({ status: 'success', title: 'Projet supprimé' })
|
||||||
|
setToDelete(null)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof ApiError ? err.message : 'Erreur'
|
||||||
|
toast({ status: 'error', title: 'Suppression impossible', description: msg })
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Échéance affichée : temps restant, ou date de suppression si suspendu.
|
||||||
|
const deadline = (p: Project) => {
|
||||||
|
if (p.status === 'suspended') {
|
||||||
|
return (
|
||||||
|
<Text color="orange.500" fontSize="sm">
|
||||||
|
Suppression le {formatDate(p.delete_at)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text fontSize="sm">
|
||||||
|
{formatDate(p.expires_at)} · {timeRemaining(p.expires_at)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box p={{ base: 4, md: 8 }}>
|
||||||
|
<Flex mb={6} align="center" gap={3}>
|
||||||
|
<Heading size="md">Projets</Heading>
|
||||||
|
<Spacer />
|
||||||
|
<Button colorScheme="primary" onClick={() => setCreateOpen(true)}>
|
||||||
|
Déployer un projet
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<Text color="gray.500">Aucun projet déployé.</Text>
|
||||||
|
) : (
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="sm">
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Client</Th>
|
||||||
|
<Th>Statut</Th>
|
||||||
|
<Th>Adresse</Th>
|
||||||
|
<Th>Abonnement</Th>
|
||||||
|
<Th>Échéance</Th>
|
||||||
|
<Th>Admins</Th>
|
||||||
|
<Th />
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
{projects.map((p) => {
|
||||||
|
const locked = busy === p.id
|
||||||
|
const canExtend = p.status === 'ready' || p.status === 'provisioning' || p.status === 'suspended'
|
||||||
|
return (
|
||||||
|
<Tr key={p.id}>
|
||||||
|
<Td>{p.client_name}</Td>
|
||||||
|
<Td>
|
||||||
|
<Badge colorScheme={projectStatusColor(p.status)}>{projectStatusLabel(p.status)}</Badge>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Link href={p.url} isExternal fontFamily="mono" fontSize="sm">
|
||||||
|
{p.host}
|
||||||
|
</Link>
|
||||||
|
</Td>
|
||||||
|
<Td>{monthsLabel(p.months_purchased)} au total</Td>
|
||||||
|
<Td>{deadline(p)}</Td>
|
||||||
|
<Td>
|
||||||
|
{p.admin_username} · max {p.admin_number}
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Wrap justify="flex-end" spacing={2}>
|
||||||
|
<Button size="xs" colorScheme="primary" isDisabled={!canExtend || locked} onClick={() => setToExtend(p)}>
|
||||||
|
Renouveler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="outline"
|
||||||
|
colorScheme="red"
|
||||||
|
isDisabled={p.status === 'deleting' || locked}
|
||||||
|
onClick={() => setToDelete(p)}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</Button>
|
||||||
|
</Wrap>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateProjectModal isOpen={createOpen} onClose={() => setCreateOpen(false)} onCreated={() => void load()} />
|
||||||
|
<ExtendProjectModal project={toExtend} onClose={() => setToExtend(null)} onExtended={() => void load()} />
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!toDelete}
|
||||||
|
title="Supprimer le projet"
|
||||||
|
confirmLabel="Supprimer définitivement"
|
||||||
|
confirmColorScheme="red"
|
||||||
|
isLoading={busy === toDelete?.id}
|
||||||
|
onConfirm={() => void confirmDelete()}
|
||||||
|
onClose={() => setToDelete(null)}
|
||||||
|
>
|
||||||
|
Le projet de {toDelete?.client_name} sera supprimé immédiatement, avec ses données et ses sauvegardes. Cette
|
||||||
|
action est irréversible.
|
||||||
|
</ConfirmDialog>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user