From 76b14129681f33b23b85e7e6a8b83e3ce7f64316 Mon Sep 17 00:00:00 2001 From: Xor290 Date: Sun, 20 Sep 2026 11:50:02 +0200 Subject: [PATCH] chore: update --- control-plane/api/.env.example | 3 - control-plane/api/cmd/api/main.go | 62 ++-- control-plane/api/internal/auth/gormstore.go | 9 - control-plane/api/internal/config/config.go | 52 ++- .../api/internal/config/config_test.go | 25 ++ control-plane/api/internal/db/db.go | 2 + .../api/internal/demos/helm_provisioner.go | 64 ++-- .../api/internal/demos/project_provisioner.go | 282 ++++++++++++++ .../demos/project_provisioner_test.go | 139 +++++++ .../api/internal/projects/handler.go | 109 ++++++ .../api/internal/projects/handler_test.go | 93 +++++ control-plane/api/internal/projects/models.go | 69 ++++ .../api/internal/projects/service.go | 319 ++++++++++++++++ .../api/internal/projects/service_test.go | 344 ++++++++++++++++++ control-plane/api/internal/projects/store.go | 93 +++++ control-plane/api/internal/router/router.go | 9 + web/src/App.tsx | 12 +- web/src/components/BackofficeLayout.tsx | 6 + web/src/components/CreateProjectModal.tsx | 174 +++++++++ web/src/components/ExtendProjectModal.tsx | 92 +++++ web/src/lib/api.ts | 48 +++ web/src/lib/format.ts | 42 ++- web/src/pages/backoffice/Projects.tsx | 182 +++++++++ 23 files changed, 2141 insertions(+), 89 deletions(-) create mode 100644 control-plane/api/internal/demos/project_provisioner.go create mode 100644 control-plane/api/internal/demos/project_provisioner_test.go create mode 100644 control-plane/api/internal/projects/handler.go create mode 100644 control-plane/api/internal/projects/handler_test.go create mode 100644 control-plane/api/internal/projects/models.go create mode 100644 control-plane/api/internal/projects/service.go create mode 100644 control-plane/api/internal/projects/service_test.go create mode 100644 control-plane/api/internal/projects/store.go create mode 100644 web/src/components/CreateProjectModal.tsx create mode 100644 web/src/components/ExtendProjectModal.tsx create mode 100644 web/src/pages/backoffice/Projects.tsx diff --git a/control-plane/api/.env.example b/control-plane/api/.env.example index 0cbe927..da317a1 100644 --- a/control-plane/api/.env.example +++ b/control-plane/api/.env.example @@ -19,9 +19,6 @@ OMNEX_ALLOWED_ORIGINS=http://localhost:5173 # Redis (sessions). Vide en dev => sessions en mémoire volatile. # 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 OMNEX_DEMO_DOMAIN=demo.omnex.app diff --git a/control-plane/api/cmd/api/main.go b/control-plane/api/cmd/api/main.go index 8a4fa7e..59da05d 100644 --- a/control-plane/api/cmd/api/main.go +++ b/control-plane/api/cmd/api/main.go @@ -23,6 +23,7 @@ import ( "github.com/omnex/control-plane/api/internal/downloads" "github.com/omnex/control-plane/api/internal/k8s" "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/session" "github.com/omnex/control-plane/api/internal/sub" @@ -78,6 +79,7 @@ func main() { var userStore auth.UserStore var demoStore demos.Store + var projectStore projects.Store var demoPool demos.Pool var codeStore sub.Store var profileStore profile.Store @@ -96,19 +98,19 @@ func main() { if err := db.SeedExternalPool(gdb); err != nil { log.Fatalf("seed pool: %v", err) } - users := auth.NewGormStore(gdb) - seedAdmin(users) - userStore = users + userStore = auth.NewGormStore(gdb) codeStore = sub.NewGormStore(gdb) demoStore = demos.NewGormStore(gdb) + projectStore = projects.NewGormStore(gdb) demoPool = demos.NewGormPool(gdb) profileStore = profile.NewGormStore(gdb) alertRecipients = alerts.GormRecipients(gdb) log.Printf("persistance: PostgreSQL (GORM)") } else { - userStore = seedMemUsers() + userStore = memUsers{} codeStore = sub.NewMemStore() demoStore = demos.NewMemStore() + projectStore = projects.NewMemStore() demoPool = demos.NewMemPool() 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. 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 // CPU/mémoire/PVC), notifie Discord et/ou Telegram de chaque admin ayant // configuré son propre canal (voir profil admin, /profile/alerts). @@ -182,6 +193,17 @@ func main() { 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 }) go watcher.Run(context.Background()) @@ -196,6 +218,7 @@ func main() { Sessions: sessions, AuthH: auth.NewHandler(userStore, sessions, iss, cfg.Secure()), DemosH: demos.NewHandler(demoSvc, helmProv), + ProjectsH: projects.NewHandler(projectSvc), SubH: sub.NewHandler(codeStore, demoSvc), ProfileH: profile.NewHandler(profileStore), DownloadsH: downloads.NewHandler(cfg.AppDownloadsDir, userStore, demoSvc), @@ -225,34 +248,3 @@ func main() { 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 -} diff --git a/control-plane/api/internal/auth/gormstore.go b/control-plane/api/internal/auth/gormstore.go index 2ed88aa..f140475 100644 --- a/control-plane/api/internal/auth/gormstore.go +++ b/control-plane/api/internal/auth/gormstore.go @@ -45,12 +45,3 @@ func (s *GormStore) Create(username, passwordHash string, role Role) (User, erro } 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 -} diff --git a/control-plane/api/internal/config/config.go b/control-plane/api/internal/config/config.go index 9c5e771..2174428 100644 --- a/control-plane/api/internal/config/config.go +++ b/control-plane/api/internal/config/config.go @@ -3,30 +3,41 @@ package config import ( "errors" "os" + "strconv" "time" ) type Config struct { - Addr string - JWTSecret []byte // OMNEX_JWT_SECRET (min 32 octets) — signe les tokens - SessionTTL time.Duration // durée de vie JWT + session Redis - AllowedOrigins []string // CORS (vitrine + back-office) - Env string // "dev" | "prod" - DatabaseURL string // OMNEX_DATABASE_URL (DSN PostgreSQL GORM) - RedisURL string // OMNEX_REDIS_URL (sessions), ex. redis://:pass@host:6379/0 - DemoDomain string // domaine des démos - DemoHTTPSPort string // port HTTPS public à afficher dans l'URL des démos (443 = omis, sinon ex. NodePort) - Kubeconfig string - FrontendImage string - BackendImage string - LBTelegramImage string - AppDownloadsDir string // OMNEX_APP_DOWNLOADS_DIR : répertoire des .apk téléchargeables (voir internal/downloads) + Addr string + JWTSecret []byte // OMNEX_JWT_SECRET (min 32 octets) — signe les tokens + SessionTTL time.Duration // durée de vie JWT + session Redis + AllowedOrigins []string // CORS (vitrine + back-office) + Env string // "dev" | "prod" + DatabaseURL string // OMNEX_DATABASE_URL (DSN PostgreSQL GORM) + RedisURL string // OMNEX_REDIS_URL (sessions), ex. redis://:pass@host:6379/0 + DemoDomain string // domaine des démos + DemoHTTPSPort string // port HTTPS public à afficher dans l'URL des démos (443 = omis, sinon ex. NodePort) + Kubeconfig string + FrontendImage string + BackendImage string + LBTelegramImage string + AppDownloadsDir string // OMNEX_APP_DOWNLOADS_DIR : répertoire des .apk téléchargeables (voir internal/downloads) // DockerHubUsername/Password : compte Docker Hub authentifié (droits // lecture seule) injecté dans chaque namespace de démo (voir // deploy/chart-gestion/registry-credentials) pour passer le rate-limit // de pull anonyme (100/6h par IP) à 200/6h. "" = pull anonyme (défaut). DockerHubUsername string DockerHubPassword string + + // Projets vitrine (abonnements de 1 à 12 mois, voir internal/projects). + VitrineDomain string // OMNEX_VITRINE_DOMAIN : les projets sont servis sur . + 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. @@ -53,6 +64,19 @@ func Load() (Config, error) { AppDownloadsDir: getenv("OMNEX_APP_DOWNLOADS_DIR", "/app-downloads"), DockerHubUsername: os.Getenv("OMNEX_DOCKERHUB_USERNAME"), 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" { diff --git a/control-plane/api/internal/config/config_test.go b/control-plane/api/internal/config/config_test.go index 5f74bb0..08efbc2 100644 --- a/control-plane/api/internal/config/config_test.go +++ b/control-plane/api/internal/config/config_test.go @@ -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) + } +} diff --git a/control-plane/api/internal/db/db.go b/control-plane/api/internal/db/db.go index ede06c2..708841e 100644 --- a/control-plane/api/internal/db/db.go +++ b/control-plane/api/internal/db/db.go @@ -11,6 +11,7 @@ import ( "github.com/omnex/control-plane/api/internal/auth" "github.com/omnex/control-plane/api/internal/demos" + "github.com/omnex/control-plane/api/internal/projects" "github.com/omnex/control-plane/api/internal/sub" ) @@ -42,6 +43,7 @@ func AutoMigrate(gdb *gorm.DB) error { &sub.CodeBuySub{}, &demos.Demo{}, &demos.ExternalResource{}, + &projects.Project{}, ) } diff --git a/control-plane/api/internal/demos/helm_provisioner.go b/control-plane/api/internal/demos/helm_provisioner.go index 0832a97..3b199bf 100644 --- a/control-plane/api/internal/demos/helm_provisioner.go +++ b/control-plane/api/internal/demos/helm_provisioner.go @@ -36,17 +36,21 @@ import ( // 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). type HelmProvisioner struct { - cfg *config.Config - k8sClient *kubernetes.Clientset - restConfig *rest.Config // pour l'exec dans les pods (pg_dump/restore) - metricsClient *metricsclient.Clientset // optionnel : nil si metrics-server indisponible - dynamicClient dynamic.Interface // pour les CRD Velero (Schedule/Backup), voir ensureDemoBackupSchedule - chartsDir string // chemin vers le dossier des charts (ex: /charts) - frontendImage string - backendImage string - lbtelegramImage string - baseDomain string - helmPath string // chemin vers l'exécutable helm (default: "helm") + cfg *config.Config + k8sClient *kubernetes.Clientset + restConfig *rest.Config // pour l'exec dans les pods (pg_dump/restore) + metricsClient *metricsclient.Clientset // optionnel : nil si metrics-server indisponible + dynamicClient dynamic.Interface // pour les CRD Velero (Schedule/Backup), voir ensureDemoBackupSchedule + chartsDir string // chemin vers le dossier des charts (ex: /charts) + // vitrineChartsDir : charts des projets vitrine (backend, frontend, + // postgresql, redis, ingressroute), distincts de chartsDir dont les + // charts de mêmes noms déploient le produit des démos. + vitrineChartsDir string + 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. @@ -79,17 +83,18 @@ func NewHelmProvisioner( } return &HelmProvisioner{ - cfg: cfg, - k8sClient: k8sClient, - restConfig: restConfig, - metricsClient: metricsClient, - dynamicClient: dynamicClient, - chartsDir: chartsDir, - frontendImage: cfg.FrontendImage, - backendImage: cfg.BackendImage, - lbtelegramImage: cfg.LBTelegramImage, - baseDomain: cfg.DemoDomain, - helmPath: helmPath, + cfg: cfg, + k8sClient: k8sClient, + restConfig: restConfig, + metricsClient: metricsClient, + dynamicClient: dynamicClient, + chartsDir: chartsDir, + vitrineChartsDir: cfg.VitrineChartsDir, + frontendImage: cfg.FrontendImage, + backendImage: cfg.BackendImage, + lbtelegramImage: cfg.LBTelegramImage, + baseDomain: cfg.DemoDomain, + helmPath: helmPath, }, nil } @@ -843,10 +848,10 @@ func (h *HelmProvisioner) ensureDemoBackupSchedule(namespace string) error { "spec": map[string]interface{}{ "schedule": "*/15 * * * *", "template": map[string]interface{}{ - "includedNamespaces": []interface{}{namespace}, + "includedNamespaces": []interface{}{namespace}, "defaultVolumesToFsBackup": true, - "storageLocation": "default", - "ttl": "24h0m0s", + "storageLocation": "default", + "ttl": "24h0m0s", }, }, }} @@ -911,6 +916,13 @@ func (h *HelmProvisioner) deleteDemoBackups(namespace string) error { // avec des valeurs personnalisées. Release name : "-". // Adapté aux charts par démo (namespace toujours neuf). 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) valuesFile, cleanup, err := h.writeValuesFile(namespace, chartName, values) if err != nil { @@ -921,7 +933,7 @@ func (h *HelmProvisioner) installChart(namespace, chartName string, values map[s args := []string{ "install", release, - filepath.Join(h.chartsDir, chartName), + filepath.Join(chartsDir, chartName), "--namespace", namespace, "--values", valuesFile, "--wait", diff --git a/control-plane/api/internal/demos/project_provisioner.go b/control-plane/api/internal/demos/project_provisioner.go new file mode 100644 index 0000000..659cac2 --- /dev/null +++ b/control-plane/api/internal/demos/project_provisioner.go @@ -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 +// "-" avec release = "-" (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://) + 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)}, + } +} diff --git a/control-plane/api/internal/demos/project_provisioner_test.go b/control-plane/api/internal/demos/project_provisioner_test.go new file mode 100644 index 0000000..e28d44c --- /dev/null +++ b/control-plane/api/internal/demos/project_provisioner_test.go @@ -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 : "-". + 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) } diff --git a/control-plane/api/internal/projects/handler.go b/control-plane/api/internal/projects/handler.go new file mode 100644 index 0000000..484505c --- /dev/null +++ b/control-plane/api/internal/projects/handler.go @@ -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) +} diff --git a/control-plane/api/internal/projects/handler_test.go b/control-plane/api/internal/projects/handler_test.go new file mode 100644 index 0000000..fff139b --- /dev/null +++ b/control-plane/api/internal/projects/handler_test.go @@ -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) + } +} diff --git a/control-plane/api/internal/projects/models.go b/control-plane/api/internal/projects/models.go new file mode 100644 index 0000000..3a888b1 --- /dev/null +++ b/control-plane/api/internal/projects/models.go @@ -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" } diff --git a/control-plane/api/internal/projects/service.go b/control-plane/api/internal/projects/service.go new file mode 100644 index 0000000..f3d77b5 --- /dev/null +++ b/control-plane/api/internal/projects/service.go @@ -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 : . + 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 +} diff --git a/control-plane/api/internal/projects/service_test.go b/control-plane/api/internal/projects/service_test.go new file mode 100644 index 0000000..d50490f --- /dev/null +++ b/control-plane/api/internal/projects/service_test.go @@ -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) + } +} diff --git a/control-plane/api/internal/projects/store.go b/control-plane/api/internal/projects/store.go new file mode 100644 index 0000000..a8fb260 --- /dev/null +++ b/control-plane/api/internal/projects/store.go @@ -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 +} diff --git a/control-plane/api/internal/router/router.go b/control-plane/api/internal/router/router.go index 3932f31..bd0b57e 100644 --- a/control-plane/api/internal/router/router.go +++ b/control-plane/api/internal/router/router.go @@ -12,6 +12,7 @@ import ( "github.com/omnex/control-plane/api/internal/downloads" "github.com/omnex/control-plane/api/internal/httpx" "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/sub" ) @@ -23,6 +24,7 @@ type Deps struct { Sessions session.Manager AuthH *auth.Handler DemosH *demos.Handler + ProjectsH *projects.Handler SubH *sub.Handler ProfileH *profile.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/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 diff --git a/web/src/App.tsx b/web/src/App.tsx index 58d8169..19501dc 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import { Register } from './pages/Register' import { Demos } from './pages/backoffice/Demos' import { PremiumDemos } from './pages/backoffice/PremiumDemos' import { Codes } from './pages/backoffice/Codes' +import { Projects } from './pages/backoffice/Projects' import { Subscription } from './pages/backoffice/Subscription' import { AppDownloads } from './pages/backoffice/AppDownloads' 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 // 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 }) { const { isAuthenticated, initializing } = useAuth() @@ -98,6 +99,15 @@ export function App() { } /> + + + + } + /> + {isPremium ? 'Ma plateforme' : 'Ma démo'}} {isAdmin && Démos} {isAdmin && Premium} + {isAdmin && Projets} {isAdmin && Codes} @@ -134,6 +135,11 @@ export function BackofficeLayout() { Premium )} + {isAdmin && ( + + Projets + + )} {isAdmin && ( Codes diff --git a/web/src/components/CreateProjectModal.tsx b/web/src/components/CreateProjectModal.tsx new file mode 100644 index 0000000..44e9d19 --- /dev/null +++ b/web/src/components/CreateProjectModal.tsx @@ -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 ( + + + + Déployer un projet + + + + + Client + setClientName(e.target.value)} + isDisabled={saving} + /> + + + + Durée de l'abonnement + + + 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é. + + + + + Identifiant admin du projet + setAdminUsername(e.target.value)} + isDisabled={saving} + autoComplete="off" + /> + 3 à 64 caractères alphanumériques. + + + + Mot de passe admin + setAdminPassword(e.target.value)} + isDisabled={saving} + autoComplete="new-password" + /> + {MIN_PASSWORD} caractères minimum. Il n'est pas conservé par Omnex. + + + + Nombre d'admins autorisés + setAdminNumber(Number.isNaN(n) ? 1 : n)} + isDisabled={saving} + > + + + + + + + Nombre maximum de comptes admin dans le projet (1 à 20). + + + + + + + + + + ) +} diff --git a/web/src/components/ExtendProjectModal.tsx b/web/src/components/ExtendProjectModal.tsx new file mode 100644 index 0000000..5b803bc --- /dev/null +++ b/web/src/components/ExtendProjectModal.tsx @@ -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 ( + + + + Renouveler l'abonnement + + + + Mois à ajouter — {project?.client_name} + + + {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."} + + + + + + + + + + ) +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c8d6eec..3fd84c1 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -58,6 +58,41 @@ export interface Demo { 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 interface CreateDemoParams { @@ -215,6 +250,19 @@ export const api = { request('POST', `/demos/${id}/domain`, { domain }), transferDemoToPremium: (id: string) => request('POST', `/demos/${id}/premium`), + listProjects: () => request<{ items: Project[] }>('GET', '/projects'), + createProject: (params: CreateProjectParams) => + request('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('POST', `/projects/${id}/extend`, { months }), + deleteProject: (id: string) => request('DELETE', `/projects/${id}`), + listCodes: () => request<{ items: CodeBuySub[] }>('GET', '/codes'), listPremiumUsers: () => request<{ items: PremiumUser[] }>('GET', '/premium'), createCode: (username: string) => diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 47f3633..45a0cc2 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -1,4 +1,4 @@ -import type { DemoStatus } from './api' +import type { DemoStatus, ProjectStatus } from './api' // Couleur de badge Chakra par statut de démo. export function statusColor(s: DemoStatus): string { @@ -92,3 +92,43 @@ export function formatDate(iso: string): string { 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 = { + 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` +} diff --git a/web/src/pages/backoffice/Projects.tsx b/web/src/pages/backoffice/Projects.tsx new file mode 100644 index 0000000..fa23bce --- /dev/null +++ b/web/src/pages/backoffice/Projects.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(null) + const [createOpen, setCreateOpen] = useState(false) + const [toExtend, setToExtend] = useState(null) + const [toDelete, setToDelete] = useState(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 ( + + Suppression le {formatDate(p.delete_at)} + + ) + } + return ( + + {formatDate(p.expires_at)} · {timeRemaining(p.expires_at)} + + ) + } + + return ( + + + Projets + + + + + {loading ? ( + + ) : projects.length === 0 ? ( + Aucun projet déployé. + ) : ( + + + + + + + + + + + + + + {projects.map((p) => { + const locked = busy === p.id + const canExtend = p.status === 'ready' || p.status === 'provisioning' || p.status === 'suspended' + return ( + + + + + + + + + + ) + })} + +
ClientStatutAdresseAbonnementÉchéanceAdmins +
{p.client_name} + {projectStatusLabel(p.status)} + + + {p.host} + + {monthsLabel(p.months_purchased)} au total{deadline(p)} + {p.admin_username} · max {p.admin_number} + + + + + +
+
+ )} + + setCreateOpen(false)} onCreated={() => void load()} /> + setToExtend(null)} onExtended={() => void load()} /> + 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. + +
+ ) +}