chore: build
ci-api / test (push) Successful in 26m45s

This commit is contained in:
Xor290
2026-08-17 19:34:14 +02:00
parent d23d2cbe37
commit 02a5edd1b7
2 changed files with 169 additions and 21 deletions
+23 -15
View File
@@ -22,6 +22,12 @@ type Config struct {
BackendImage string BackendImage string
LBTelegramImage string LBTelegramImage string
AppDownloadsDir string // OMNEX_APP_DOWNLOADS_DIR : répertoire des .apk téléchargeables (voir internal/downloads) 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. // Load lit la config. Fail-secure : secret JWT obligatoire ; en prod base + Redis aussi.
@@ -32,21 +38,23 @@ func Load() (Config, error) {
} }
cfg := Config{ cfg := Config{
Addr: getenv("OMNEX_ADDR", ":8080"), Addr: getenv("OMNEX_ADDR", ":8080"),
JWTSecret: []byte(secret), JWTSecret: []byte(secret),
SessionTTL: 24 * time.Hour, SessionTTL: 24 * time.Hour,
AllowedOrigins: splitCSV(getenv("OMNEX_ALLOWED_ORIGINS", "http://localhost:5173")), AllowedOrigins: splitCSV(getenv("OMNEX_ALLOWED_ORIGINS", "http://localhost:5173")),
Env: getenv("OMNEX_ENV", "dev"), Env: getenv("OMNEX_ENV", "dev"),
DatabaseURL: os.Getenv("OMNEX_DATABASE_URL"), DatabaseURL: os.Getenv("OMNEX_DATABASE_URL"),
RedisURL: os.Getenv("OMNEX_REDIS_URL"), RedisURL: os.Getenv("OMNEX_REDIS_URL"),
DemoDomain: getenv("OMNEX_DEMO_DOMAIN", "demo.omnex.app"), DemoDomain: getenv("OMNEX_DEMO_DOMAIN", "demo.omnex.app"),
DemoHTTPSPort: getenv("OMNEX_DEMO_HTTPS_PORT", "443"), DemoHTTPSPort: getenv("OMNEX_DEMO_HTTPS_PORT", "443"),
RegistrationCode: os.Getenv("OMNEX_REGISTRATION_CODE"), RegistrationCode: os.Getenv("OMNEX_REGISTRATION_CODE"),
Kubeconfig: os.Getenv("KUBECONFIG"), Kubeconfig: os.Getenv("KUBECONFIG"),
FrontendImage: os.Getenv("FRONTEND_IMAGE_APP"), FrontendImage: os.Getenv("FRONTEND_IMAGE_APP"),
BackendImage: os.Getenv("BACKEND_IMAGE_APP"), BackendImage: os.Getenv("BACKEND_IMAGE_APP"),
LBTelegramImage: os.Getenv("LBTELEGRAM_IMAGE_APP"), LBTelegramImage: os.Getenv("LBTELEGRAM_IMAGE_APP"),
AppDownloadsDir: getenv("OMNEX_APP_DOWNLOADS_DIR", "/app-downloads"), AppDownloadsDir: getenv("OMNEX_APP_DOWNLOADS_DIR", "/app-downloads"),
DockerHubUsername: os.Getenv("OMNEX_DOCKERHUB_USERNAME"),
DockerHubPassword: os.Getenv("OMNEX_DOCKERHUB_PASSWORD"),
} }
if cfg.Env == "prod" { if cfg.Env == "prod" {
@@ -19,6 +19,7 @@ import (
"go.yaml.in/yaml/v2" "go.yaml.in/yaml/v2"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
k8sCoreV1 "k8s.io/api/core/v1" k8sCoreV1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
k8sErrors "k8s.io/apimachinery/pkg/api/errors" k8sErrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/types"
@@ -87,8 +88,9 @@ func NewHelmProvisioner(
// EnsureSharedInfra), jamais par démo — chaque démo installe seulement le // EnsureSharedInfra), jamais par démo — chaque démo installe seulement le
// chart "ingressroute" qui la raccorde à ce Traefik partagé. // chart "ingressroute" qui la raccorde à ce Traefik partagé.
const ( const (
sharedTraefikNamespace = "traefik" sharedTraefikNamespace = "traefik"
sharedTraefikRelease = "gestion-traefik" sharedTraefikRelease = "gestion-traefik"
sharedRegistryCredentialsRelease = "gestion-registry-credentials"
) )
// Provision déploie une démo avec Helm : // 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) 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 // 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 // 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 // (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) 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 // Le subchart officiel traefik/traefik est vendorisé dans le repo
// (deploy/chart-gestion/traefik/charts/traefik-*.tgz + Chart.lock) : // (deploy/chart-gestion/traefik/charts/traefik-*.tgz + Chart.lock) :
// pas de "helm dependency update" au runtime, qui échouerait de toute // pas de "helm dependency update" au runtime, qui échouerait de toute
// façon puisque /charts est monté en lecture seule. // 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) return fmt.Errorf("déploiement traefik partagé: %w", err)
} }
@@ -278,6 +316,14 @@ func (h *HelmProvisioner) MigrateToPremiumNamespace(d Demo, newNamespace, newURL
h.deleteNamespace(newNamespace) h.deleteNamespace(newNamespace)
return fmt.Errorf("déploiement network-policy: %w", err) 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 := d
newDemo.Namespace = newNamespace newDemo.Namespace = newNamespace
@@ -648,7 +694,23 @@ func (h *HelmProvisioner) execInPod(ctx context.Context, namespace, pod, contain
return stdout.String(), stderrBuf.String(), nil 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 { func (h *HelmProvisioner) createNamespace(name string) error {
ns := &k8sCoreV1.Namespace{ ns := &k8sCoreV1.Namespace{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
@@ -662,6 +724,34 @@ func (h *HelmProvisioner) createNamespace(name string) error {
if err != nil && !k8sErrors.IsAlreadyExists(err) { if err != nil && !k8sErrors.IsAlreadyExists(err) {
return 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 return nil
} }
@@ -693,6 +783,13 @@ func (h *HelmProvisioner) installChart(namespace, chartName string, values map[s
"--wait", "--wait",
"--atomic", "--atomic",
"--timeout", "5m", "--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 { if err := h.runHelm(args...); err != nil {
return fmt.Errorf("helm install %s: %w", chartName, err) return fmt.Errorf("helm install %s: %w", chartName, err)
@@ -714,6 +811,12 @@ func (h *HelmProvisioner) upgradeInstallChart(namespace, release, chartName stri
"--wait", "--wait",
"--atomic", "--atomic",
"--timeout", "5m", "--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 { if values != nil {
valuesFile, cleanup, err := h.writeValuesFile(namespace, chartName, values) valuesFile, cleanup, err := h.writeValuesFile(namespace, chartName, values)
@@ -765,6 +868,38 @@ func (h *HelmProvisioner) runHelmOutput(args ...string) ([]byte, error) {
return stdout.Bytes(), nil 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) { func parseImage(image string) (repo string, tag string) {
if image == "" { if image == "" {
return "", "helm" return "", "helm"
@@ -860,7 +995,8 @@ func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResourc
} }
values := map[string]interface{}{ values := map[string]interface{}{
"replicaCount": 1, "replicaCount": 1,
"imagePullSecrets": h.imagePullSecretsValue(),
"image": map[string]interface{}{ "image": map[string]interface{}{
"repository": backendRepo, "repository": backendRepo,
"tag": backendTag, "tag": backendTag,
@@ -894,7 +1030,8 @@ func (h *HelmProvisioner) buildFrontendValues(d Demo) map[string]interface{} {
frontendRepo, frontendTag := parseImage(h.frontendImage) frontendRepo, frontendTag := parseImage(h.frontendImage)
return map[string]interface{}{ return map[string]interface{}{
"replicaCount": 1, "replicaCount": 1,
"imagePullSecrets": h.imagePullSecretsValue(),
"image": map[string]interface{}{ "image": map[string]interface{}{
"repository": frontendRepo, "repository": frontendRepo,
"tag": frontendTag, "tag": frontendTag,
@@ -977,6 +1114,7 @@ func (h *HelmProvisioner) buildLBTelegramValues(d Demo, cfg ProvisionConfig, bac
} }
return map[string]interface{}{ return map[string]interface{}{
"imagePullSecrets": h.imagePullSecretsValue(),
"image": map[string]interface{}{ "image": map[string]interface{}{
"repository": lbRepo, "repository": lbRepo,
"tag": lbTag, "tag": lbTag,
@@ -1028,6 +1166,7 @@ func (h *HelmProvisioner) buildSecrets(resources []ExternalResource) map[string]
// jamais partagé (voir Provision/MigrateToPremiumNamespace). // jamais partagé (voir Provision/MigrateToPremiumNamespace).
func (h *HelmProvisioner) buildPostgresValues(d Demo, password string) map[string]interface{} { func (h *HelmProvisioner) buildPostgresValues(d Demo, password string) map[string]interface{} {
return map[string]interface{}{ return map[string]interface{}{
"imagePullSecrets": h.imagePullSecretsValue(),
"auth": map[string]interface{}{ "auth": map[string]interface{}{
"password": password, "password": password,
"username": "postgres", "username": "postgres",
@@ -1048,6 +1187,7 @@ func (h *HelmProvisioner) buildPostgresValues(d Demo, password string) map[strin
// jamais partagé (voir Provision/MigrateToPremiumNamespace). // jamais partagé (voir Provision/MigrateToPremiumNamespace).
func (h *HelmProvisioner) buildRedisValues(d Demo, password string) map[string]interface{} { func (h *HelmProvisioner) buildRedisValues(d Demo, password string) map[string]interface{} {
return map[string]interface{}{ return map[string]interface{}{
"imagePullSecrets": h.imagePullSecretsValue(),
"service": map[string]interface{}{ "service": map[string]interface{}{
"type": "ClusterIP", "type": "ClusterIP",
"port": 6379, "port": 6379,