chore: build
This commit is contained in:
@@ -136,7 +136,7 @@ func main() {
|
||||
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 {
|
||||
log.Fatalf("helm provisioner: %v", err)
|
||||
}
|
||||
@@ -169,11 +169,10 @@ func main() {
|
||||
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).
|
||||
// sauvegardes Velero que les démos, mais durée choisie au déploiement puis
|
||||
// prolongée ; supprimés à 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)
|
||||
|
||||
@@ -193,8 +192,7 @@ 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).
|
||||
// Projets vitrine en ligne.
|
||||
ps, err := projectSvc.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -3,7 +3,6 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -31,13 +30,8 @@ type Config struct {
|
||||
|
||||
// Projets vitrine (abonnements de 1 à 12 mois, voir internal/projects).
|
||||
VitrineDomain string // OMNEX_VITRINE_DOMAIN : les projets sont servis sur <namespace>.<domaine>
|
||||
VitrineChartsDir string // OMNEX_VITRINE_CHARTS_DIR : charts backend/frontend/postgresql/redis/ingressroute du projet vitrine
|
||||
VitrineBackendImage string // VITRINE_BACKEND_IMAGE, ex. registry/vitrine-backend:1.0.0
|
||||
VitrineFrontendImage string // VITRINE_FRONTEND_IMAGE
|
||||
// VitrineGraceDays : jours pendant lesquels un projet dont l'abonnement
|
||||
// a expiré reste suspendu (données et sauvegardes conservées) avant sa
|
||||
// suppression définitive. Un renouvellement pendant ce délai le relance.
|
||||
VitrineGraceDays int
|
||||
}
|
||||
|
||||
// Load lit la config. Fail-secure : secret JWT obligatoire ; en prod base + Redis aussi.
|
||||
@@ -66,17 +60,8 @@ func Load() (Config, error) {
|
||||
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" {
|
||||
|
||||
@@ -107,21 +107,7 @@ func TestLoad_VitrineDefaults(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// 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")
|
||||
// metricsClient : client metrics.k8s.io pour l'usage CPU/mémoire live (optionnel, peut être nil).
|
||||
func NewHelmProvisioner(
|
||||
@@ -63,6 +64,7 @@ func NewHelmProvisioner(
|
||||
restConfig *rest.Config,
|
||||
metricsClient *metricsclient.Clientset,
|
||||
chartsDir string,
|
||||
vitrineChartsDir string,
|
||||
helmPath string,
|
||||
) (*HelmProvisioner, error) {
|
||||
if chartsDir == "" {
|
||||
@@ -89,7 +91,7 @@ func NewHelmProvisioner(
|
||||
metricsClient: metricsClient,
|
||||
dynamicClient: dynamicClient,
|
||||
chartsDir: chartsDir,
|
||||
vitrineChartsDir: cfg.VitrineChartsDir,
|
||||
vitrineChartsDir: vitrineChartsDir,
|
||||
frontendImage: cfg.FrontendImage,
|
||||
backendImage: cfg.BackendImage,
|
||||
lbtelegramImage: cfg.LBTelegramImage,
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
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 ;
|
||||
// Le cycle de vie (durée, prolongation, 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
|
||||
@@ -29,10 +26,6 @@ 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.
|
||||
@@ -128,92 +121,6 @@ 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.
|
||||
|
||||
@@ -14,55 +14,6 @@ import (
|
||||
|
||||
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")
|
||||
|
||||
@@ -82,7 +82,7 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Extend : POST /projects/:id/extend {"months": 1..12} — renouvelle
|
||||
// l'abonnement (relance un projet suspendu).
|
||||
// l'abonnement.
|
||||
func (h *Handler) Extend(c *gin.Context) {
|
||||
var req extendRequest
|
||||
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
|
||||
// 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.
|
||||
// Contrairement aux démos (TTL fixe de 30 jours), la durée d'un projet payé est
|
||||
// choisie au déploiement puis prolongée à chaque renouvellement ; à l'échéance
|
||||
// il est supprimé, sans délai de grâce. 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"
|
||||
@@ -26,16 +26,15 @@ 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é).
|
||||
// Extendable : un abonnement peut être prolongé.
|
||||
func (s Status) Extendable() bool {
|
||||
switch s {
|
||||
case StatusProvisioning, StatusReady, StatusSuspended:
|
||||
case StatusProvisioning, StatusReady:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -58,12 +57,8 @@ type Project struct {
|
||||
MonthsPurchased int `json:"months_purchased"`
|
||||
|
||||
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"`
|
||||
// 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" }
|
||||
|
||||
@@ -31,8 +31,6 @@ var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9]{3,64}$`)
|
||||
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).
|
||||
@@ -40,13 +38,10 @@ type NoopProvisioner struct{}
|
||||
|
||||
func (NoopProvisioner) ProvisionProject(demos.ProjectSpec) error { return nil }
|
||||
func (NoopProvisioner) TeardownProject(string) error { return nil }
|
||||
func (NoopProvisioner) SuspendProject(string) error { return nil }
|
||||
func (NoopProvisioner) ResumeProject(string) error { return nil }
|
||||
|
||||
// Config du service (injectable pour les tests).
|
||||
type Config struct {
|
||||
Domain string // domaine des projets : <namespace>.<Domain>
|
||||
Grace time.Duration // délai entre l'échéance (suspension) et la suppression
|
||||
Domain string // domaine des projets : <namespace>.<Domain>
|
||||
}
|
||||
|
||||
// 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,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: expires,
|
||||
DeleteAt: expires.Add(s.cfg.Grace),
|
||||
}
|
||||
created, err := s.store.Create(p)
|
||||
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() }
|
||||
|
||||
// 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) {
|
||||
if err := ValidateMonths(months); err != nil {
|
||||
return Project{}, err
|
||||
@@ -205,21 +199,13 @@ func (s *Service) Extend(id string, months int) (Project, error) {
|
||||
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.
|
||||
// Delete détruit le projet (namespace, données, sauvegardes) sans attendre
|
||||
// l'échéance — action explicite de l'admin.
|
||||
func (s *Service) Delete(id string) (Project, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -246,7 +232,7 @@ func (s *Service) deleteLocked(id string) (Project, error) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
@@ -273,40 +259,18 @@ func (s *Service) expireOverdue() {
|
||||
}
|
||||
}
|
||||
|
||||
// applyLifecycle relit le projet (il a pu changer depuis List) et applique la
|
||||
// transition due. À appeler avec s.mu tenu.
|
||||
// applyLifecycle relit le projet (il a pu changer depuis List) et le supprime
|
||||
// si son abonnement est échu. À appeler avec s.mu tenu.
|
||||
func (s *Service) applyLifecycle(id string) {
|
||||
p, ok := s.store.Get(id)
|
||||
if !ok {
|
||||
if !ok || p.Status != StatusReady || p.ExpiresAt.After(s.now().UTC()) {
|
||||
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)
|
||||
if _, err := s.deleteLocked(p.ID); err != nil {
|
||||
log.Printf("projets: suppression de %s échouée: %v", p.Namespace, err)
|
||||
return
|
||||
}
|
||||
log.Printf("projets: %s supprimé (abonnement échu)", p.Namespace)
|
||||
}
|
||||
|
||||
// shortID : 8 premiers caractères hex d'un uuid pour un nom de namespace court.
|
||||
|
||||
@@ -14,11 +14,8 @@ import (
|
||||
type fakeProv struct {
|
||||
mu sync.Mutex
|
||||
specs []demos.ProjectSpec
|
||||
suspended []string
|
||||
resumed []string
|
||||
tornDown []string
|
||||
provErr error
|
||||
resumeErr error
|
||||
provisioned chan struct{}
|
||||
}
|
||||
|
||||
@@ -37,25 +34,13 @@ func (f *fakeProv) TeardownProject(ns string) error {
|
||||
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})
|
||||
svc := NewService(NewMemStore(), prov, Config{Domain: "vitrine-omnex.club"})
|
||||
now := t0
|
||||
svc.now = func() time.Time { return now }
|
||||
return svc, prov, &now
|
||||
@@ -109,9 +94,6 @@ func TestCreate_ExpiryIsCalendarMonths(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -196,9 +178,6 @@ func TestExtend_AddsMonthsFromCurrentExpiry(t *testing.T) {
|
||||
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) {
|
||||
@@ -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)
|
||||
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")
|
||||
if len(prov.tornDown) != 0 {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
p := createReady(t, svc, prov, validInput())
|
||||
|
||||
*now = p.ExpiresAt.Add(2 * 24 * time.Hour)
|
||||
svc.expireOverdue()
|
||||
|
||||
*now = p.ExpiresAt.Add(-time.Hour)
|
||||
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) {
|
||||
if want := p.ExpiresAt.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.
|
||||
// L'ancienne échéance est passée : le projet renouvelé ne doit pas être supprimé.
|
||||
*now = p.ExpiresAt.Add(time.Minute)
|
||||
svc.expireOverdue()
|
||||
if len(prov.suspended) != 1 {
|
||||
t.Errorf("re-suspension d'un projet renouvelé : %v", prov.suspended)
|
||||
if len(prov.tornDown) != 0 {
|
||||
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)
|
||||
p := createReady(t, svc, prov, validInput())
|
||||
*now = p.ExpiresAt.Add(time.Hour)
|
||||
*now = p.ExpiresAt.Add(time.Minute)
|
||||
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)
|
||||
if _, err := svc.Extend(p.ID, 1); !errors.Is(err, ErrNotExtendable) {
|
||||
t.Errorf("erreur %v, attendu ErrNotExtendable", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,19 +277,3 @@ func TestDelete_IsIdempotentAndImmediate(t *testing.T) {
|
||||
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>
|
||||
<FormHelperText>
|
||||
Durée réellement payée par le client. À l'échéance le projet est suspendu, puis supprimé
|
||||
après le délai de grâce s'il n'est pas renouvelé.
|
||||
Durée réellement payée par le client. À l'échéance le projet est supprimé s'il n'est pas
|
||||
renouvelé.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ interface ExtendProjectModalProps {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const toast = useToast()
|
||||
const [months, setMonths] = useState(1)
|
||||
@@ -72,9 +72,7 @@ export function ExtendProjectModal({ project, onClose, onExtended }: ExtendProje
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
{project?.status === 'suspended'
|
||||
? "Le projet est suspendu : il sera relancé et l'abonnement repart d'aujourd'hui."
|
||||
: "Les mois s'ajoutent à l'échéance actuelle."}
|
||||
Les mois s'ajoutent à l'échéance actuelle.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
|
||||
+2
-5
@@ -58,13 +58,12 @@ 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).
|
||||
// Projets vitrine : abonnement de 1 à 12 mois, supprimé à l'échéance
|
||||
// (voir internal/projects côté API).
|
||||
export type ProjectStatus =
|
||||
| 'pending'
|
||||
| 'provisioning'
|
||||
| 'ready'
|
||||
| 'suspended'
|
||||
| 'deleting'
|
||||
| 'deleted'
|
||||
| 'failed'
|
||||
@@ -81,8 +80,6 @@ export interface Project {
|
||||
months_purchased: number
|
||||
created_at: string
|
||||
expires_at: string
|
||||
delete_at: string
|
||||
suspended_at?: string
|
||||
}
|
||||
|
||||
export interface CreateProjectParams {
|
||||
|
||||
@@ -101,8 +101,6 @@ export function projectStatusColor(s: ProjectStatus): string {
|
||||
case 'provisioning':
|
||||
case 'pending':
|
||||
return 'blue'
|
||||
case 'suspended':
|
||||
return 'orange'
|
||||
case 'deleting':
|
||||
case 'failed':
|
||||
return 'red'
|
||||
@@ -118,7 +116,6 @@ export function projectStatusLabel(s: ProjectStatus): string {
|
||||
pending: 'En attente',
|
||||
provisioning: 'Déploiement…',
|
||||
ready: 'En ligne',
|
||||
suspended: 'Suspendu',
|
||||
deleting: 'Suppression…',
|
||||
deleted: 'Supprimé',
|
||||
failed: 'Échec',
|
||||
|
||||
@@ -75,21 +75,12 @@ export function Projects() {
|
||||
}
|
||||
}
|
||||
|
||||
// Échéance affichée : temps restant, ou date de suppression si suspendu.
|
||||
const deadline = (p: Project) => {
|
||||
if (p.status === 'suspended') {
|
||||
return (
|
||||
<Text color="orange.500" fontSize="sm">
|
||||
Suppression le {formatDate(p.delete_at)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Text fontSize="sm">
|
||||
{formatDate(p.expires_at)} · {timeRemaining(p.expires_at)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
// Échéance affichée : date de suppression et temps restant.
|
||||
const deadline = (p: Project) => (
|
||||
<Text fontSize="sm">
|
||||
{formatDate(p.expires_at)} · {timeRemaining(p.expires_at)}
|
||||
</Text>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box p={{ base: 4, md: 8 }}>
|
||||
@@ -122,7 +113,7 @@ export function Projects() {
|
||||
<Tbody>
|
||||
{projects.map((p) => {
|
||||
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 (
|
||||
<Tr key={p.id}>
|
||||
<Td>{p.client_name}</Td>
|
||||
|
||||
Reference in New Issue
Block a user