chore: update
ci-api / test (push) Successful in 24m6s
ci-web / test (push) Successful in 13m50s

This commit is contained in:
Xor290
2026-09-20 11:50:02 +02:00
parent cb48bb2b70
commit 76b1412968
23 changed files with 2141 additions and 89 deletions
-3
View File
@@ -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
+27 -35
View File
@@ -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
}
+38 -14
View File
@@ -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)
}
}
+2
View File
@@ -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
View File
@@ -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={
+6
View File
@@ -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
+174
View File
@@ -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>
)
}
+92
View File
@@ -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>
)
}
+48
View File
@@ -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
View File
@@ -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`
}
+182
View File
@@ -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>
)
}