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
@@ -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.
@@ -47,6 +53,8 @@ func Load() (Config, error) {
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" {
@@ -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"
@@ -89,6 +90,7 @@ func NewHelmProvisioner(
const (
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"
@@ -861,6 +996,7 @@ func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResourc
values := map[string]interface{}{
"replicaCount": 1,
"imagePullSecrets": h.imagePullSecretsValue(),
"image": map[string]interface{}{
"repository": backendRepo,
"tag": backendTag,
@@ -895,6 +1031,7 @@ func (h *HelmProvisioner) buildFrontendValues(d Demo) map[string]interface{} {
return map[string]interface{}{
"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,