chore: update
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user