1516 lines
59 KiB
Go
1516 lines
59 KiB
Go
package demos
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/omnex/control-plane/api/internal/config"
|
|
"go.yaml.in/yaml/v2"
|
|
"golang.org/x/crypto/bcrypt"
|
|
k8sCoreV1 "k8s.io/api/core/v1"
|
|
rbacv1 "k8s.io/api/rbac/v1"
|
|
k8sErrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/client-go/dynamic"
|
|
"k8s.io/client-go/kubernetes"
|
|
"k8s.io/client-go/kubernetes/scheme"
|
|
"k8s.io/client-go/rest"
|
|
"k8s.io/client-go/tools/remotecommand"
|
|
metricsclient "k8s.io/metrics/pkg/client/clientset/versioned"
|
|
)
|
|
|
|
// HelmProvisioner : implémente Provisioner en appelant l'exécutable Helm.
|
|
// Nécessite que helm soit installé dans le container (ex: dans /usr/local/bin/helm).
|
|
type HelmProvisioner struct {
|
|
cfg *config.Config
|
|
k8sClient *kubernetes.Clientset
|
|
restConfig *rest.Config // pour l'exec dans les pods (pg_dump/restore)
|
|
metricsClient *metricsclient.Clientset // optionnel : nil si metrics-server indisponible
|
|
dynamicClient dynamic.Interface // pour les CRD Velero (Schedule/Backup), voir ensureDemoBackupSchedule
|
|
chartsDir string // chemin vers le dossier des charts (ex: /charts)
|
|
// vitrineChartsDir : charts des projets vitrine (backend, frontend,
|
|
// postgresql, redis, ingressroute), distincts de chartsDir dont les
|
|
// charts de mêmes noms déploient le produit des démos.
|
|
vitrineChartsDir string
|
|
frontendImage string
|
|
backendImage string
|
|
lbtelegramImage string
|
|
baseDomain string
|
|
helmPath string // chemin vers l'exécutable helm (default: "helm")
|
|
}
|
|
|
|
// NewHelmProvisioner crée un nouveau provisioner Helm.
|
|
// chartsDir : chemin absolu vers le dossier contenant les charts (backend/, frontend/)
|
|
// 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(
|
|
cfg *config.Config,
|
|
k8sClient *kubernetes.Clientset,
|
|
restConfig *rest.Config,
|
|
metricsClient *metricsclient.Clientset,
|
|
chartsDir string,
|
|
helmPath string,
|
|
) (*HelmProvisioner, error) {
|
|
if chartsDir == "" {
|
|
chartsDir = "/charts"
|
|
}
|
|
if helmPath == "" {
|
|
helmPath = "helm"
|
|
}
|
|
|
|
// Vérifier que helm est disponible
|
|
if _, err := exec.LookPath(helmPath); err != nil {
|
|
return nil, fmt.Errorf("helm exécutable non trouvé à %s: %w", helmPath, err)
|
|
}
|
|
|
|
dynamicClient, err := dynamic.NewForConfig(restConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("client dynamique (CRD Velero): %w", err)
|
|
}
|
|
|
|
return &HelmProvisioner{
|
|
cfg: cfg,
|
|
k8sClient: k8sClient,
|
|
restConfig: restConfig,
|
|
metricsClient: metricsClient,
|
|
dynamicClient: dynamicClient,
|
|
chartsDir: chartsDir,
|
|
vitrineChartsDir: cfg.VitrineChartsDir,
|
|
frontendImage: cfg.FrontendImage,
|
|
backendImage: cfg.BackendImage,
|
|
lbtelegramImage: cfg.LBTelegramImage,
|
|
baseDomain: cfg.DemoDomain,
|
|
helmPath: helmPath,
|
|
}, nil
|
|
}
|
|
|
|
// sharedTraefikNamespace / sharedTraefikRelease : le routeur Traefik + WAF
|
|
// Coraza est installé UNE SEULE FOIS pour toutes les démos (voir
|
|
// EnsureSharedInfra), jamais par démo — chaque démo installe seulement le
|
|
// chart "ingressroute" qui la raccorde à ce Traefik partagé.
|
|
const (
|
|
sharedTraefikNamespace = "traefik"
|
|
sharedTraefikRelease = "gestion-traefik"
|
|
sharedRegistryCredentialsRelease = "gestion-registry-credentials"
|
|
)
|
|
|
|
// Provision déploie une démo avec Helm :
|
|
// 1. Crée le namespace
|
|
// 2. Installe PostgreSQL pour la démo
|
|
// 3. Installe Redis pour la démo
|
|
// 4. Installe le chart backend
|
|
// 5. Installe le chart frontend
|
|
// 6. Raccorde la démo au Traefik partagé (WAF + routage HTTPS du sous-domaine)
|
|
func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource, cfg ProvisionConfig) error {
|
|
// Créer le namespace
|
|
if err := h.createNamespace(d.Namespace); err != nil {
|
|
return fmt.Errorf("création namespace %s: %w", d.Namespace, err)
|
|
}
|
|
|
|
// Sauvegardes (voir ensureDemoBackupSchedule) : non-fatal, une démo doit
|
|
// pouvoir fonctionner même si Velero est momentanément indisponible.
|
|
if err := h.ensureDemoBackupSchedule(d.Namespace); err != nil {
|
|
log.Printf("Warning: planning de sauvegarde échoué pour %s: %v", d.Namespace, err)
|
|
}
|
|
|
|
// Isolation réseau AVANT tout workload : deny-by-default, aucune
|
|
// communication inter-démos, ingress limité au Traefik partagé (voir
|
|
// deploy/chart-gestion/network-policy). Sans ça, tout pod d'une démo
|
|
// peut atteindre le réseau de n'importe quelle autre démo.
|
|
if err := h.installChart(d.Namespace, "network-policy", h.buildNetworkPolicyValues()); err != nil {
|
|
h.deleteNamespace(d.Namespace)
|
|
return fmt.Errorf("déploiement network-policy: %w", err)
|
|
}
|
|
|
|
// Plafond agrégé de ressources (voir deploy/chart-gestion/resource-quota) :
|
|
// sans ça, l'autoscaling (HPA) du backend/frontend d'une démo peut monter
|
|
// en charge sans limite globale et affamer les autres démos du même
|
|
// cluster — les limites par pod ne bornent que l'individuel, pas l'agrégat.
|
|
if err := h.installChart(d.Namespace, "resource-quota", nil); err != nil {
|
|
h.deleteNamespace(d.Namespace)
|
|
return fmt.Errorf("déploiement resource-quota: %w", err)
|
|
}
|
|
|
|
// Identifiants Docker Hub (voir deploy/chart-gestion/registry-credentials) :
|
|
// AVANT tout chart qui pull une image, sinon les pods restent en
|
|
// ImagePullBackOff si le rate-limit anonyme est déjà atteint sur ce nœud.
|
|
// No-op si aucun compte n'est configuré (imagePullSecretsValue == nil).
|
|
if err := h.installChart(d.Namespace, "registry-credentials", h.buildRegistryCredentialsValues()); err != nil {
|
|
h.deleteNamespace(d.Namespace)
|
|
return fmt.Errorf("déploiement registry-credentials: %w", err)
|
|
}
|
|
|
|
// Mot de passe postgres/redis généré par démo (jamais réutilisé d'une
|
|
// démo à l'autre) : avec des identifiants partagés, un gap réseau
|
|
// (CNI, règle manquante) donnerait un accès direct aux données de
|
|
// n'importe quelle autre démo. Le NetworkPolicy ci-dessus est la
|
|
// première ligne de défense, ce mot de passe unique la seconde.
|
|
pgPassword := randomSecret()
|
|
redisPassword := randomSecret()
|
|
|
|
// PVC dès la création de la démo (pas d'emptyDir) : si le client ne
|
|
// souscrit pas, le teardown du namespace (TTL ou suppression admin)
|
|
// supprime le PVC avec le reste. S'il souscrit, TransferToPaid n'a rien
|
|
// d'autre à faire côté stockage — les données sont déjà en place.
|
|
postgresValues := h.buildPostgresValues(d, pgPassword)
|
|
redisValues := h.buildRedisValues(d, redisPassword)
|
|
|
|
// Secret partagé backend <-> lbtelegram (authentifie les appels de
|
|
// lbtelegram vers backend) — généré une seule fois si le load-balancer
|
|
// Telegram est activé, vide sinon (le chart lbtelegram n'est pas installé).
|
|
var backendLinkSecret string
|
|
if cfg.LBTelegramEnabled() {
|
|
backendLinkSecret = randomSecret()
|
|
}
|
|
|
|
backendValues := h.buildBackendValues(d, resources, cfg, backendLinkSecret, pgPassword, redisPassword)
|
|
frontendValues := h.buildFrontendValues(d)
|
|
ingressValues := h.buildIngressRouteValues(d)
|
|
|
|
// Installer PostgreSQL (nécessaire pour le backend)
|
|
if err := h.installChart(d.Namespace, "postgresql", postgresValues); err != nil {
|
|
h.deleteNamespace(d.Namespace)
|
|
return fmt.Errorf("déploiement postgresql: %w", err)
|
|
}
|
|
|
|
// Installer Redis (nécessaire pour le backend)
|
|
if err := h.installChart(d.Namespace, "redis", redisValues); err != nil {
|
|
h.deleteNamespace(d.Namespace)
|
|
return fmt.Errorf("déploiement redis: %w", err)
|
|
}
|
|
|
|
// Installer le backend
|
|
if err := h.installChart(d.Namespace, "backend", backendValues); err != nil {
|
|
h.deleteNamespace(d.Namespace)
|
|
return fmt.Errorf("déploiement backend: %w", err)
|
|
}
|
|
|
|
// Installer le frontend
|
|
if err := h.installChart(d.Namespace, "frontend", frontendValues); err != nil {
|
|
h.deleteNamespace(d.Namespace)
|
|
return fmt.Errorf("déploiement frontend: %w", err)
|
|
}
|
|
|
|
// Raccorder au Traefik partagé : sans ça la démo n'est ni routée ni
|
|
// protégée par le WAF, même si backend/frontend tournent.
|
|
if err := h.installChart(d.Namespace, "ingressroute", ingressValues); err != nil {
|
|
h.deleteNamespace(d.Namespace)
|
|
return fmt.Errorf("déploiement ingressroute: %w", err)
|
|
}
|
|
|
|
// Load-balancer Telegram : optionnel, seulement si l'admin a renseigné
|
|
// au moins un bot.
|
|
if cfg.LBTelegramEnabled() {
|
|
lbValues := h.buildLBTelegramValues(d, cfg, backendLinkSecret, pgPassword, redisPassword)
|
|
if err := h.installChart(d.Namespace, "lbtelegram", lbValues); err != nil {
|
|
h.deleteNamespace(d.Namespace)
|
|
return fmt.Errorf("déploiement lbtelegram: %w", err)
|
|
}
|
|
}
|
|
|
|
// Attendre que postgres/redis/backend/frontend soient tous Running avant
|
|
// de créer le compte admin : la table "users" n'existe qu'une fois que
|
|
// le backend a fait tourner sa migration au démarrage, donc tenter
|
|
// l'insertion avant que le rollout ne soit confirmé échouerait
|
|
// ("relation users does not exist").
|
|
if err := h.waitForRollout(d.Namespace); err != nil {
|
|
log.Printf("Warning: rollout check échoué pour %s: %v — compte admin non créé", d.Namespace, err)
|
|
return nil
|
|
}
|
|
|
|
if err := h.createGestionAdmin(context.Background(), d.Namespace, cfg.AdminUsername, cfg.AdminPassword, pgPassword); err != nil {
|
|
log.Printf("Warning: création du compte admin échouée pour %s: %v", d.Namespace, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// EnsureSharedInfra installe (ou met à jour, de façon idempotente) le
|
|
// Traefik partagé + WAF Coraza. À appeler une fois au démarrage de l'API,
|
|
// avant toute démo — voir cmd/api/main.go. Contrairement à Provision, ceci
|
|
// n'est PAS répété par démo.
|
|
func (h *HelmProvisioner) EnsureSharedInfra() error {
|
|
if err := h.createNamespace(sharedTraefikNamespace); err != nil {
|
|
return fmt.Errorf("création namespace %s: %w", sharedTraefikNamespace, err)
|
|
}
|
|
|
|
// Identifiants Docker Hub (voir deploy/chart-gestion/registry-credentials) :
|
|
// AVANT traefik, qui pull son image + le plugin WASM Coraza. No-op si
|
|
// aucun compte n'est configuré.
|
|
if err := h.upgradeInstallChart(sharedTraefikNamespace, sharedRegistryCredentialsRelease, "registry-credentials", h.buildRegistryCredentialsValues()); err != nil {
|
|
return fmt.Errorf("déploiement registry-credentials partagé: %w", err)
|
|
}
|
|
|
|
// Le subchart officiel traefik/traefik est vendorisé dans le repo
|
|
// (deploy/chart-gestion/traefik/charts/traefik-*.tgz + Chart.lock) :
|
|
// pas de "helm dependency update" au runtime, qui échouerait de toute
|
|
// façon puisque /charts est monté en lecture seule.
|
|
// "deployment.imagePullSecrets", pas "imagePullSecrets" à la racine :
|
|
// emplacement imposé par values.schema.json du chart officiel
|
|
// traefik/traefik (voir le commentaire équivalent dans
|
|
// deploy/chart-gestion/traefik/values.yaml).
|
|
traefikValues := map[string]interface{}{
|
|
"traefik": map[string]interface{}{
|
|
"deployment": map[string]interface{}{
|
|
"imagePullSecrets": h.imagePullSecretsValue(),
|
|
},
|
|
},
|
|
}
|
|
if err := h.upgradeInstallChart(sharedTraefikNamespace, sharedTraefikRelease, "traefik", traefikValues); err != nil {
|
|
return fmt.Errorf("déploiement traefik partagé: %w", err)
|
|
}
|
|
|
|
log.Printf("Traefik partagé (WAF Coraza + rate-limit) prêt dans le namespace %s", sharedTraefikNamespace)
|
|
return nil
|
|
}
|
|
|
|
// Teardown détruit une démo en supprimant son namespace (cascading) — les
|
|
// PVC postgres/redis de la démo partent avec, puisqu'ils sont namespaced —
|
|
// et ses sauvegardes Velero (planning + Backup déjà pris, voir
|
|
// deleteDemoBackups). Appelée aussi bien pour l'expiration TTL que pour une
|
|
// destruction forcée par un admin (même chemin, voir Service.Delete) : dans
|
|
// les deux cas, les sauvegardes associées disparaissent avec la démo.
|
|
// La suppression des sauvegardes échoue en silence (journalisée) : un
|
|
// pépin côté Velero ne doit pas bloquer la destruction de la démo
|
|
// elle-même, qui reste l'action prioritaire demandée par l'appelant.
|
|
func (h *HelmProvisioner) Teardown(d Demo) error {
|
|
if err := h.deleteDemoBackups(d.Namespace); err != nil {
|
|
log.Printf("Warning: suppression des sauvegardes échouée pour %s: %v", d.Namespace, err)
|
|
}
|
|
return h.deleteNamespace(d.Namespace)
|
|
}
|
|
|
|
// MigrateToPremiumNamespace reconstruit l'intégralité de la démo dans
|
|
// newNamespace ("premium-<client>") et y migre les données postgres —
|
|
// Kubernetes ne permet pas de renommer un namespace, il faut donc
|
|
// recréer toute la stack (postgres/redis/backend/frontend/ingressroute/
|
|
// lbtelegram) ailleurs.
|
|
//
|
|
// La configuration (bot Telegram, NowPayments, load-balancer...) n'est
|
|
// jamais persistée en base (voir ProvisionConfig) : elle est relue depuis
|
|
// les valeurs Helm de l'ancien déploiement ("helm get values"), qui
|
|
// contiennent exactement ce qui a été installé à l'origine, secrets
|
|
// inclus — jamais journalisés ni interprétés ici, seulement retransmis
|
|
// tels quels au nouveau déploiement. Seuls les champs qui référencent
|
|
// l'ancien namespace (DNS internes, URL du webhook Telegram) sont
|
|
// recalculés pour le nouveau (voir patchBackendValuesForNamespace /
|
|
// patchLBTelegramValuesForNamespace).
|
|
//
|
|
// L'ancien namespace n'est supprimé qu'une fois le nouveau confirmé
|
|
// opérationnel (rollout réussi) : en cas d'échec à n'importe quelle étape,
|
|
// la démo continue de fonctionner sous son ancienne adresse, rien n'est
|
|
// perdu. No-op si newNamespace == d.Namespace (déjà migrée, ex.
|
|
// renouvellement d'un client déjà premium).
|
|
func (h *HelmProvisioner) MigrateToPremiumNamespace(d Demo, newNamespace, newURL string) error {
|
|
oldNamespace := d.Namespace
|
|
if newNamespace == oldNamespace {
|
|
return nil
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
|
defer cancel()
|
|
|
|
backendValues, err := h.getReleaseValues(oldNamespace, oldNamespace+"-backend")
|
|
if err != nil {
|
|
return fmt.Errorf("lecture config backend existante: %w", err)
|
|
}
|
|
lbValues, lbEnabled := h.getReleaseValuesOptional(oldNamespace, oldNamespace+"-lbtelegram")
|
|
|
|
// Mot de passe postgres actuel (pour le pg_dump) — jamais persisté nulle
|
|
// part, relu directement depuis la release Helm en place.
|
|
oldPostgresValues, err := h.getReleaseValues(oldNamespace, oldNamespace+"-postgresql")
|
|
if err != nil {
|
|
return fmt.Errorf("lecture config postgresql existante: %w", err)
|
|
}
|
|
oldPgPassword := asStringMap(oldPostgresValues["auth"])["password"]
|
|
|
|
if err := h.createNamespace(newNamespace); err != nil {
|
|
return fmt.Errorf("création namespace %s: %w", newNamespace, err)
|
|
}
|
|
|
|
// Sauvegardes : voir le commentaire équivalent dans Provision. Non-fatal.
|
|
if err := h.ensureDemoBackupSchedule(newNamespace); err != nil {
|
|
log.Printf("Warning: planning de sauvegarde échoué pour %s: %v", newNamespace, err)
|
|
}
|
|
|
|
// Isolation réseau AVANT tout workload, comme pour Provision.
|
|
if err := h.installChart(newNamespace, "network-policy", h.buildNetworkPolicyValues()); err != nil {
|
|
h.deleteNamespace(newNamespace)
|
|
return fmt.Errorf("déploiement network-policy: %w", err)
|
|
}
|
|
if err := h.installChart(newNamespace, "resource-quota", nil); err != nil {
|
|
h.deleteNamespace(newNamespace)
|
|
return fmt.Errorf("déploiement resource-quota: %w", err)
|
|
}
|
|
if err := h.installChart(newNamespace, "registry-credentials", h.buildRegistryCredentialsValues()); err != nil {
|
|
h.deleteNamespace(newNamespace)
|
|
return fmt.Errorf("déploiement registry-credentials: %w", err)
|
|
}
|
|
|
|
newDemo := d
|
|
newDemo.Namespace = newNamespace
|
|
newDemo.URL = newURL
|
|
|
|
// Nouveau mot de passe postgres/redis pour le nouveau namespace (pas de
|
|
// réutilisation de l'ancien) : la donnée est migrée, pas l'identifiant.
|
|
pgPassword := randomSecret()
|
|
redisPassword := randomSecret()
|
|
|
|
if err := h.installChart(newNamespace, "postgresql", h.buildPostgresValues(newDemo, pgPassword)); err != nil {
|
|
h.deleteNamespace(newNamespace)
|
|
return fmt.Errorf("déploiement postgresql: %w", err)
|
|
}
|
|
if err := h.installChart(newNamespace, "redis", h.buildRedisValues(newDemo, redisPassword)); err != nil {
|
|
h.deleteNamespace(newNamespace)
|
|
return fmt.Errorf("déploiement redis: %w", err)
|
|
}
|
|
|
|
// Données postgres : migrées par dump/restore (le nouveau PVC est
|
|
// vide). Redis (cache/sessions) n'est pas migré, reconstruit
|
|
// naturellement — même convention que l'historique passage en
|
|
// stockage persistant.
|
|
if err := h.migratePostgresData(ctx, oldNamespace, newNamespace, oldPgPassword, pgPassword); err != nil {
|
|
h.deleteNamespace(newNamespace)
|
|
return fmt.Errorf("migration données postgres: %w", err)
|
|
}
|
|
|
|
var backendLinkSecret string
|
|
if lbEnabled {
|
|
backendLinkSecret = asStringMap(backendValues["secrets"])["BACKEND_LINK_SECRET"]
|
|
if backendLinkSecret == "" {
|
|
backendLinkSecret = randomSecret()
|
|
}
|
|
}
|
|
|
|
backendPatched := h.patchBackendValuesForNamespace(backendValues, newDemo, lbEnabled, backendLinkSecret, pgPassword, redisPassword)
|
|
if err := h.installChart(newNamespace, "backend", backendPatched); err != nil {
|
|
h.deleteNamespace(newNamespace)
|
|
return fmt.Errorf("déploiement backend: %w", err)
|
|
}
|
|
if err := h.installChart(newNamespace, "frontend", h.buildFrontendValues(newDemo)); err != nil {
|
|
h.deleteNamespace(newNamespace)
|
|
return fmt.Errorf("déploiement frontend: %w", err)
|
|
}
|
|
if err := h.installChart(newNamespace, "ingressroute", h.buildIngressRouteValues(newDemo)); err != nil {
|
|
h.deleteNamespace(newNamespace)
|
|
return fmt.Errorf("déploiement ingressroute: %w", err)
|
|
}
|
|
if lbEnabled {
|
|
lbPatched := h.patchLBTelegramValuesForNamespace(lbValues, newDemo, backendLinkSecret, pgPassword, redisPassword)
|
|
if err := h.installChart(newNamespace, "lbtelegram", lbPatched); err != nil {
|
|
h.deleteNamespace(newNamespace)
|
|
return fmt.Errorf("déploiement lbtelegram: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := h.waitForRollout(newNamespace); err != nil {
|
|
// Le nouveau namespace n'est PAS supprimé : les données y sont déjà
|
|
// migrées, un rollout en échec est réparable manuellement sans tout
|
|
// reperdre. L'ancien namespace reste lui aussi en place tant que le
|
|
// nouveau n'est pas confirmé opérationnel.
|
|
return fmt.Errorf("rollout du nouveau namespace %s: %w", newNamespace, err)
|
|
}
|
|
|
|
// Sauvegardes de l'ancien namespace : plus de sens une fois migré (les
|
|
// données sont déjà dans le nouveau namespace, qui a son propre
|
|
// planning depuis sa création ci-dessus).
|
|
if err := h.deleteDemoBackups(oldNamespace); err != nil {
|
|
log.Printf("Warning: suppression des sauvegardes de l'ancien namespace %s échouée: %v", oldNamespace, err)
|
|
}
|
|
|
|
if err := h.deleteNamespace(oldNamespace); err != nil {
|
|
log.Printf("Warning: suppression ancien namespace %s échouée (nouveau namespace %s opérationnel): %v", oldNamespace, newNamespace, err)
|
|
}
|
|
|
|
log.Printf("Démo %s migrée vers le namespace premium %s", oldNamespace, newNamespace)
|
|
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.
|
|
// oldPassword/newPassword : mots de passe respectifs de chaque instance
|
|
// postgres (uniques par démo, jamais réutilisés — voir Provision).
|
|
func (h *HelmProvisioner) migratePostgresData(ctx context.Context, oldNamespace, newNamespace, oldPassword, newPassword string) error {
|
|
oldPod, err := h.findPod(ctx, oldNamespace, "postgresql")
|
|
if err != nil {
|
|
return fmt.Errorf("pod postgresql source introuvable: %w", err)
|
|
}
|
|
dumpCmd := fmt.Sprintf("PGPASSWORD=%s pg_dump -h localhost -U %s %s", oldPassword, demoDBUser, demoDBName)
|
|
dump, stderr, err := h.execInPod(ctx, oldNamespace, oldPod, "postgresql", []string{"sh", "-c", dumpCmd}, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("pg_dump: %w (%s)", err, stderr)
|
|
}
|
|
if strings.TrimSpace(dump) == "" {
|
|
return nil
|
|
}
|
|
|
|
newPod, err := h.findPod(ctx, newNamespace, "postgresql")
|
|
if err != nil {
|
|
return fmt.Errorf("pod postgresql cible introuvable: %w", err)
|
|
}
|
|
restoreCmd := fmt.Sprintf("PGPASSWORD=%s psql -h localhost -U %s %s", newPassword, demoDBUser, demoDBName)
|
|
if _, stderr, err := h.execInPod(ctx, newNamespace, newPod, "postgresql", []string{"sh", "-c", restoreCmd}, strings.NewReader(dump)); err != nil {
|
|
return fmt.Errorf("restore pg_dump: %w (%s)", err, stderr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// patchBackendValuesForNamespace réutilise les valeurs Helm existantes du
|
|
// backend (image, secrets, réglages métier...) en ne recalculant que ce qui
|
|
// référence le nom du namespace : DNS internes postgres/redis/lbtelegram,
|
|
// URL du webhook Telegram, et les nouveaux mots de passe postgres/redis
|
|
// (uniques au nouveau namespace, voir MigrateToPremiumNamespace).
|
|
func (h *HelmProvisioner) patchBackendValuesForNamespace(values map[string]interface{}, newDemo Demo, lbEnabled bool, backendLinkSecret, pgPassword, redisPassword string) map[string]interface{} {
|
|
env := asStringMap(values["env"])
|
|
secrets := asStringMap(values["secrets"])
|
|
|
|
env["DB_HOST"] = fmt.Sprintf("%s-postgresql-postgresql", newDemo.Namespace)
|
|
env["REDIS_HOST"] = fmt.Sprintf("%s-redis-redis", newDemo.Namespace)
|
|
delete(env, "DB_PASSWORD") // désormais dans secrets, voir buildBackendValues
|
|
delete(env, "REDIS_PASSWORD") // idem
|
|
secrets["DB_PASSWORD"] = pgPassword
|
|
secrets["REDIS_PASSWORD"] = redisPassword
|
|
if lbEnabled {
|
|
env["LBTELEGRAM_URL"] = fmt.Sprintf("http://%s-lbtelegram-lbtelegram.%s.svc.cluster.local:8081", newDemo.Namespace, newDemo.Namespace)
|
|
secrets["BACKEND_LINK_SECRET"] = backendLinkSecret
|
|
}
|
|
if secrets["TELEGRAM_BOT_TOKEN"] != "" {
|
|
secrets["TELEGRAM_WEBHOOK_URL"] = newDemo.URL + "/webhook/telegram"
|
|
}
|
|
|
|
values["env"] = env
|
|
values["secrets"] = secrets
|
|
return values
|
|
}
|
|
|
|
// patchLBTelegramValuesForNamespace réutilise les valeurs Helm existantes
|
|
// du chart lbtelegram (bots, stratégie, tokens...) en ne recalculant que ce
|
|
// qui référence le nom du namespace : host public, GATEWAY_URL, DSN
|
|
// postgres/redis (avec les nouveaux mots de passe) et URL interne du backend.
|
|
func (h *HelmProvisioner) patchLBTelegramValuesForNamespace(values map[string]interface{}, newDemo Demo, backendLinkSecret, pgPassword, redisPassword string) map[string]interface{} {
|
|
env := asStringMap(values["env"])
|
|
secrets := asStringMap(values["secrets"])
|
|
|
|
host := fmt.Sprintf("%s.%s", newDemo.Namespace, h.baseDomain)
|
|
env["GATEWAY_URL"] = "https://" + host
|
|
env["BACKEND_LINK_URL"] = fmt.Sprintf("http://%s-backend-gestion-backend.%s.svc.cluster.local:8080", newDemo.Namespace, newDemo.Namespace)
|
|
|
|
secrets["BACKEND_LINK_SECRET"] = backendLinkSecret
|
|
secrets["DATABASE_URL"] = fmt.Sprintf("postgres://postgres:%s@%s-postgresql-postgresql:5432/demo_db?sslmode=disable", pgPassword, newDemo.Namespace)
|
|
secrets["REDIS_URL"] = fmt.Sprintf("redis://:%s@%s-redis-redis:6379/0", redisPassword, newDemo.Namespace)
|
|
|
|
values["host"] = host
|
|
values["env"] = env
|
|
values["secrets"] = secrets
|
|
return values
|
|
}
|
|
|
|
// getReleaseValues récupère les valeurs Helm exactes utilisées par une
|
|
// release déjà installée ("helm get values -o json").
|
|
func (h *HelmProvisioner) getReleaseValues(namespace, release string) (map[string]interface{}, error) {
|
|
out, err := h.runHelmOutput("get", "values", release, "--namespace", namespace, "-o", "json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var values map[string]interface{}
|
|
if err := json.Unmarshal(out, &values); err != nil {
|
|
return nil, fmt.Errorf("parse valeurs helm de %s: %w", release, err)
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
// getReleaseValuesOptional : comme getReleaseValues, mais renvoie
|
|
// simplement (nil, false) si la release n'existe pas (ex. lbtelegram non
|
|
// activé pour cette démo) plutôt qu'une erreur.
|
|
func (h *HelmProvisioner) getReleaseValuesOptional(namespace, release string) (map[string]interface{}, bool) {
|
|
values, err := h.getReleaseValues(namespace, release)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return values, true
|
|
}
|
|
|
|
// asStringMap convertit une valeur JSON décodée (map[string]interface{})
|
|
// en map[string]string, en ignorant silencieusement les clés dont la
|
|
// valeur n'est pas une chaîne.
|
|
func asStringMap(v interface{}) map[string]string {
|
|
out := map[string]string{}
|
|
m, ok := v.(map[string]interface{})
|
|
if !ok {
|
|
return out
|
|
}
|
|
for k, val := range m {
|
|
if s, ok := val.(string); ok {
|
|
out[k] = s
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// createGestionAdmin crée le compte admin de l'application "gestion"
|
|
// déployée dans cette démo, en insérant directement dans la table "users"
|
|
// (aucune route API ne le permet : CreateUser refuse explicitement de créer
|
|
// un compte admin, par design de l'app). Mot de passe haché en bcrypt
|
|
// (golang.org/x/crypto/bcrypt), comme le fait l'app elle-même
|
|
// (bcrypt.CompareHashAndPassword côté LoginAdmin).
|
|
func (h *HelmProvisioner) createGestionAdmin(ctx context.Context, namespace, username, password, pgPassword string) error {
|
|
if username == "" || password == "" {
|
|
return fmt.Errorf("username/password admin manquants")
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return fmt.Errorf("hash bcrypt: %w", err)
|
|
}
|
|
|
|
pgPod, err := h.findPod(ctx, namespace, "postgresql")
|
|
if err != nil {
|
|
return fmt.Errorf("pod postgresql introuvable: %w", err)
|
|
}
|
|
|
|
// Échappement basique des quotes simples : le username est déjà validé
|
|
// alphanumérique côté handler HTTP, le hash bcrypt ne contient jamais de
|
|
// guillemet (alphabet base64 restreint ./A-Za-z0-9).
|
|
escapedUsername := strings.ReplaceAll(username, "'", "''")
|
|
sql := fmt.Sprintf(
|
|
`INSERT INTO users (username, password, role) VALUES ('%s', '%s', 'admin') ON CONFLICT (username) DO NOTHING;`,
|
|
escapedUsername, string(hash),
|
|
)
|
|
shellCmd := fmt.Sprintf("PGPASSWORD=%s psql -h localhost -U %s -d %s -v ON_ERROR_STOP=1 -c %s",
|
|
pgPassword, demoDBUser, demoDBName, shellQuote(sql))
|
|
|
|
_, stderr, err := h.execInPod(ctx, namespace, pgPod, "postgresql", []string{"sh", "-c", shellCmd}, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("insertion compte admin: %w (%s)", err, stderr)
|
|
}
|
|
|
|
log.Printf("Compte admin %q créé pour la démo %s", username, namespace)
|
|
return nil
|
|
}
|
|
|
|
// shellQuote entoure une chaîne de guillemets simples pour un usage sûr dans
|
|
// une commande sh -c (échappe les guillemets simples qu'elle contient).
|
|
func shellQuote(s string) string {
|
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
|
}
|
|
|
|
// Identifiants postgres des démos — utilisateur/base fixes (pas des
|
|
// secrets), le mot de passe est généré par démo (voir Provision et
|
|
// MigrateToPremiumNamespace, jamais codé en dur ni partagé entre démos).
|
|
const (
|
|
demoDBUser = "postgres"
|
|
demoDBName = "demo_db"
|
|
)
|
|
|
|
// findPod retourne le nom du premier pod du namespace dont le nom contient nameContains.
|
|
func (h *HelmProvisioner) findPod(ctx context.Context, namespace, nameContains string) (string, error) {
|
|
pods, err := h.k8sClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, p := range pods.Items {
|
|
if strings.Contains(p.Name, nameContains) {
|
|
return p.Name, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("aucun pod contenant %q dans %s", nameContains, namespace)
|
|
}
|
|
|
|
// execInPod exécute une commande dans un conteneur et capture stdout/stderr.
|
|
// stdin peut être nil (aucune entrée à fournir).
|
|
func (h *HelmProvisioner) execInPod(ctx context.Context, namespace, pod, container string, command []string, stdin io.Reader) (string, string, error) {
|
|
req := h.k8sClient.CoreV1().RESTClient().Post().
|
|
Resource("pods").
|
|
Name(pod).
|
|
Namespace(namespace).
|
|
SubResource("exec").
|
|
VersionedParams(&k8sCoreV1.PodExecOptions{
|
|
Container: container,
|
|
Command: command,
|
|
Stdin: stdin != nil,
|
|
Stdout: true,
|
|
Stderr: true,
|
|
}, scheme.ParameterCodec)
|
|
|
|
executor, err := remotecommand.NewSPDYExecutor(h.restConfig, "POST", req.URL())
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("exec setup: %w", err)
|
|
}
|
|
|
|
var stdout, stderrBuf bytes.Buffer
|
|
err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{
|
|
Stdin: stdin,
|
|
Stdout: &stdout,
|
|
Stderr: &stderrBuf,
|
|
})
|
|
if err != nil {
|
|
return stdout.String(), stderrBuf.String(), err
|
|
}
|
|
return stdout.String(), stderrBuf.String(), nil
|
|
}
|
|
|
|
// omnexServiceAccountNamespace / omnexServiceAccountName / omnexWorkloadClusterRole :
|
|
// identité du ServiceAccount du control-plane (voir deploy/rbac/control-plane.yml).
|
|
// omnexWorkloadClusterRole n'est JAMAIS liée cluster-wide — seulement via un
|
|
// RoleBinding par namespace (voir ensureWorkloadRoleBinding), pour que le
|
|
// ServiceAccount n'ait aucun accès aux namespaces qu'il ne gère pas
|
|
// (cert-manager, longhorn-system, kube-system...). Le ServiceAccount ne
|
|
// possède que le verbe "bind" sur cette ClusterRole précise : il ne peut pas
|
|
// s'octroyer davantage (mécanisme RBAC anti-escalade de Kubernetes).
|
|
const (
|
|
omnexServiceAccountNamespace = "omnex-system"
|
|
omnexServiceAccountName = "omnex-control-plane"
|
|
omnexWorkloadClusterRole = "omnex-demo-workload-manager"
|
|
)
|
|
|
|
// createNamespace crée un namespace Kubernetes et y lie la ClusterRole
|
|
// métier du control-plane (moindre privilège — voir la doc des constantes
|
|
// ci-dessus).
|
|
func (h *HelmProvisioner) createNamespace(name string) error {
|
|
ns := &k8sCoreV1.Namespace{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: name,
|
|
Labels: map[string]string{
|
|
"omnex.app/demo": "true",
|
|
},
|
|
},
|
|
}
|
|
_, err := h.k8sClient.CoreV1().Namespaces().Create(context.Background(), ns, metav1.CreateOptions{})
|
|
if err != nil && !k8sErrors.IsAlreadyExists(err) {
|
|
return err
|
|
}
|
|
return h.ensureWorkloadRoleBinding(name)
|
|
}
|
|
|
|
// ensureWorkloadRoleBinding lie omnexWorkloadClusterRole au ServiceAccount du
|
|
// control-plane, dans "namespace" uniquement — c'est ce RoleBinding (pas une
|
|
// ClusterRoleBinding) qui borne les droits du control-plane à ce seul
|
|
// namespace pour les Deployments/Secrets/ConfigMaps/etc.
|
|
func (h *HelmProvisioner) ensureWorkloadRoleBinding(namespace string) error {
|
|
rb := &rbacv1.RoleBinding{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: omnexServiceAccountName,
|
|
Namespace: namespace,
|
|
},
|
|
RoleRef: rbacv1.RoleRef{
|
|
APIGroup: "rbac.authorization.k8s.io",
|
|
Kind: "ClusterRole",
|
|
Name: omnexWorkloadClusterRole,
|
|
},
|
|
Subjects: []rbacv1.Subject{{
|
|
Kind: "ServiceAccount",
|
|
Name: omnexServiceAccountName,
|
|
Namespace: omnexServiceAccountNamespace,
|
|
}},
|
|
}
|
|
_, err := h.k8sClient.RbacV1().RoleBindings(namespace).Create(context.Background(), rb, metav1.CreateOptions{})
|
|
if err != nil && !k8sErrors.IsAlreadyExists(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deleteNamespace supprime un namespace (avec cascading).
|
|
func (h *HelmProvisioner) deleteNamespace(name string) error {
|
|
propagationPolicy := metav1.DeletePropagationForeground
|
|
return h.k8sClient.CoreV1().Namespaces().Delete(context.Background(), name, metav1.DeleteOptions{
|
|
PropagationPolicy: &propagationPolicy,
|
|
})
|
|
}
|
|
|
|
// veleroNamespace : namespace fixe où tourne Velero (installé une fois par
|
|
// un opérateur admin, voir deploy/velero/) — jamais créé par ce code.
|
|
//
|
|
// Sauvegardes des démos : un Schedule Velero PAR démo (pas un planning
|
|
// global multi-namespace) — c'est le seul moyen de supprimer les
|
|
// sauvegardes d'UNE démo sans toucher à celles des autres, Velero ne
|
|
// permettant de supprimer qu'un objet Backup entier, jamais une portion
|
|
// (voir ensureDemoBackupSchedule/deleteDemoBackups, appelés depuis
|
|
// Provision/Teardown/MigrateToPremiumNamespace).
|
|
const veleroNamespace = "velero"
|
|
|
|
var (
|
|
veleroScheduleGVR = schema.GroupVersionResource{Group: "velero.io", Version: "v1", Resource: "schedules"}
|
|
veleroBackupGVR = schema.GroupVersionResource{Group: "velero.io", Version: "v1", Resource: "backups"}
|
|
veleroBackupRepoGVR = schema.GroupVersionResource{Group: "velero.io", Version: "v1", Resource: "backuprepositories"}
|
|
)
|
|
|
|
// demoBackupScheduleName : nom du Schedule Velero dédié à ce namespace de
|
|
// démo (aussi utilisé pour retrouver ses Backup via le label
|
|
// velero.io/schedule-name que Velero pose automatiquement).
|
|
func demoBackupScheduleName(namespace string) string {
|
|
return "demo-backup-" + namespace
|
|
}
|
|
|
|
// ensureDemoBackupSchedule crée (idempotent) le Schedule Velero dédié à ce
|
|
// namespace de démo : sauvegarde complète (manifestes + contenu réel des
|
|
// PVC via File System Backup) toutes les 15 min, conservée 24h — fenêtre de
|
|
// perte de données minimale en cas de problème sur le cluster. Non-fatal en
|
|
// cas d'erreur côté appelant (Velero indisponible ne doit pas empêcher une
|
|
// démo de fonctionner) : voir les appels dans Provision/
|
|
// MigrateToPremiumNamespace, qui journalisent seulement.
|
|
func (h *HelmProvisioner) ensureDemoBackupSchedule(namespace string) error {
|
|
obj := &unstructured.Unstructured{Object: map[string]interface{}{
|
|
"apiVersion": "velero.io/v1",
|
|
"kind": "Schedule",
|
|
"metadata": map[string]interface{}{
|
|
"name": demoBackupScheduleName(namespace),
|
|
"namespace": veleroNamespace,
|
|
},
|
|
"spec": map[string]interface{}{
|
|
"schedule": "*/15 * * * *",
|
|
"template": map[string]interface{}{
|
|
"includedNamespaces": []interface{}{namespace},
|
|
"defaultVolumesToFsBackup": true,
|
|
"storageLocation": "default",
|
|
"ttl": "24h0m0s",
|
|
},
|
|
},
|
|
}}
|
|
_, err := h.dynamicClient.Resource(veleroScheduleGVR).Namespace(veleroNamespace).
|
|
Create(context.Background(), obj, metav1.CreateOptions{})
|
|
if err != nil && !k8sErrors.IsAlreadyExists(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// deleteDemoBackups supprime le Schedule Velero de cette démo, toutes les
|
|
// sauvegardes déjà prises par ce planning, et le BackupRepository associé
|
|
// (la suppression d'un Backup déclenche le nettoyage asynchrone des données
|
|
// Kopia associées côté Velero — pas de suppression directe dans le bucket
|
|
// S3, qui risquerait de casser le dépôt Kopia partagé par les autres
|
|
// démos). Appelée depuis Teardown (expiration TTL et destruction forcée
|
|
// passent toutes les deux par ce même chemin) et lors d'une migration
|
|
// premium réussie (l'ancien namespace disparaît, son planning de sauvegarde
|
|
// n'a plus de sens).
|
|
//
|
|
// Le BackupRepository (l'objet qui référence le dépôt Kopia du namespace,
|
|
// nommé "<namespace>-default-kopia") n'est PAS supprimé automatiquement par
|
|
// Velero quand ses Backup disparaissent — sans ce nettoyage explicite, il
|
|
// reste indéfiniment et Velero continue de lancer des jobs de maintenance
|
|
// périodiques dessus (kopia-maintain-job) pour un dépôt qui ne sert plus à
|
|
// rien. Supprimer l'objet ne touche pas aux données déjà écrites dans le
|
|
// bucket S3 (même logique que pour les Backup ci-dessus), juste le
|
|
// bookkeeping Velero.
|
|
func (h *HelmProvisioner) deleteDemoBackups(namespace string) error {
|
|
name := demoBackupScheduleName(namespace)
|
|
|
|
list, err := h.dynamicClient.Resource(veleroBackupGVR).Namespace(veleroNamespace).
|
|
List(context.Background(), metav1.ListOptions{LabelSelector: "velero.io/schedule-name=" + name})
|
|
if err != nil {
|
|
return fmt.Errorf("liste des sauvegardes de %s: %w", namespace, err)
|
|
}
|
|
for _, b := range list.Items {
|
|
err := h.dynamicClient.Resource(veleroBackupGVR).Namespace(veleroNamespace).
|
|
Delete(context.Background(), b.GetName(), metav1.DeleteOptions{})
|
|
if err != nil && !k8sErrors.IsNotFound(err) {
|
|
return fmt.Errorf("suppression sauvegarde %s: %w", b.GetName(), err)
|
|
}
|
|
}
|
|
|
|
err = h.dynamicClient.Resource(veleroScheduleGVR).Namespace(veleroNamespace).
|
|
Delete(context.Background(), name, metav1.DeleteOptions{})
|
|
if err != nil && !k8sErrors.IsNotFound(err) {
|
|
return fmt.Errorf("suppression planning %s: %w", name, err)
|
|
}
|
|
|
|
repoName := namespace + "-default-kopia"
|
|
err = h.dynamicClient.Resource(veleroBackupRepoGVR).Namespace(veleroNamespace).
|
|
Delete(context.Background(), repoName, metav1.DeleteOptions{})
|
|
if err != nil && !k8sErrors.IsNotFound(err) {
|
|
return fmt.Errorf("suppression backuprepository %s: %w", repoName, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// installChart installe (première install, jamais réappliqué) un chart Helm
|
|
// avec des valeurs personnalisées. Release name : "<namespace>-<chartName>".
|
|
// Adapté aux charts par démo (namespace toujours neuf).
|
|
func (h *HelmProvisioner) installChart(namespace, chartName string, values map[string]interface{}) error {
|
|
return h.installChartFrom(h.chartsDir, namespace, chartName, values)
|
|
}
|
|
|
|
// installChartFrom : comme installChart, avec un dossier de charts explicite
|
|
// (charts des démos : h.chartsDir ; charts des projets vitrine :
|
|
// h.vitrineChartsDir).
|
|
func (h *HelmProvisioner) installChartFrom(chartsDir, namespace, chartName string, values map[string]interface{}) error {
|
|
release := fmt.Sprintf("%s-%s", namespace, chartName)
|
|
valuesFile, cleanup, err := h.writeValuesFile(namespace, chartName, values)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
|
|
args := []string{
|
|
"install",
|
|
release,
|
|
filepath.Join(chartsDir, chartName),
|
|
"--namespace", namespace,
|
|
"--values", valuesFile,
|
|
"--wait",
|
|
"--atomic",
|
|
"--timeout", "5m",
|
|
// Le ServiceAccount du control-plane n'a volontairement aucun droit
|
|
// cluster-scope sur les CustomResourceDefinition (voir
|
|
// deploy/rbac/control-plane.yml) : les CRD sont appliquées une fois,
|
|
// à part, par un opérateur humain avec un accès admin — jamais par
|
|
// ce chemin. Sans ce flag, "helm install" tente quand même de les
|
|
// créer (même si elles existent déjà) et échoue en Forbidden.
|
|
"--skip-crds",
|
|
}
|
|
if err := h.runHelm(args...); err != nil {
|
|
return fmt.Errorf("helm install %s: %w", chartName, err)
|
|
}
|
|
|
|
log.Printf("Chart %s installé dans %s", chartName, namespace)
|
|
return nil
|
|
}
|
|
|
|
// upgradeInstallChart installe ou met à jour un chart de façon idempotente
|
|
// (helm upgrade --install) — pour l'infra partagée (Traefik), appelée à
|
|
// chaque démarrage de l'API et qui ne doit pas échouer si déjà en place.
|
|
func (h *HelmProvisioner) upgradeInstallChart(namespace, release, chartName string, values map[string]interface{}) error {
|
|
args := []string{
|
|
"upgrade", release,
|
|
filepath.Join(h.chartsDir, chartName),
|
|
"--install",
|
|
"--namespace", namespace,
|
|
"--wait",
|
|
"--atomic",
|
|
"--timeout", "5m",
|
|
// Voir le commentaire équivalent dans installChart : le
|
|
// ServiceAccount du control-plane n'a pas le droit de créer des CRD
|
|
// (deploy/rbac/control-plane.yml) — utilisé ici pour le chart
|
|
// "traefik", dont le subchart vendorisé embarque des CRD déjà
|
|
// appliquées une fois à part par un opérateur admin.
|
|
"--skip-crds",
|
|
}
|
|
if values != nil {
|
|
valuesFile, cleanup, err := h.writeValuesFile(namespace, chartName, values)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
args = append(args, "--values", valuesFile)
|
|
}
|
|
if err := h.runHelm(args...); err != nil {
|
|
return fmt.Errorf("helm upgrade --install %s: %w", chartName, err)
|
|
}
|
|
|
|
log.Printf("Chart %s (release %s) à jour dans %s", chartName, release, namespace)
|
|
return nil
|
|
}
|
|
|
|
// writeValuesFile sérialise des valeurs Helm dans un fichier temporaire et
|
|
// retourne son chemin + une fonction de nettoyage à appeler en defer.
|
|
func (h *HelmProvisioner) writeValuesFile(namespace, chartName string, values map[string]interface{}) (string, func(), error) {
|
|
valuesFile := filepath.Join("/tmp", fmt.Sprintf("values-%s-%s.yaml", namespace, chartName))
|
|
valuesYAML, err := yaml.Marshal(values)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("marshal YAML: %w", err)
|
|
}
|
|
if err := os.WriteFile(valuesFile, valuesYAML, 0644); err != nil {
|
|
return "", nil, fmt.Errorf("write values file: %w", err)
|
|
}
|
|
return valuesFile, func() { os.Remove(valuesFile) }, nil
|
|
}
|
|
|
|
// runHelm exécute une commande helm et remonte stdout/stderr en cas d'erreur.
|
|
func (h *HelmProvisioner) runHelm(args ...string) error {
|
|
_, err := h.runHelmOutput(args...)
|
|
return err
|
|
}
|
|
|
|
// runHelmOutput exécute une commande helm et retourne son stdout (ex. "helm
|
|
// get values -o json").
|
|
func (h *HelmProvisioner) runHelmOutput(args ...string) ([]byte, error) {
|
|
cmd := exec.Command(h.helmPath, args...)
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("%v (stdout: %s, stderr: %s)", err, stdout.String(), stderr.String())
|
|
}
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
// dockerHubSecretName : nom fixe du Secret créé par le chart
|
|
// registry-credentials (voir buildRegistryCredentialsValues) — référencé
|
|
// tel quel par imagePullSecretsValue, toujours dans le même namespace.
|
|
const dockerHubSecretName = "dockerhub-pull-secret"
|
|
|
|
// imagePullSecretsValue : nil si aucun compte Docker Hub n'est configuré
|
|
// (OMNEX_DOCKERHUB_USERNAME/PASSWORD vides — pull anonyme, comportement par
|
|
// défaut inchangé). Un map Helm avec une valeur nil pour une clé existante
|
|
// du chart fait retomber sur le défaut du chart ("[]"), donc sans danger à
|
|
// assigner inconditionnellement.
|
|
func (h *HelmProvisioner) imagePullSecretsValue() []map[string]interface{} {
|
|
if h.cfg.DockerHubUsername == "" || h.cfg.DockerHubPassword == "" {
|
|
return nil
|
|
}
|
|
return []map[string]interface{}{{"name": dockerHubSecretName}}
|
|
}
|
|
|
|
// buildRegistryCredentialsValues construit les valeurs pour le chart
|
|
// registry-credentials (voir Provision/EnsureSharedInfra/
|
|
// MigrateToPremiumNamespace) — nil si pas de compte configuré, auquel cas
|
|
// installChart écrit un fichier de valeurs "null" (no-op, voir writeValuesFile).
|
|
func (h *HelmProvisioner) buildRegistryCredentialsValues() map[string]interface{} {
|
|
if h.cfg.DockerHubUsername == "" || h.cfg.DockerHubPassword == "" {
|
|
return nil
|
|
}
|
|
return map[string]interface{}{
|
|
"secretName": dockerHubSecretName,
|
|
"username": h.cfg.DockerHubUsername,
|
|
"password": h.cfg.DockerHubPassword,
|
|
}
|
|
}
|
|
|
|
func parseImage(image string) (repo string, tag string) {
|
|
if image == "" {
|
|
return "", "helm"
|
|
}
|
|
// Chercher le dernier ":" (pour gérer les images avec port dans le repo)
|
|
idx := strings.LastIndex(image, ":")
|
|
if idx == -1 {
|
|
return image, "helm"
|
|
}
|
|
return image[:idx], image[idx+1:]
|
|
}
|
|
|
|
// buildBackendValues construit les valeurs pour le chart backend.
|
|
// backendLinkSecret : partagé avec le chart lbtelegram (voir
|
|
// buildLBTelegramValues) — vide si le load-balancer Telegram n'est pas activé.
|
|
// pgPassword/redisPassword : générés par démo (voir Provision), jamais
|
|
// codés en dur — placés dans "secrets" (Secret k8s), pas "env" (visible en
|
|
// clair sur le Deployment sans droit de lecture des Secrets).
|
|
func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource, cfg ProvisionConfig, backendLinkSecret, pgPassword, redisPassword string) map[string]interface{} {
|
|
// Parser l'image backend pour séparer repository et tag
|
|
backendRepo, backendTag := parseImage(h.backendImage)
|
|
|
|
storageDriver := cfg.StorageDriver
|
|
if storageDriver == "" {
|
|
storageDriver = StorageLocal
|
|
}
|
|
|
|
// PVC uploads inutile si le stockage est S3 (rien n'est écrit sur le
|
|
// disque local dans ce cas).
|
|
uploadsEnabled := storageDriver == StorageLocal
|
|
|
|
env := map[string]string{
|
|
"DB_HOST": fmt.Sprintf("%s-postgresql-postgresql", d.Namespace),
|
|
"DB_PORT": "5432",
|
|
"DB_USER": "postgres",
|
|
"DB_NAME": "demo_db",
|
|
"DB_SSLMODE": "disable",
|
|
"REDIS_HOST": fmt.Sprintf("%s-redis-redis", d.Namespace),
|
|
"REDIS_PORT": "6379",
|
|
"API_PORT": "8080",
|
|
"NODE_ENV": "production",
|
|
"STORAGE_DRIVER": storageDriver,
|
|
}
|
|
if storageDriver == StorageS3 {
|
|
env["S3_BUCKET"] = cfg.S3Bucket
|
|
env["S3_ENDPOINT"] = cfg.S3Endpoint
|
|
}
|
|
|
|
if cfg.TelegramBotUsername != "" {
|
|
env["TELEGRAM_BOT_USERNAME"] = cfg.TelegramBotUsername
|
|
}
|
|
|
|
secrets := h.buildSecrets(resources)
|
|
secrets["DB_PASSWORD"] = pgPassword
|
|
secrets["REDIS_PASSWORD"] = redisPassword
|
|
if cfg.TelegramBotToken != "" {
|
|
secrets["TELEGRAM_BOT_TOKEN"] = cfg.TelegramBotToken
|
|
// URL publique du webhook Telegram de cette démo (route backend :
|
|
// POST /webhook/telegram, sans préfixe /api/v1). TELEGRAM_WEBHOOK_SECRET
|
|
// reste celui généré par le pool (h.buildSecrets ci-dessus).
|
|
secrets["TELEGRAM_WEBHOOK_URL"] = d.URL + "/webhook/telegram"
|
|
}
|
|
if cfg.NowPaymentsAPIKey != "" {
|
|
secrets["NOWPAYMENTS_API_KEY"] = cfg.NowPaymentsAPIKey
|
|
}
|
|
if cfg.NowPaymentsIPNSecret != "" {
|
|
// Écrase le secret pré-provisionné par le pool (ServiceNowPayments) :
|
|
// contrairement au webhook Telegram, l'IPN NowPayments doit
|
|
// correspondre à ce qui est réellement configuré sur le compte
|
|
// marchand NowPayments, pas à une valeur générée par le pool.
|
|
secrets["NOWPAYMENTS_IPN_SECRET"] = cfg.NowPaymentsIPNSecret
|
|
}
|
|
// TomTom : seule la première clé est requise, les 3 suivantes sont des
|
|
// clés de secours optionnelles (rotation automatique côté backend, voir
|
|
// services/tomtom_keys.go côté gestion).
|
|
if cfg.TomTomAPIKey != "" {
|
|
secrets["TOMTOM_API_KEY"] = cfg.TomTomAPIKey
|
|
}
|
|
if cfg.TomTomAPIKey1 != "" {
|
|
secrets["TOMTOM_API_KEY_1"] = cfg.TomTomAPIKey1
|
|
}
|
|
if cfg.TomTomAPIKey2 != "" {
|
|
secrets["TOMTOM_API_KEY_2"] = cfg.TomTomAPIKey2
|
|
}
|
|
if cfg.TomTomAPIKey3 != "" {
|
|
secrets["TOMTOM_API_KEY_3"] = cfg.TomTomAPIKey3
|
|
}
|
|
if cfg.LBTelegramEnabled() {
|
|
env["LBTELEGRAM_URL"] = fmt.Sprintf("http://%s-lbtelegram-lbtelegram.%s.svc.cluster.local:8081", d.Namespace, d.Namespace)
|
|
env["LBTELEGRAM_BOT1_USERNAME"] = cfg.LBBot1Username
|
|
env["LBTELEGRAM_BOT2_USERNAME"] = cfg.LBBot2Username
|
|
secrets["BACKEND_LINK_SECRET"] = backendLinkSecret
|
|
}
|
|
|
|
values := map[string]interface{}{
|
|
"replicaCount": 1,
|
|
"imagePullSecrets": h.imagePullSecretsValue(),
|
|
"image": map[string]interface{}{
|
|
"repository": backendRepo,
|
|
"tag": backendTag,
|
|
"pullPolicy": "Always",
|
|
},
|
|
"service": map[string]interface{}{
|
|
"type": "ClusterIP",
|
|
"port": 8080,
|
|
},
|
|
"autoscaling": map[string]interface{}{
|
|
"enabled": false,
|
|
},
|
|
"persistence": map[string]interface{}{
|
|
"uploads": map[string]interface{}{
|
|
"enabled": uploadsEnabled,
|
|
},
|
|
},
|
|
"env": env,
|
|
"secrets": secrets,
|
|
}
|
|
|
|
return values
|
|
}
|
|
|
|
// buildFrontendValues construit les valeurs pour le chart frontend.
|
|
func (h *HelmProvisioner) buildFrontendValues(d Demo) map[string]interface{} {
|
|
// Nom réel du Service backend : "<namespace>-backend" (release helm) +
|
|
// "-gestion-backend" (nom du chart, ajouté par le helper fullname).
|
|
backendURL := fmt.Sprintf("http://%s-backend-gestion-backend.%s.svc.cluster.local:8080", d.Namespace, d.Namespace)
|
|
// Parser l'image frontend pour séparer repository et tag
|
|
frontendRepo, frontendTag := parseImage(h.frontendImage)
|
|
|
|
return map[string]interface{}{
|
|
"replicaCount": 1,
|
|
"imagePullSecrets": h.imagePullSecretsValue(),
|
|
"image": map[string]interface{}{
|
|
"repository": frontendRepo,
|
|
"tag": frontendTag,
|
|
"pullPolicy": "Always",
|
|
},
|
|
"apiUrl": backendURL,
|
|
"service": map[string]interface{}{
|
|
"type": "ClusterIP",
|
|
"port": 80,
|
|
},
|
|
"autoscaling": map[string]interface{}{
|
|
"enabled": false,
|
|
},
|
|
}
|
|
}
|
|
|
|
// buildIngressRouteValues construit les valeurs pour le chart ingressroute,
|
|
// qui raccorde cette démo au Traefik partagé (WAF + routage HTTPS).
|
|
func (h *HelmProvisioner) buildIngressRouteValues(d Demo) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"host": fmt.Sprintf("%s.%s", d.Namespace, h.baseDomain),
|
|
"traefikNamespace": sharedTraefikNamespace,
|
|
}
|
|
}
|
|
|
|
// buildNetworkPolicyValues construit les valeurs pour le chart
|
|
// network-policy (isolation réseau stricte d'une démo, voir Provision).
|
|
// kubeSystemNamespace/blockedEgressCIDRs gardent les défauts du chart
|
|
// (deploy/chart-gestion/network-policy/values.yaml) ; seul traefikNamespace
|
|
// est explicité pour rester synchronisé avec sharedTraefikNamespace.
|
|
func (h *HelmProvisioner) buildNetworkPolicyValues() map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"traefikNamespace": sharedTraefikNamespace,
|
|
}
|
|
}
|
|
|
|
// buildLBTelegramValues construit les valeurs pour le chart lbtelegram
|
|
// (load-balancer multi-bots Telegram). N'est appelé que si
|
|
// cfg.LBTelegramEnabled() — au moins un bot renseigné par l'admin. Les
|
|
// identifiants des bots (username/token) viennent de l'admin ; le reste
|
|
// (DSN postgres/redis, secrets JWT/webhook/backend-link) est généré ici,
|
|
// comme indiqué par le commentaire du chart (deploy/chart-gestion/lbtelegram/values.yaml).
|
|
func (h *HelmProvisioner) buildLBTelegramValues(d Demo, cfg ProvisionConfig, backendLinkSecret, pgPassword, redisPassword string) map[string]interface{} {
|
|
botCount := 0
|
|
if cfg.LBBot1Username != "" {
|
|
botCount++
|
|
}
|
|
if cfg.LBBot2Username != "" {
|
|
botCount++
|
|
}
|
|
|
|
backendURL := fmt.Sprintf("http://%s-backend-gestion-backend.%s.svc.cluster.local:8080", d.Namespace, d.Namespace)
|
|
dbURL := fmt.Sprintf("postgres://postgres:%s@%s-postgresql-postgresql:5432/demo_db?sslmode=disable", pgPassword, d.Namespace)
|
|
redisURL := fmt.Sprintf("redis://:%s@%s-redis-redis:6379/0", redisPassword, d.Namespace)
|
|
lbRepo, lbTag := parseImage(h.lbtelegramImage)
|
|
host := fmt.Sprintf("%s.%s", d.Namespace, h.baseDomain)
|
|
|
|
env := map[string]string{
|
|
"PORT": "8081",
|
|
"ENV": "production",
|
|
// URL publique HTTPS de cette démo : requise par lbtelegram (panic
|
|
// sinon), utilisée pour enregistrer les webhooks Telegram de chaque
|
|
// bot (GATEWAY_URL + "/webhook/" + botID) — voir
|
|
// deploy/chart-gestion/lbtelegram/templates/ingressroute.yaml pour
|
|
// le routage public correspondant.
|
|
"GATEWAY_URL": "https://" + host,
|
|
"BOT_COUNT": fmt.Sprintf("%d", botCount),
|
|
"BOT1_USERNAME": cfg.LBBot1Username,
|
|
"BOT2_USERNAME": cfg.LBBot2Username,
|
|
"BACKEND_LINK_URL": backendURL,
|
|
}
|
|
if cfg.LBStrategy != "" {
|
|
env["LB_STRATEGY"] = cfg.LBStrategy
|
|
}
|
|
if cfg.LBJWTTTLSeconds != "" {
|
|
env["JWT_TTL_SECONDS"] = cfg.LBJWTTTLSeconds
|
|
}
|
|
if cfg.LBHealthCheckInterval != "" {
|
|
env["HEALTH_CHECK_INTERVAL"] = cfg.LBHealthCheckInterval
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"imagePullSecrets": h.imagePullSecretsValue(),
|
|
"image": map[string]interface{}{
|
|
"repository": lbRepo,
|
|
"tag": lbTag,
|
|
"pullPolicy": "Always",
|
|
},
|
|
"host": host,
|
|
"traefikNamespace": sharedTraefikNamespace,
|
|
"env": env,
|
|
"secrets": map[string]string{
|
|
"BOT1_TOKEN": cfg.LBBot1Token,
|
|
"BOT2_TOKEN": cfg.LBBot2Token,
|
|
"BOT1_WEBHOOK_SECRET": randomSecret(),
|
|
"BOT2_WEBHOOK_SECRET": randomSecret(),
|
|
"BACKEND_LINK_SECRET": backendLinkSecret,
|
|
"JWT_SECRET": randomSecret(),
|
|
"DATABASE_URL": dbURL,
|
|
"REDIS_URL": redisURL,
|
|
},
|
|
}
|
|
}
|
|
|
|
// randomSecret génère un secret hexadécimal aléatoire de 32 octets (256 bits).
|
|
func randomSecret() string {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
// crypto/rand.Read ne devrait jamais échouer sur un système normal ;
|
|
// fail-secure plutôt que de retourner un secret prévisible.
|
|
panic(fmt.Sprintf("randomSecret: %v", err))
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// buildSecrets extrait les références des secrets des ressources externes.
|
|
func (h *HelmProvisioner) buildSecrets(resources []ExternalResource) map[string]string {
|
|
secrets := map[string]string{}
|
|
for _, r := range resources {
|
|
switch r.Service {
|
|
case ServiceTelegram:
|
|
secrets["TELEGRAM_WEBHOOK_SECRET"] = r.SecretRef
|
|
case ServiceNowPayments:
|
|
secrets["NOWPAYMENTS_IPN_SECRET"] = r.SecretRef
|
|
}
|
|
}
|
|
return secrets
|
|
}
|
|
|
|
// buildPostgresValues construit les valeurs pour le chart postgresql.
|
|
// Toujours persistant (PVC) : voir Provision. password : généré par démo,
|
|
// jamais partagé (voir Provision/MigrateToPremiumNamespace).
|
|
func (h *HelmProvisioner) buildPostgresValues(d Demo, password string) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"imagePullSecrets": h.imagePullSecretsValue(),
|
|
"auth": map[string]interface{}{
|
|
"password": password,
|
|
"username": "postgres",
|
|
"database": "demo_db",
|
|
},
|
|
"service": map[string]interface{}{
|
|
"type": "ClusterIP",
|
|
"port": 5432,
|
|
},
|
|
"persistence": map[string]interface{}{
|
|
"enabled": true,
|
|
},
|
|
}
|
|
}
|
|
|
|
// buildRedisValues construit les valeurs pour le chart redis.
|
|
// Toujours persistant (PVC) : voir Provision. password : généré par démo,
|
|
// jamais partagé (voir Provision/MigrateToPremiumNamespace).
|
|
func (h *HelmProvisioner) buildRedisValues(d Demo, password string) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"imagePullSecrets": h.imagePullSecretsValue(),
|
|
"service": map[string]interface{}{
|
|
"type": "ClusterIP",
|
|
"port": 6379,
|
|
},
|
|
"auth": map[string]interface{}{
|
|
"password": password,
|
|
},
|
|
"persistence": map[string]interface{}{
|
|
"enabled": true,
|
|
},
|
|
}
|
|
}
|
|
|
|
// waitForRollout attend que tous les déploiements dans le namespace soient prêts.
|
|
func (h *HelmProvisioner) waitForRollout(namespace string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(5 * time.Second):
|
|
ready, err := h.rolloutReady(ctx, namespace)
|
|
if err != nil {
|
|
return fmt.Errorf("rollout check: %w", err)
|
|
}
|
|
if ready {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// rolloutReady : true si tous les Deployment et StatefulSet du namespace ont
|
|
// toutes leurs répliques prêtes. Remplace un ancien appel à
|
|
// "kubectl rollout status deployment -n <namespace>" — en plus de l'absence
|
|
// du binaire kubectl dans l'image (seul helm y est installé, voir
|
|
// control-plane/api/Dockerfile), cette commande était de toute façon
|
|
// invalide sans nom de déploiement précis (jamais réellement exercée avant
|
|
// la première migration premium en conditions réelles).
|
|
func (h *HelmProvisioner) rolloutReady(ctx context.Context, namespace string) (bool, error) {
|
|
deploys, err := h.k8sClient.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, d := range deploys.Items {
|
|
desired := int32(1)
|
|
if d.Spec.Replicas != nil {
|
|
desired = *d.Spec.Replicas
|
|
}
|
|
if d.Status.ReadyReplicas < desired {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
statefulSets, err := h.k8sClient.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, s := range statefulSets.Items {
|
|
desired := int32(1)
|
|
if s.Spec.Replicas != nil {
|
|
desired = *s.Spec.Replicas
|
|
}
|
|
if s.Status.ReadyReplicas < desired {
|
|
return false, nil
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// componentResourceLimits : limites CPU/mémoire configurées dans chaque chart
|
|
// (deploy/chart-gestion/*/values.yaml), utilisées pour calculer un % d'usage.
|
|
var componentResourceLimits = map[string]struct {
|
|
cpuMilli int64
|
|
memMi int64
|
|
}{
|
|
"backend": {cpuMilli: 500, memMi: 256},
|
|
"frontend": {cpuMilli: 200, memMi: 128},
|
|
"postgresql": {cpuMilli: 1000, memMi: 1024},
|
|
"redis": {cpuMilli: 500, memMi: 512},
|
|
"lbtelegram": {cpuMilli: 500, memMi: 256},
|
|
}
|
|
|
|
// componentKey identifie le composant (backend/frontend/postgresql/redis/
|
|
// lbtelegram) à partir du nom de pod généré par Helm (ex:
|
|
// "demo-xxx-backend-...-6d59f4-abcde").
|
|
func componentKey(podName string) string {
|
|
switch {
|
|
case strings.Contains(podName, "backend"):
|
|
return "backend"
|
|
case strings.Contains(podName, "frontend"):
|
|
return "frontend"
|
|
case strings.Contains(podName, "postgresql"):
|
|
return "postgresql"
|
|
case strings.Contains(podName, "redis"):
|
|
return "redis"
|
|
case strings.Contains(podName, "lbtelegram"):
|
|
return "lbtelegram"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// GetResourceState : phase + usage CPU/mémoire live de chaque pod d'une démo.
|
|
// L'usage (metrics-server) est best-effort : s'il est indisponible, seule la
|
|
// phase du pod est renseignée plutôt que de faire échouer tout l'appel.
|
|
func (h *HelmProvisioner) GetResourceState(
|
|
ctx context.Context,
|
|
namespace string,
|
|
) (ResourceState, error) {
|
|
pods, err := h.k8sClient.
|
|
CoreV1().
|
|
Pods(namespace).
|
|
List(ctx, metav1.ListOptions{})
|
|
|
|
if err != nil {
|
|
return ResourceState{}, err
|
|
}
|
|
|
|
usage := map[string][2]int64{} // pod name -> [cpuMilli, memMi]
|
|
if h.metricsClient != nil {
|
|
if list, err := h.metricsClient.MetricsV1beta1().PodMetricses(namespace).List(ctx, metav1.ListOptions{}); err == nil {
|
|
for _, m := range list.Items {
|
|
var cpu, mem int64
|
|
for _, c := range m.Containers {
|
|
cpu += c.Usage.Cpu().MilliValue()
|
|
mem += c.Usage.Memory().Value() / (1024 * 1024)
|
|
}
|
|
usage[m.Name] = [2]int64{cpu, mem}
|
|
}
|
|
}
|
|
}
|
|
|
|
var state ResourceState
|
|
|
|
for _, pod := range pods.Items {
|
|
key := componentKey(pod.Name)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
|
|
cs := ComponentState{Phase: string(pod.Status.Phase)}
|
|
if limits, ok := componentResourceLimits[key]; ok {
|
|
cs.CPULimitMilli = limits.cpuMilli
|
|
cs.MemoryLimitMi = limits.memMi
|
|
}
|
|
if u, ok := usage[pod.Name]; ok {
|
|
cs.CPUMilli = u[0]
|
|
cs.MemoryMi = u[1]
|
|
}
|
|
|
|
switch key {
|
|
case "backend":
|
|
state.API = cs
|
|
case "frontend":
|
|
state.Web = cs
|
|
case "postgresql":
|
|
state.DB = cs
|
|
case "redis":
|
|
state.DBM = cs
|
|
case "lbtelegram":
|
|
state.LB = cs
|
|
}
|
|
}
|
|
|
|
return state, nil
|
|
}
|