From 02a5edd1b7353fd782ded34c939a006617431ef6 Mon Sep 17 00:00:00 2001 From: Xor290 Date: Mon, 17 Aug 2026 19:34:14 +0200 Subject: [PATCH] chore: build --- control-plane/api/internal/config/config.go | 38 +++-- .../api/internal/demos/helm_provisioner.go | 152 +++++++++++++++++- 2 files changed, 169 insertions(+), 21 deletions(-) diff --git a/control-plane/api/internal/config/config.go b/control-plane/api/internal/config/config.go index 935c5c3..056dfb4 100644 --- a/control-plane/api/internal/config/config.go +++ b/control-plane/api/internal/config/config.go @@ -22,6 +22,12 @@ type Config struct { BackendImage string LBTelegramImage string AppDownloadsDir string // OMNEX_APP_DOWNLOADS_DIR : répertoire des .apk téléchargeables (voir internal/downloads) + // DockerHubUsername/Password : compte Docker Hub authentifié (droits + // lecture seule) injecté dans chaque namespace de démo (voir + // deploy/chart-gestion/registry-credentials) pour passer le rate-limit + // de pull anonyme (100/6h par IP) à 200/6h. "" = pull anonyme (défaut). + DockerHubUsername string + DockerHubPassword string } // Load lit la config. Fail-secure : secret JWT obligatoire ; en prod base + Redis aussi. @@ -32,21 +38,23 @@ func Load() (Config, error) { } cfg := Config{ - Addr: getenv("OMNEX_ADDR", ":8080"), - JWTSecret: []byte(secret), - SessionTTL: 24 * time.Hour, - AllowedOrigins: splitCSV(getenv("OMNEX_ALLOWED_ORIGINS", "http://localhost:5173")), - Env: getenv("OMNEX_ENV", "dev"), - DatabaseURL: os.Getenv("OMNEX_DATABASE_URL"), - RedisURL: os.Getenv("OMNEX_REDIS_URL"), - DemoDomain: getenv("OMNEX_DEMO_DOMAIN", "demo.omnex.app"), - DemoHTTPSPort: getenv("OMNEX_DEMO_HTTPS_PORT", "443"), - RegistrationCode: os.Getenv("OMNEX_REGISTRATION_CODE"), - Kubeconfig: os.Getenv("KUBECONFIG"), - FrontendImage: os.Getenv("FRONTEND_IMAGE_APP"), - BackendImage: os.Getenv("BACKEND_IMAGE_APP"), - LBTelegramImage: os.Getenv("LBTELEGRAM_IMAGE_APP"), - AppDownloadsDir: getenv("OMNEX_APP_DOWNLOADS_DIR", "/app-downloads"), + Addr: getenv("OMNEX_ADDR", ":8080"), + JWTSecret: []byte(secret), + SessionTTL: 24 * time.Hour, + AllowedOrigins: splitCSV(getenv("OMNEX_ALLOWED_ORIGINS", "http://localhost:5173")), + Env: getenv("OMNEX_ENV", "dev"), + DatabaseURL: os.Getenv("OMNEX_DATABASE_URL"), + RedisURL: os.Getenv("OMNEX_REDIS_URL"), + DemoDomain: getenv("OMNEX_DEMO_DOMAIN", "demo.omnex.app"), + DemoHTTPSPort: getenv("OMNEX_DEMO_HTTPS_PORT", "443"), + RegistrationCode: os.Getenv("OMNEX_REGISTRATION_CODE"), + Kubeconfig: os.Getenv("KUBECONFIG"), + FrontendImage: os.Getenv("FRONTEND_IMAGE_APP"), + BackendImage: os.Getenv("BACKEND_IMAGE_APP"), + LBTelegramImage: os.Getenv("LBTELEGRAM_IMAGE_APP"), + AppDownloadsDir: getenv("OMNEX_APP_DOWNLOADS_DIR", "/app-downloads"), + DockerHubUsername: os.Getenv("OMNEX_DOCKERHUB_USERNAME"), + DockerHubPassword: os.Getenv("OMNEX_DOCKERHUB_PASSWORD"), } if cfg.Env == "prod" { diff --git a/control-plane/api/internal/demos/helm_provisioner.go b/control-plane/api/internal/demos/helm_provisioner.go index 2943339..a0d880a 100644 --- a/control-plane/api/internal/demos/helm_provisioner.go +++ b/control-plane/api/internal/demos/helm_provisioner.go @@ -19,6 +19,7 @@ import ( "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/types" @@ -87,8 +88,9 @@ func NewHelmProvisioner( // 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" + sharedTraefikNamespace = "traefik" + sharedTraefikRelease = "gestion-traefik" + sharedRegistryCredentialsRelease = "gestion-registry-credentials" ) // Provision déploie une démo avec Helm : @@ -113,6 +115,24 @@ func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource, cfg Pr 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 @@ -207,11 +227,29 @@ func (h *HelmProvisioner) EnsureSharedInfra() error { 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. - if err := h.upgradeInstallChart(sharedTraefikNamespace, sharedTraefikRelease, "traefik", nil); err != nil { + // "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) } @@ -278,6 +316,14 @@ func (h *HelmProvisioner) MigrateToPremiumNamespace(d Demo, newNamespace, newURL 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 @@ -648,7 +694,23 @@ func (h *HelmProvisioner) execInPod(ctx context.Context, namespace, pod, contain return stdout.String(), stderrBuf.String(), nil } -// createNamespace crée un namespace Kubernetes. +// 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{ @@ -662,6 +724,34 @@ func (h *HelmProvisioner) createNamespace(name string) error { 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 } @@ -693,6 +783,13 @@ func (h *HelmProvisioner) installChart(namespace, chartName string, values map[s "--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) @@ -714,6 +811,12 @@ func (h *HelmProvisioner) upgradeInstallChart(namespace, release, chartName stri "--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) @@ -765,6 +868,38 @@ func (h *HelmProvisioner) runHelmOutput(args ...string) ([]byte, error) { 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" @@ -860,7 +995,8 @@ func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResourc } values := map[string]interface{}{ - "replicaCount": 1, + "replicaCount": 1, + "imagePullSecrets": h.imagePullSecretsValue(), "image": map[string]interface{}{ "repository": backendRepo, "tag": backendTag, @@ -894,7 +1030,8 @@ func (h *HelmProvisioner) buildFrontendValues(d Demo) map[string]interface{} { frontendRepo, frontendTag := parseImage(h.frontendImage) return map[string]interface{}{ - "replicaCount": 1, + "replicaCount": 1, + "imagePullSecrets": h.imagePullSecretsValue(), "image": map[string]interface{}{ "repository": frontendRepo, "tag": frontendTag, @@ -977,6 +1114,7 @@ func (h *HelmProvisioner) buildLBTelegramValues(d Demo, cfg ProvisionConfig, bac } return map[string]interface{}{ + "imagePullSecrets": h.imagePullSecretsValue(), "image": map[string]interface{}{ "repository": lbRepo, "tag": lbTag, @@ -1028,6 +1166,7 @@ func (h *HelmProvisioner) buildSecrets(resources []ExternalResource) map[string] // 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", @@ -1048,6 +1187,7 @@ func (h *HelmProvisioner) buildPostgresValues(d Demo, password string) map[strin // 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,