chore: build
This commit is contained in:
@@ -136,7 +136,7 @@ func main() {
|
|||||||
metricsClient = nil
|
metricsClient = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
helmProv, err := demos.NewHelmProvisioner(&cfg, k8sClient, restConfig, metricsClient, "/charts", "helm")
|
helmProv, err := demos.NewHelmProvisioner(&cfg, k8sClient, restConfig, metricsClient, "/charts", "/charts-vitrine", "helm")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("helm provisioner: %v", err)
|
log.Fatalf("helm provisioner: %v", err)
|
||||||
}
|
}
|
||||||
@@ -169,11 +169,10 @@ func main() {
|
|||||||
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
|
// Projets vitrine (abonnements de 1 à 12 mois) : même cluster, mêmes
|
||||||
// sauvegardes Velero que les démos, mais suspension puis suppression après
|
// sauvegardes Velero que les démos, mais durée choisie au déploiement puis
|
||||||
// un délai de grâce à l'échéance (voir internal/projects).
|
// prolongée ; supprimés à l'échéance (voir internal/projects).
|
||||||
projectSvc := projects.NewService(projectStore, helmProv, projects.Config{
|
projectSvc := projects.NewService(projectStore, helmProv, projects.Config{
|
||||||
Domain: cfg.VitrineDomain,
|
Domain: cfg.VitrineDomain,
|
||||||
Grace: time.Duration(cfg.VitrineGraceDays) * 24 * time.Hour,
|
|
||||||
})
|
})
|
||||||
go projectSvc.RunExpirationLoop(context.Background(), 5*time.Minute)
|
go projectSvc.RunExpirationLoop(context.Background(), 5*time.Minute)
|
||||||
|
|
||||||
@@ -193,8 +192,7 @@ func main() {
|
|||||||
ns = append(ns, d.Namespace)
|
ns = append(ns, d.Namespace)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Projets vitrine en ligne (un projet suspendu n'a plus de pods :
|
// Projets vitrine en ligne.
|
||||||
// l'alerte « pod absent » serait un faux positif).
|
|
||||||
ps, err := projectSvc.List()
|
ps, err := projectSvc.List()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,13 +30,8 @@ type Config struct {
|
|||||||
|
|
||||||
// Projets vitrine (abonnements de 1 à 12 mois, voir internal/projects).
|
// Projets vitrine (abonnements de 1 à 12 mois, voir internal/projects).
|
||||||
VitrineDomain string // OMNEX_VITRINE_DOMAIN : les projets sont servis sur <namespace>.<domaine>
|
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
|
VitrineBackendImage string // VITRINE_BACKEND_IMAGE, ex. registry/vitrine-backend:1.0.0
|
||||||
VitrineFrontendImage string // VITRINE_FRONTEND_IMAGE
|
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.
|
||||||
@@ -66,17 +60,8 @@ func Load() (Config, error) {
|
|||||||
DockerHubPassword: os.Getenv("OMNEX_DOCKERHUB_PASSWORD"),
|
DockerHubPassword: os.Getenv("OMNEX_DOCKERHUB_PASSWORD"),
|
||||||
|
|
||||||
VitrineDomain: getenv("OMNEX_VITRINE_DOMAIN", "vitrine-omnex.club"),
|
VitrineDomain: getenv("OMNEX_VITRINE_DOMAIN", "vitrine-omnex.club"),
|
||||||
VitrineChartsDir: getenv("OMNEX_VITRINE_CHARTS_DIR", "/charts-vitrine"),
|
|
||||||
VitrineBackendImage: os.Getenv("VITRINE_BACKEND_IMAGE"),
|
VitrineBackendImage: os.Getenv("VITRINE_BACKEND_IMAGE"),
|
||||||
VitrineFrontendImage: os.Getenv("VITRINE_FRONTEND_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" {
|
||||||
|
|||||||
@@ -107,21 +107,7 @@ func TestLoad_VitrineDefaults(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if cfg.VitrineDomain != "vitrine-omnex.club" || cfg.VitrineGraceDays != 7 || cfg.VitrineChartsDir != "/charts-vitrine" {
|
if cfg.VitrineDomain != "vitrine-omnex.club" {
|
||||||
t.Errorf("défauts vitrine inattendus : %+v", cfg)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type HelmProvisioner struct {
|
|||||||
|
|
||||||
// NewHelmProvisioner crée un nouveau provisioner Helm.
|
// NewHelmProvisioner crée un nouveau provisioner Helm.
|
||||||
// chartsDir : chemin absolu vers le dossier contenant les charts (backend/, frontend/)
|
// chartsDir : chemin absolu vers le dossier contenant les charts (backend/, frontend/)
|
||||||
|
// vitrineChartsDir : idem pour les charts des projets vitrine (voir internal/projects)
|
||||||
// helmPath : chemin vers l'exécutable helm (optionnel, default: "helm")
|
// helmPath : chemin vers l'exécutable helm (optionnel, default: "helm")
|
||||||
// metricsClient : client metrics.k8s.io pour l'usage CPU/mémoire live (optionnel, peut être nil).
|
// metricsClient : client metrics.k8s.io pour l'usage CPU/mémoire live (optionnel, peut être nil).
|
||||||
func NewHelmProvisioner(
|
func NewHelmProvisioner(
|
||||||
@@ -63,6 +64,7 @@ func NewHelmProvisioner(
|
|||||||
restConfig *rest.Config,
|
restConfig *rest.Config,
|
||||||
metricsClient *metricsclient.Clientset,
|
metricsClient *metricsclient.Clientset,
|
||||||
chartsDir string,
|
chartsDir string,
|
||||||
|
vitrineChartsDir string,
|
||||||
helmPath string,
|
helmPath string,
|
||||||
) (*HelmProvisioner, error) {
|
) (*HelmProvisioner, error) {
|
||||||
if chartsDir == "" {
|
if chartsDir == "" {
|
||||||
@@ -89,7 +91,7 @@ func NewHelmProvisioner(
|
|||||||
metricsClient: metricsClient,
|
metricsClient: metricsClient,
|
||||||
dynamicClient: dynamicClient,
|
dynamicClient: dynamicClient,
|
||||||
chartsDir: chartsDir,
|
chartsDir: chartsDir,
|
||||||
vitrineChartsDir: cfg.VitrineChartsDir,
|
vitrineChartsDir: vitrineChartsDir,
|
||||||
frontendImage: cfg.FrontendImage,
|
frontendImage: cfg.FrontendImage,
|
||||||
backendImage: cfg.BackendImage,
|
backendImage: cfg.BackendImage,
|
||||||
lbtelegramImage: cfg.LBTelegramImage,
|
lbtelegramImage: cfg.LBTelegramImage,
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
package demos
|
package demos
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Projets vitrine (voir internal/projects) : même infrastructure que les
|
// Projets vitrine (voir internal/projects) : même infrastructure que les
|
||||||
// démos (namespace isolé, NetworkPolicy, ResourceQuota, Traefik + WAF
|
// démos (namespace isolé, NetworkPolicy, ResourceQuota, Traefik + WAF
|
||||||
// partagés, sauvegardes Velero par namespace) mais avec les charts du
|
// partagés, sauvegardes Velero par namespace) mais avec les charts du
|
||||||
// produit vitrine (h.vitrineChartsDir) et une durée d'abonnement en mois.
|
// produit vitrine (h.vitrineChartsDir) et une durée d'abonnement en mois.
|
||||||
// Le cycle de vie (durée, suspension, suppression) vit dans internal/projects ;
|
// Le cycle de vie (durée, prolongation, suppression) vit dans internal/projects ;
|
||||||
// ce fichier ne contient que ce qui touche au cluster.
|
// ce fichier ne contient que ce qui touche au cluster.
|
||||||
|
|
||||||
// Noms de release/chart utilisés par les charts vitrine (dossier
|
// Noms de release/chart utilisés par les charts vitrine (dossier
|
||||||
@@ -29,10 +26,6 @@ func vitrineFrontendService(namespace string) string {
|
|||||||
return namespace + "-frontend-vitrine-frontend"
|
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
|
// ProjectSpec : ce dont le provisioner a besoin pour déployer un projet
|
||||||
// vitrine. AdminPassword ne doit jamais être persisté : il ne transite que
|
// vitrine. AdminPassword ne doit jamais être persisté : il ne transite que
|
||||||
// par le fichier de valeurs Helm temporaire puis le Secret k8s du chart.
|
// par le fichier de valeurs Helm temporaire puis le Secret k8s du chart.
|
||||||
@@ -128,92 +121,6 @@ func (h *HelmProvisioner) TeardownProject(namespace string) error {
|
|||||||
return h.Teardown(Demo{Namespace: namespace})
|
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{} {
|
func (h *HelmProvisioner) buildVitrinePostgresValues(password string) map[string]interface{} {
|
||||||
// Nom de base et utilisateur : défauts du chart (vitrine_db / postgres),
|
// Nom de base et utilisateur : défauts du chart (vitrine_db / postgres),
|
||||||
// alignés sur database.name / database.user du chart backend.
|
// alignés sur database.name / database.user du chart backend.
|
||||||
|
|||||||
@@ -14,55 +14,6 @@ import (
|
|||||||
|
|
||||||
func ptr[T any](v T) *T { return &v }
|
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) {
|
func TestVitrineValuesRenderWithCharts(t *testing.T) {
|
||||||
if _, err := exec.LookPath("helm"); err != nil {
|
if _, err := exec.LookPath("helm"); err != nil {
|
||||||
t.Skip("helm absent du PATH")
|
t.Skip("helm absent du PATH")
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func (h *Handler) Get(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extend : POST /projects/:id/extend {"months": 1..12} — renouvelle
|
// Extend : POST /projects/:id/extend {"months": 1..12} — renouvelle
|
||||||
// l'abonnement (relance un projet suspendu).
|
// l'abonnement.
|
||||||
func (h *Handler) Extend(c *gin.Context) {
|
func (h *Handler) Extend(c *gin.Context) {
|
||||||
var req extendRequest
|
var req extendRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// Package projects : cycle de vie des projets vitrine déployés pour un client
|
// 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é).
|
// 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
|
// Contrairement aux démos (TTL fixe de 30 jours), la durée d'un projet payé est
|
||||||
// projet payé est suspendu à l'échéance puis supprimé seulement après un
|
// choisie au déploiement puis prolongée à chaque renouvellement ; à l'échéance
|
||||||
// délai de grâce : un renouvellement en retard ne détruit pas les données du
|
// il est supprimé, sans délai de grâce. Le paiement est hors périmètre :
|
||||||
// client. Le paiement est hors périmètre : l'admin Omnex saisit la durée
|
// l'admin Omnex saisit la durée souscrite au déploiement puis à chaque
|
||||||
// souscrite au déploiement puis à chaque renouvellement.
|
// renouvellement.
|
||||||
package projects
|
package projects
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
@@ -26,16 +26,15 @@ const (
|
|||||||
StatusPending Status = "pending" // créé, pas encore pris par le worker
|
StatusPending Status = "pending" // créé, pas encore pris par le worker
|
||||||
StatusProvisioning Status = "provisioning" // déploiement en cours
|
StatusProvisioning Status = "provisioning" // déploiement en cours
|
||||||
StatusReady Status = "ready" // en ligne
|
StatusReady Status = "ready" // en ligne
|
||||||
StatusSuspended Status = "suspended" // abonnement échu : arrêté, données conservées jusqu'à DeleteAt
|
|
||||||
StatusDeleting Status = "deleting" // suppression en cours
|
StatusDeleting Status = "deleting" // suppression en cours
|
||||||
StatusDeleted Status = "deleted" // supprimé
|
StatusDeleted Status = "deleted" // supprimé
|
||||||
StatusFailed Status = "failed" // échec du déploiement
|
StatusFailed Status = "failed" // échec du déploiement
|
||||||
)
|
)
|
||||||
|
|
||||||
// Extendable : un abonnement peut être prolongé (et un projet suspendu relancé).
|
// Extendable : un abonnement peut être prolongé.
|
||||||
func (s Status) Extendable() bool {
|
func (s Status) Extendable() bool {
|
||||||
switch s {
|
switch s {
|
||||||
case StatusProvisioning, StatusReady, StatusSuspended:
|
case StatusProvisioning, StatusReady:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -58,12 +57,8 @@ type Project struct {
|
|||||||
MonthsPurchased int `json:"months_purchased"`
|
MonthsPurchased int `json:"months_purchased"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
// ExpiresAt : fin de l'abonnement (le projet est alors suspendu).
|
// ExpiresAt : fin de l'abonnement (le projet est alors supprimé).
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
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" }
|
func (Project) TableName() string { return "projects" }
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9]{3,64}$`)
|
|||||||
type Provisioner interface {
|
type Provisioner interface {
|
||||||
ProvisionProject(spec demos.ProjectSpec) error
|
ProvisionProject(spec demos.ProjectSpec) error
|
||||||
TeardownProject(namespace string) error
|
TeardownProject(namespace string) error
|
||||||
SuspendProject(namespace string) error
|
|
||||||
ResumeProject(namespace string) error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoopProvisioner : aucun déploiement réel (dev en mémoire, tests).
|
// NoopProvisioner : aucun déploiement réel (dev en mémoire, tests).
|
||||||
@@ -40,13 +38,10 @@ type NoopProvisioner struct{}
|
|||||||
|
|
||||||
func (NoopProvisioner) ProvisionProject(demos.ProjectSpec) error { return nil }
|
func (NoopProvisioner) ProvisionProject(demos.ProjectSpec) error { return nil }
|
||||||
func (NoopProvisioner) TeardownProject(string) 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).
|
// Config du service (injectable pour les tests).
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Domain string // domaine des projets : <namespace>.<Domain>
|
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).
|
// CreateInput : saisie de l'admin Omnex au déploiement (dashboard).
|
||||||
@@ -135,7 +130,6 @@ func (s *Service) Create(in CreateInput) (Project, error) {
|
|||||||
MonthsPurchased: in.Months,
|
MonthsPurchased: in.Months,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
ExpiresAt: expires,
|
ExpiresAt: expires,
|
||||||
DeleteAt: expires.Add(s.cfg.Grace),
|
|
||||||
}
|
}
|
||||||
created, err := s.store.Create(p)
|
created, err := s.store.Create(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -183,7 +177,7 @@ func (s *Service) Get(id string) (Project, error) {
|
|||||||
func (s *Service) List() ([]Project, error) { return s.store.List() }
|
func (s *Service) List() ([]Project, error) { return s.store.List() }
|
||||||
|
|
||||||
// Extend ajoute months mois à l'abonnement, à partir de l'échéance courante
|
// 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é.
|
// (ou de maintenant si elle est dépassée).
|
||||||
func (s *Service) Extend(id string, months int) (Project, error) {
|
func (s *Service) Extend(id string, months int) (Project, error) {
|
||||||
if err := ValidateMonths(months); err != nil {
|
if err := ValidateMonths(months); err != nil {
|
||||||
return Project{}, err
|
return Project{}, err
|
||||||
@@ -205,21 +199,13 @@ func (s *Service) Extend(id string, months int) (Project, error) {
|
|||||||
base = 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.ExpiresAt = base.AddDate(0, months, 0)
|
||||||
p.DeleteAt = p.ExpiresAt.Add(s.cfg.Grace)
|
|
||||||
p.MonthsPurchased += months
|
p.MonthsPurchased += months
|
||||||
return s.store.Update(p)
|
return s.store.Update(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete détruit le projet (namespace, données, sauvegardes) sans attendre le
|
// Delete détruit le projet (namespace, données, sauvegardes) sans attendre
|
||||||
// délai de grâce — action explicite de l'admin.
|
// l'échéance — action explicite de l'admin.
|
||||||
func (s *Service) Delete(id string) (Project, error) {
|
func (s *Service) Delete(id string) (Project, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -246,7 +232,7 @@ func (s *Service) deleteLocked(id string) (Project, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RunExpirationLoop applique le cycle de vie à intervalle régulier jusqu'à
|
// RunExpirationLoop applique le cycle de vie à intervalle régulier jusqu'à
|
||||||
// l'annulation de ctx : suspension à l'échéance, suppression à DeleteAt.
|
// l'annulation de ctx : suppression des projets dont l'abonnement est échu.
|
||||||
func (s *Service) RunExpirationLoop(ctx context.Context, interval time.Duration) {
|
func (s *Service) RunExpirationLoop(ctx context.Context, interval time.Duration) {
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -273,40 +259,18 @@ func (s *Service) expireOverdue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyLifecycle relit le projet (il a pu changer depuis List) et applique la
|
// applyLifecycle relit le projet (il a pu changer depuis List) et le supprime
|
||||||
// transition due. À appeler avec s.mu tenu.
|
// si son abonnement est échu. À appeler avec s.mu tenu.
|
||||||
func (s *Service) applyLifecycle(id string) {
|
func (s *Service) applyLifecycle(id string) {
|
||||||
p, ok := s.store.Get(id)
|
p, ok := s.store.Get(id)
|
||||||
if !ok {
|
if !ok || p.Status != StatusReady || p.ExpiresAt.After(s.now().UTC()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := s.now().UTC()
|
if _, err := s.deleteLocked(p.ID); err != nil {
|
||||||
switch p.Status {
|
log.Printf("projets: suppression de %s échouée: %v", p.Namespace, err)
|
||||||
case StatusReady:
|
return
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
log.Printf("projets: %s supprimé (abonnement échu)", p.Namespace)
|
||||||
}
|
}
|
||||||
|
|
||||||
// shortID : 8 premiers caractères hex d'un uuid pour un nom de namespace court.
|
// shortID : 8 premiers caractères hex d'un uuid pour un nom de namespace court.
|
||||||
|
|||||||
@@ -14,11 +14,8 @@ import (
|
|||||||
type fakeProv struct {
|
type fakeProv struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
specs []demos.ProjectSpec
|
specs []demos.ProjectSpec
|
||||||
suspended []string
|
|
||||||
resumed []string
|
|
||||||
tornDown []string
|
tornDown []string
|
||||||
provErr error
|
provErr error
|
||||||
resumeErr error
|
|
||||||
provisioned chan struct{}
|
provisioned chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,25 +34,13 @@ func (f *fakeProv) TeardownProject(ns string) error {
|
|||||||
f.tornDown = append(f.tornDown, ns)
|
f.tornDown = append(f.tornDown, ns)
|
||||||
return nil
|
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)
|
var t0 = time.Date(2026, time.September, 20, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
func newTestService(t *testing.T) (*Service, *fakeProv, *time.Time) {
|
func newTestService(t *testing.T) (*Service, *fakeProv, *time.Time) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
prov := newFakeProv()
|
prov := newFakeProv()
|
||||||
svc := NewService(NewMemStore(), prov, Config{Domain: "vitrine-omnex.club", Grace: 7 * 24 * time.Hour})
|
svc := NewService(NewMemStore(), prov, Config{Domain: "vitrine-omnex.club"})
|
||||||
now := t0
|
now := t0
|
||||||
svc.now = func() time.Time { return now }
|
svc.now = func() time.Time { return now }
|
||||||
return svc, prov, &now
|
return svc, prov, &now
|
||||||
@@ -109,9 +94,6 @@ func TestCreate_ExpiryIsCalendarMonths(t *testing.T) {
|
|||||||
if !p.ExpiresAt.Equal(want) {
|
if !p.ExpiresAt.Equal(want) {
|
||||||
t.Errorf("months=%d : ExpiresAt=%v, attendu %v", months, p.ExpiresAt, 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 {
|
if p.MonthsPurchased != months {
|
||||||
t.Errorf("MonthsPurchased=%d, attendu %d", p.MonthsPurchased, months)
|
t.Errorf("MonthsPurchased=%d, attendu %d", p.MonthsPurchased, months)
|
||||||
}
|
}
|
||||||
@@ -196,9 +178,6 @@ func TestExtend_AddsMonthsFromCurrentExpiry(t *testing.T) {
|
|||||||
if got.MonthsPurchased != 15 {
|
if got.MonthsPurchased != 15 {
|
||||||
t.Errorf("MonthsPurchased=%d, attendu 15", got.MonthsPurchased)
|
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) {
|
func TestExtend_MonthsBounds(t *testing.T) {
|
||||||
@@ -222,89 +201,61 @@ func TestExtend_UnknownProject(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExpiration_SuspendsAtExpiryThenDeletesAfterGrace(t *testing.T) {
|
func TestExpiration_DeletesAtExpiry(t *testing.T) {
|
||||||
svc, prov, now := newTestService(t)
|
svc, prov, now := newTestService(t)
|
||||||
p := createReady(t, svc, prov, validInput()) // 3 mois
|
p := createReady(t, svc, prov, validInput()) // 3 mois
|
||||||
|
|
||||||
// Avant l'échéance : rien ne bouge.
|
// Avant l'échéance : rien ne bouge.
|
||||||
*now = p.ExpiresAt.Add(-time.Hour)
|
*now = p.ExpiresAt.Add(-time.Hour)
|
||||||
svc.expireOverdue()
|
svc.expireOverdue()
|
||||||
if len(prov.suspended) != 0 {
|
if len(prov.tornDown) != 0 {
|
||||||
t.Fatal("suspension avant l'échéance")
|
t.Fatal("suppression avant l'échéance")
|
||||||
}
|
}
|
||||||
|
|
||||||
// À l'échéance : suspendu, jamais supprimé.
|
// À l'échéance : supprimé, sans délai de grâce.
|
||||||
*now = p.ExpiresAt.Add(time.Minute)
|
*now = p.ExpiresAt.Add(time.Minute)
|
||||||
svc.expireOverdue()
|
svc.expireOverdue()
|
||||||
got, _ := svc.Get(p.ID)
|
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 {
|
if got.Status != StatusDeleted || len(prov.tornDown) != 1 || prov.tornDown[0] != p.Namespace {
|
||||||
t.Errorf("statut %s, supprimés=%v", got.Status, prov.tornDown)
|
t.Errorf("statut %s, supprimés=%v", got.Status, prov.tornDown)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Déjà supprimé : la boucle ne rappelle pas le cluster.
|
||||||
|
svc.expireOverdue()
|
||||||
|
if len(prov.tornDown) != 1 {
|
||||||
|
t.Errorf("teardown répété : %v", prov.tornDown)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtend_ResumesSuspendedProject(t *testing.T) {
|
func TestExtend_BeforeExpiryKeepsProject(t *testing.T) {
|
||||||
svc, prov, now := newTestService(t)
|
svc, prov, now := newTestService(t)
|
||||||
p := createReady(t, svc, prov, validInput())
|
p := createReady(t, svc, prov, validInput())
|
||||||
|
|
||||||
*now = p.ExpiresAt.Add(2 * 24 * time.Hour)
|
*now = p.ExpiresAt.Add(-time.Hour)
|
||||||
svc.expireOverdue()
|
|
||||||
|
|
||||||
got, err := svc.Extend(p.ID, 1)
|
got, err := svc.Extend(p.ID, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got.Status != StatusReady || got.SuspendedAt != nil {
|
if want := p.ExpiresAt.AddDate(0, 1, 0); !got.ExpiresAt.Equal(want) {
|
||||||
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)
|
t.Errorf("ExpiresAt=%v, attendu %v", got.ExpiresAt, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renouvelé : la boucle ne doit plus le suspendre.
|
// L'ancienne échéance est passée : le projet renouvelé ne doit pas être supprimé.
|
||||||
|
*now = p.ExpiresAt.Add(time.Minute)
|
||||||
svc.expireOverdue()
|
svc.expireOverdue()
|
||||||
if len(prov.suspended) != 1 {
|
if len(prov.tornDown) != 0 {
|
||||||
t.Errorf("re-suspension d'un projet renouvelé : %v", prov.suspended)
|
t.Errorf("suppression d'un projet renouvelé : %v", prov.tornDown)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtend_ResumeFailureKeepsProjectSuspended(t *testing.T) {
|
func TestExtend_DeletedProjectNotExtendable(t *testing.T) {
|
||||||
svc, prov, now := newTestService(t)
|
svc, prov, now := newTestService(t)
|
||||||
p := createReady(t, svc, prov, validInput())
|
p := createReady(t, svc, prov, validInput())
|
||||||
*now = p.ExpiresAt.Add(time.Hour)
|
*now = p.ExpiresAt.Add(time.Minute)
|
||||||
svc.expireOverdue()
|
svc.expireOverdue()
|
||||||
|
|
||||||
prov.resumeErr = errors.New("api k8s indisponible")
|
if _, err := svc.Extend(p.ID, 1); !errors.Is(err, ErrNotExtendable) {
|
||||||
if _, err := svc.Extend(p.ID, 1); err == nil {
|
t.Errorf("erreur %v, attendu ErrNotExtendable", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,19 +277,3 @@ func TestDelete_IsIdempotentAndImmediate(t *testing.T) {
|
|||||||
t.Errorf("erreur %v, attendu ErrNotFound", err)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ export function CreateProjectModal({ isOpen, onClose, onCreated }: CreateProject
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
Durée réellement payée par le client. À l'échéance le projet est suspendu, puis supprimé
|
Durée réellement payée par le client. À l'échéance le projet est supprimé s'il n'est pas
|
||||||
après le délai de grâce s'il n'est pas renouvelé.
|
renouvelé.
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface ExtendProjectModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Renouvellement d'un abonnement : 1 à 12 mois ajoutés à l'échéance courante
|
// 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.
|
// (ou à aujourd'hui si elle est dépassée).
|
||||||
export function ExtendProjectModal({ project, onClose, onExtended }: ExtendProjectModalProps) {
|
export function ExtendProjectModal({ project, onClose, onExtended }: ExtendProjectModalProps) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [months, setMonths] = useState(1)
|
const [months, setMonths] = useState(1)
|
||||||
@@ -72,9 +72,7 @@ export function ExtendProjectModal({ project, onClose, onExtended }: ExtendProje
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<FormHelperText>
|
<FormHelperText>
|
||||||
{project?.status === 'suspended'
|
Les mois s'ajoutent à l'échéance actuelle.
|
||||||
? "Le projet est suspendu : il sera relancé et l'abonnement repart d'aujourd'hui."
|
|
||||||
: "Les mois s'ajoutent à l'échéance actuelle."}
|
|
||||||
</FormHelperText>
|
</FormHelperText>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|||||||
+2
-5
@@ -58,13 +58,12 @@ export interface Demo {
|
|||||||
expires_at: string
|
expires_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Projets vitrine : abonnement de 1 à 12 mois, suspendu à l'échéance puis
|
// Projets vitrine : abonnement de 1 à 12 mois, supprimé à l'échéance
|
||||||
// supprimé après un délai de grâce (voir internal/projects côté API).
|
// (voir internal/projects côté API).
|
||||||
export type ProjectStatus =
|
export type ProjectStatus =
|
||||||
| 'pending'
|
| 'pending'
|
||||||
| 'provisioning'
|
| 'provisioning'
|
||||||
| 'ready'
|
| 'ready'
|
||||||
| 'suspended'
|
|
||||||
| 'deleting'
|
| 'deleting'
|
||||||
| 'deleted'
|
| 'deleted'
|
||||||
| 'failed'
|
| 'failed'
|
||||||
@@ -81,8 +80,6 @@ export interface Project {
|
|||||||
months_purchased: number
|
months_purchased: number
|
||||||
created_at: string
|
created_at: string
|
||||||
expires_at: string
|
expires_at: string
|
||||||
delete_at: string
|
|
||||||
suspended_at?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateProjectParams {
|
export interface CreateProjectParams {
|
||||||
|
|||||||
@@ -101,8 +101,6 @@ export function projectStatusColor(s: ProjectStatus): string {
|
|||||||
case 'provisioning':
|
case 'provisioning':
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return 'blue'
|
return 'blue'
|
||||||
case 'suspended':
|
|
||||||
return 'orange'
|
|
||||||
case 'deleting':
|
case 'deleting':
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return 'red'
|
return 'red'
|
||||||
@@ -118,7 +116,6 @@ export function projectStatusLabel(s: ProjectStatus): string {
|
|||||||
pending: 'En attente',
|
pending: 'En attente',
|
||||||
provisioning: 'Déploiement…',
|
provisioning: 'Déploiement…',
|
||||||
ready: 'En ligne',
|
ready: 'En ligne',
|
||||||
suspended: 'Suspendu',
|
|
||||||
deleting: 'Suppression…',
|
deleting: 'Suppression…',
|
||||||
deleted: 'Supprimé',
|
deleted: 'Supprimé',
|
||||||
failed: 'Échec',
|
failed: 'Échec',
|
||||||
|
|||||||
@@ -75,21 +75,12 @@ export function Projects() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Échéance affichée : temps restant, ou date de suppression si suspendu.
|
// Échéance affichée : date de suppression et temps restant.
|
||||||
const deadline = (p: Project) => {
|
const deadline = (p: Project) => (
|
||||||
if (p.status === 'suspended') {
|
<Text fontSize="sm">
|
||||||
return (
|
{formatDate(p.expires_at)} · {timeRemaining(p.expires_at)}
|
||||||
<Text color="orange.500" fontSize="sm">
|
</Text>
|
||||||
Suppression le {formatDate(p.delete_at)}
|
)
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Text fontSize="sm">
|
|
||||||
{formatDate(p.expires_at)} · {timeRemaining(p.expires_at)}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box p={{ base: 4, md: 8 }}>
|
<Box p={{ base: 4, md: 8 }}>
|
||||||
@@ -122,7 +113,7 @@ export function Projects() {
|
|||||||
<Tbody>
|
<Tbody>
|
||||||
{projects.map((p) => {
|
{projects.map((p) => {
|
||||||
const locked = busy === p.id
|
const locked = busy === p.id
|
||||||
const canExtend = p.status === 'ready' || p.status === 'provisioning' || p.status === 'suspended'
|
const canExtend = p.status === 'ready' || p.status === 'provisioning'
|
||||||
return (
|
return (
|
||||||
<Tr key={p.id}>
|
<Tr key={p.id}>
|
||||||
<Td>{p.client_name}</Td>
|
<Td>{p.client_name}</Td>
|
||||||
|
|||||||
Reference in New Issue
Block a user