chore: update
ci-api / test (push) Successful in 21m59s
ci-web / test (push) Successful in 13m44s

This commit is contained in:
Xor290
2026-08-02 21:31:29 +02:00
parent ca14327ea2
commit 2592ea1c6f
15 changed files with 338 additions and 1629 deletions
+19
View File
@@ -4,6 +4,7 @@ package demos
import (
"errors"
"regexp"
"strings"
"time"
)
@@ -33,8 +34,26 @@ const (
var (
ErrInvalidStorageDriver = errors.New("storage_driver doit être 'local' ou 's3'")
ErrS3ConfigIncomplete = errors.New("s3_bucket et s3_endpoint sont requis pour le stockage S3")
ErrInvalidDomain = errors.New("nom de domaine invalide")
)
// domainPattern : nom d'hôte RFC 1123 (labels alphanumériques + tirets,
// séparés par des points, au moins un point — pas de protocole ni de chemin).
var domainPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$`)
// ValidateDomain vérifie le format d'un domaine personnalisé. Une chaîne
// vide est valide : elle signifie "revenir au domaine par défaut" (voir
// Service.SetDomain).
func ValidateDomain(domain string) error {
if domain == "" {
return nil
}
if len(domain) > 253 || !domainPattern.MatchString(domain) {
return ErrInvalidDomain
}
return nil
}
// ProvisionConfig : réglages saisis par l'admin à la création (popup de
// déploiement), propagés jusqu'au chart backend. Jamais persisté en base —
// notamment les secrets (token Telegram, clés NowPayments), qui ne
@@ -179,6 +179,35 @@ func (h *Handler) Delete(c *gin.Context) {
c.JSON(http.StatusOK, d)
}
type setDomainRequest struct {
// Domain : nom d'hôte sans protocole ni chemin (ex. "boutique.client.com").
// Vide = réinitialise au domaine par défaut.
Domain string `json:"domain" binding:"omitempty,max=253"`
}
// SetDomain : POST /demos/:id/domain — modifie (ou réinitialise si vide) le
// domaine public d'une démo.
func (h *Handler) SetDomain(c *gin.Context) {
var req setDomainRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
return
}
d, err := h.svc.SetDomain(c.Param("id"), req.Domain)
if err != nil {
switch {
case errors.Is(err, ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"})
case errors.Is(err, ErrInvalidDomain):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
}
return
}
c.JSON(http.StatusOK, d)
}
// Extend : POST /demos/:id/extend — prolonge de 30 jours.
func (h *Handler) Extend(c *gin.Context) {
d, err := h.svc.Extend(c.Param("id"))
@@ -21,6 +21,7 @@ import (
k8sCoreV1 "k8s.io/api/core/v1"
k8sErrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
@@ -314,6 +315,80 @@ func (h *HelmProvisioner) MigrateToPremiumNamespace(d Demo, newNamespace, newURL
return nil
}
// UpdateDomain change le domaine public d'une démo sans changer de
// namespace ni migrer de données : réinstalle l'IngressRoute avec le
// nouveau host (Traefik route sur le nom d'hôte), et si un bot Telegram
// et/ou le load-balancer lbtelegram sont configurés, met à jour leurs
// valeurs dépendantes du domaine (URL de webhook, GATEWAY_URL) puis force
// un redémarrage des pods concernés — un Secret modifié seul ne déclenche
// pas de rollout automatique côté Kubernetes.
//
// newURL : URL complète ("https://hôte[:port]") telle que calculée par
// Service.SetDomain. Best-effort sur backend/lbtelegram : une démo encore
// en cours de provisioning (releases pas encore installées) n'a que son
// IngressRoute mis à jour, sans erreur bloquante.
func (h *HelmProvisioner) UpdateDomain(d Demo, newURL string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
host := strings.TrimPrefix(strings.TrimPrefix(newURL, "https://"), "http://")
if i := strings.IndexByte(host, '/'); i >= 0 {
host = host[:i]
}
if i := strings.IndexByte(host, ':'); i >= 0 {
host = host[:i] // Host() de Traefik ne matche pas le port
}
ingressValues := map[string]interface{}{
"host": host,
"traefikNamespace": sharedTraefikNamespace,
}
if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-ingressroute", "ingressroute", ingressValues); err != nil {
return fmt.Errorf("mise à jour ingressroute: %w", err)
}
if backendValues, err := h.getReleaseValues(d.Namespace, d.Namespace+"-backend"); err == nil {
secrets := asStringMap(backendValues["secrets"])
if secrets["TELEGRAM_BOT_TOKEN"] != "" {
secrets["TELEGRAM_WEBHOOK_URL"] = newURL + "/webhook/telegram"
backendValues["secrets"] = secrets
if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-backend", "backend", backendValues); err != nil {
log.Printf("Warning: mise à jour webhook Telegram (backend) échouée pour %s: %v", d.Namespace, err)
} else if err := h.restartDeployment(ctx, d.Namespace, d.Namespace+"-backend-gestion-backend"); err != nil {
log.Printf("Warning: redémarrage backend échoué pour %s: %v", d.Namespace, err)
}
}
}
if lbValues, ok := h.getReleaseValuesOptional(d.Namespace, d.Namespace+"-lbtelegram"); ok {
env := asStringMap(lbValues["env"])
env["GATEWAY_URL"] = "https://" + host
lbValues["env"] = env
lbValues["host"] = host
if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-lbtelegram", "lbtelegram", lbValues); err != nil {
log.Printf("Warning: mise à jour host lbtelegram échouée pour %s: %v", d.Namespace, err)
} else if err := h.restartDeployment(ctx, d.Namespace, d.Namespace+"-lbtelegram-lbtelegram"); err != nil {
log.Printf("Warning: redémarrage lbtelegram échoué pour %s: %v", d.Namespace, err)
}
}
log.Printf("Domaine de %s mis à jour: %s", d.Namespace, host)
return nil
}
// restartDeployment force un nouveau rollout (annotation datée sur le pod
// template) pour qu'un Deployment reprenne en compte un Secret modifié sans
// changer d'image ni de spec structurel — Kubernetes ne redémarre pas les
// pods automatiquement dans ce cas.
func (h *HelmProvisioner) restartDeployment(ctx context.Context, namespace, name string) error {
patch := []byte(fmt.Sprintf(
`{"spec":{"template":{"metadata":{"annotations":{"omnex.app/restartedAt":%q}}}}}`,
time.Now().UTC().Format(time.RFC3339),
))
_, err := h.k8sClient.AppsV1().Deployments(namespace).Patch(ctx, name, types.StrategicMergePatchType, patch, metav1.PatchOptions{})
return err
}
// migratePostgresData copie les données postgres d'un namespace à l'autre
// (pg_dump / psql restore), utilisé par MigrateToPremiumNamespace.
func (h *HelmProvisioner) migratePostgresData(ctx context.Context, oldNamespace, newNamespace string) error {
+11 -8
View File
@@ -3,14 +3,17 @@ package demos
import "time"
type Demo struct {
ID string `gorm:"type:uuid;primaryKey" json:"id"`
Username string `gorm:"type:varchar(64);index" json:"username,omitempty"`
Status Status `gorm:"size:20;not null;index" json:"status"`
Namespace string `gorm:"size:63;uniqueIndex" json:"namespace"`
URL string `gorm:"size:255" json:"url"`
TypeAbo string `gorm:"type:varchar(35)" json:"type_abonnement"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
ID string `gorm:"type:uuid;primaryKey" json:"id"`
Username string `gorm:"type:varchar(64);index" json:"username,omitempty"`
Status Status `gorm:"size:20;not null;index" json:"status"`
Namespace string `gorm:"size:63;uniqueIndex" json:"namespace"`
URL string `gorm:"size:255" json:"url"`
// CustomDomain : domaine choisi par l'admin ("" = domaine par défaut
// "<namespace>.<baseDomain>", voir Service.SetDomain).
CustomDomain string `gorm:"size:255" json:"custom_domain,omitempty"`
TypeAbo string `gorm:"type:varchar(35)" json:"type_abonnement"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
func (Demo) TableName() string { return "demos" }
@@ -15,6 +15,10 @@ type Provisioner interface {
// données postgres, puis supprime l'ancien namespace une fois le
// nouveau opérationnel. No-op si d.Namespace == newNamespace.
MigrateToPremiumNamespace(d Demo, newNamespace, newURL string) error
// UpdateDomain change le domaine public d'une démo (IngressRoute, et
// re-branchement des webhooks Telegram si configurés) sans changer de
// namespace ni migrer de données.
UpdateDomain(d Demo, newURL string) error
GetResourceState(ctx context.Context, namespace string) (ResourceState, error)
}
@@ -25,6 +29,7 @@ type NoopProvisioner struct{}
func (NoopProvisioner) Provision(Demo, []ExternalResource, ProvisionConfig) error { return nil }
func (NoopProvisioner) Teardown(Demo) error { return nil }
func (NoopProvisioner) MigrateToPremiumNamespace(Demo, string, string) error { return nil }
func (NoopProvisioner) UpdateDomain(Demo, string) error { return nil }
func (NoopProvisioner) GetResourceState(ctx context.Context, namespace string) (ResourceState, error) {
return ResourceState{}, nil
}
@@ -3,6 +3,7 @@ package demos
import (
"errors"
"log"
"strings"
"time"
"github.com/google/uuid"
@@ -192,6 +193,39 @@ func (s *Service) urlFor(namespace string) string {
return "https://" + namespace + "." + s.cfg.BaseDomain + portSuffix
}
// SetDomain modifie le domaine public d'une démo (IngressRoute, et
// re-branchement des webhooks Telegram si un bot est configuré — voir
// HelmProvisioner.UpdateDomain). domain vide réinitialise au domaine par
// défaut ("<namespace>.<baseDomain>"). Contrairement à
// MigrateToPremiumNamespace, c'est une opération rapide (aucune donnée à
// migrer, aucun namespace à recréer) : appliquée de façon synchrone.
func (s *Service) SetDomain(id, domain string) (Demo, error) {
domain = strings.ToLower(strings.TrimSpace(domain))
if err := ValidateDomain(domain); err != nil {
return Demo{}, err
}
d, ok := s.store.Get(id)
if !ok {
return Demo{}, ErrNotFound
}
newURL := domain
if newURL == "" {
newURL = s.urlFor(d.Namespace)
} else {
newURL = "https://" + domain
}
if err := s.prov.UpdateDomain(d, newURL); err != nil {
return Demo{}, err
}
d.CustomDomain = domain
d.URL = newURL
return s.store.Update(d)
}
// Delete déclenche le teardown et libère le pool.
func (s *Service) Delete(id string) (Demo, error) {
d, ok := s.store.Get(id)
@@ -76,6 +76,7 @@ func New(d Deps) *gin.Engine {
admin.GET("/demos/:id", d.DemosH.Get)
admin.DELETE("/demos/:id", d.DemosH.Delete)
admin.POST("/demos/:id/extend", d.DemosH.Extend)
admin.POST("/demos/:id/domain", d.DemosH.SetDomain)
admin.POST("/demos/details", d.DemosH.ListDetails)
}
}