chore: build
ci-api / test (push) Successful in 25m49s

This commit is contained in:
Xor290
2026-08-18 20:07:52 +02:00
parent aa1aa60f00
commit dfc62c305d
25 changed files with 741 additions and 58 deletions
@@ -1337,23 +1337,55 @@ func (h *HelmProvisioner) waitForRollout(namespace string) error {
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
// Utiliser kubectl pour vérifier le rollout
cmd := exec.Command("kubectl", "rollout", "status", "deployment", "-n", namespace, "--timeout=30s")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
// Si c'est juste un timeout, on continue d'attendre
if strings.Contains(stderr.String(), "timed out") {
continue
}
return fmt.Errorf("rollout check: %v: %s", err, stderr.String())
ready, err := h.rolloutReady(ctx, namespace)
if err != nil {
return fmt.Errorf("rollout check: %w", err)
}
if ready {
return nil
}
// Si la commande réussit, tous les déploiements sont prêts
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 {
@@ -20,6 +20,10 @@ spec:
labels:
{{- include "backend.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
+5
View File
@@ -5,6 +5,11 @@ image:
tag: latest
pullPolicy: IfNotPresent
# Injecté par le provisioner (voir deploy/chart-gestion/registry-credentials)
# si un compte Docker Hub authentifié est configuré — évite le rate-limit de
# pull anonyme partagé par IP de nœud. Vide = pull anonyme (défaut).
imagePullSecrets: []
service:
type: ClusterIP
port: 8080
@@ -16,6 +16,10 @@ spec:
labels:
{{- include "frontend.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
@@ -25,8 +29,13 @@ spec:
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
ports:
# L'image xor1234/frontend-mln (nginx) écoute en dur sur le port
# 80 (voir /etc/nginx/conf.d/default.conf embarqué dans l'image) —
# déjà anticipé par deploy/chart-gestion/ingressroute/values.yaml
# (frontend.servicePort: 80). 8080 ici ne correspondait à rien de
# réellement écouté, d'où le "connection refused" en probe.
- name: http
containerPort: 8080
containerPort: 80
protocol: TCP
volumeMounts:
- name: tmp
@@ -37,6 +46,15 @@ spec:
mountPath: /srv/config.js
subPath: config.js
readOnly: true
# readOnlyRootFilesystem:true (voir securityContext) empêche
# nginx de créer /var/cache/nginx/client_temp au démarrage
# ("mkdir() ... failed (30: Read-only file system)", CrashLoop) —
# nginx a besoin d'y écrire même quand il ne sert que du
# contenu statique.
- name: nginx-cache
mountPath: /var/cache/nginx
- name: nginx-run
mountPath: /var/run
livenessProbe:
httpGet:
path: /
@@ -56,6 +74,10 @@ spec:
emptyDir: {}
- name: caddy-data
emptyDir: {}
- name: nginx-cache
emptyDir: {}
- name: nginx-run
emptyDir: {}
- name: app-config
configMap:
name: {{ include "frontend.fullname" . }}-config
+10 -1
View File
@@ -7,9 +7,18 @@ image:
tag: latest
pullPolicy: IfNotPresent
# Injecté par le provisioner (voir deploy/chart-gestion/registry-credentials)
# si un compte Docker Hub authentifié est configuré — évite le rate-limit de
# pull anonyme partagé par IP de nœud. Vide = pull anonyme (défaut).
imagePullSecrets: []
service:
type: ClusterIP
port: 8080
# L'IngressRoute (deploy/chart-gestion/ingressroute) référence directement
# ce port de Service (frontend.servicePort, déjà à 80 dans son
# values.yaml — il anticipait correctement le vrai port de nginx). Doit
# rester égal à containerPort (voir templates/deployment.yaml).
port: 80
resources:
requests:
@@ -18,6 +18,10 @@ spec:
labels:
{{- include "lbtelegram.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
@@ -5,6 +5,11 @@ image:
tag: latest
pullPolicy: IfNotPresent
# Injecté par le provisioner (voir deploy/chart-gestion/registry-credentials)
# si un compte Docker Hub authentifié est configuré — évite le rate-limit de
# pull anonyme partagé par IP de nœud. Vide = pull anonyme (défaut).
imagePullSecrets: []
resources:
requests:
cpu: 100m
@@ -15,6 +15,10 @@ spec:
labels:
{{- include "postgresql.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
@@ -3,6 +3,11 @@ image:
tag: "16-alpine"
pullPolicy: IfNotPresent
# Injecté par le provisioner (voir deploy/chart-gestion/registry-credentials)
# si un compte Docker Hub authentifié est configuré — évite le rate-limit de
# pull anonyme partagé par IP de nœud. Vide = pull anonyme (défaut).
imagePullSecrets: []
auth:
username: postgres
database: gestion_db
@@ -15,6 +15,10 @@ spec:
labels:
{{- include "redis.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
+5
View File
@@ -3,6 +3,11 @@ image:
tag: "7-alpine"
pullPolicy: IfNotPresent
# Injecté par le provisioner (voir deploy/chart-gestion/registry-credentials)
# si un compte Docker Hub authentifié est configuré — évite le rate-limit de
# pull anonyme partagé par IP de nœud. Vide = pull anonyme (défaut).
imagePullSecrets: []
auth:
# À surcharger via --set auth.password=xxx
password: ""
@@ -0,0 +1,6 @@
apiVersion: v2
name: gestion-registry-credentials
description: Secret docker-registry (Docker Hub authentifié) installé par démo — évite le rate-limit de pull anonyme (100/6h par IP, contre 200/6h authentifié) partagé entre tous les pods du même nœud.
type: application
version: 0.1.0
appVersion: "1.0.0"
@@ -0,0 +1,9 @@
{{- if and .Values.username .Values.password }}
apiVersion: v1
kind: Secret
metadata:
name: {{ .Values.secretName }}
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: {{ printf `{"auths":{"%s":{"username":"%s","password":"%s","auth":"%s"}}}` .Values.registry .Values.username .Values.password (printf "%s:%s" .Values.username .Values.password | b64enc) | b64enc }}
{{- end }}
@@ -0,0 +1,9 @@
# Identifiants Docker Hub (compte dédié, droits de lecture seule suffisent —
# pas besoin d'un compte payant, le seul but est de doubler le rate-limit de
# pull anonyme : 100/6h par IP -> 200/6h authentifié). Jamais codés en dur,
# injectés par le control-plane (voir OMNEX_DOCKERHUB_USERNAME/PASSWORD dans
# internal/config/config.go) au moment de l'installation.
secretName: dockerhub-pull-secret
registry: https://index.docker.io/v1/
username: ""
password: ""
@@ -9,5 +9,14 @@ spec:
chain:
middlewares:
- name: rate-limit
- name: coraza-waf
# coraza-waf désactivé temporairement (2026-08-17) : fait planter
# Traefik (panic Go "unsafe.Slice: len out of range" dans le runtime
# WASM du plugin coraza-http-wasm-traefik v0.3.0) dès qu'une vraie
# IngressRoute de démo l'active — reproductible à 100% au chargement,
# crash-loop du pod partagé, bloque TOUTES les démos. Jamais exercé
# avant la toute première démo réelle. À investiguer (version du
# plugin ? directives ModSecurity trop volumineuses/complexes pour ce
# runtime WASM ?) avant réactivation — voir middleware-waf.yaml pour
# les directives complètes.
# - name: coraza-waf
- name: security-headers
+20
View File
@@ -27,6 +27,17 @@ rateLimit:
# https://github.com/traefik/traefik-helm-chart
# =============================================================
traefik:
# Injecté par le provisioner (voir deploy/chart-gestion/registry-credentials,
# installé dans le namespace traefik par EnsureSharedInfra) si un compte
# Docker Hub authentifié est configuré — évite le rate-limit de pull
# anonyme. Vide = pull anonyme (défaut). Sous "deployment", pas à la racine
# : c'est l'emplacement attendu par values.schema.json du chart officiel
# traefik/traefik (voir charts/traefik-41.1.0.tgz) — un imagePullSecrets à
# la racine est rejeté ("Additional property imagePullSecrets is not
# allowed").
deployment:
imagePullSecrets: []
# Expose les ports 80 et 443. Le port web redirige automatiquement vers
# websecure au niveau de l'entrypoint (pas besoin d'IngressRoute dédiée).
# Schéma valable pour le chart traefik/traefik >= 34 (vendored : 41.1.0,
@@ -96,8 +107,17 @@ traefik:
# prix de perdre l'IP réelle du client (SNAT par kube-proxy) — le
# rate-limiter/logs verront l'IP du nœud plutôt que celle du client tant
# qu'il n'y a pas de LoadBalancer (MetalLB) devant.
#
# type: ClusterIP — Traefik n'est plus exposé directement à internet
# depuis l'ajout du WAF ModSecurity natif devant lui (voir
# deploy/chart-gestion/waf-cluster, remplace coraza-http-wasm-traefik :
# bug mémoire non résolu upstream, jcchavezs/coraza-http-wasm-traefik#9).
# Le WAF termine le TLS public et rouvre une connexion vers l'entryPoint
# websecure (port 8443) de ce Service, désormais interne au cluster —
# aucun changement requis côté IngressRoute/entryPoints.
service:
spec:
type: ClusterIP
externalTrafficPolicy: Cluster
# Métriques Prometheus
@@ -0,0 +1,6 @@
apiVersion: v2
name: gestion-waf-cluster
description: WAF ModSecurity/OWASP CRS natif (nginx, pas WASM) devant le Traefik partagé — remplace le plugin coraza-http-wasm-traefik (bug mémoire non résolu upstream, voir jcchavezs/coraza-http-wasm-traefik#9 et traefik/traefik#11436). Même image/pattern que docker/waf (omnex-prod).
type: application
version: 0.1.0
appVersion: "1.0.0"
@@ -0,0 +1,16 @@
{{- define "waf-cluster.fullname" -}}
{{ .Release.Name }}
{{- end -}}
{{- define "waf-cluster.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: Helm
{{- end -}}
{{- define "waf-cluster.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
@@ -0,0 +1,147 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "waf-cluster.fullname" . }}-conf
namespace: {{ .Release.Namespace }}
labels:
{{- include "waf-cluster.labels" . | nindent 4 }}
data:
app.conf: |
server {
listen 80;
listen [::]:80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name _;
server_tokens off;
ssl_certificate /etc/nginx/certs/tls.crt;
ssl_certificate_key /etc/nginx/certs/tls.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
client_max_body_size 10M;
client_body_buffer_size 128k;
client_header_buffer_size 1k;
large_client_header_buffers 4 8k;
client_body_timeout 30s;
client_header_timeout 30s;
send_timeout 30s;
keepalive_timeout 65s;
access_log /dev/stdout;
error_log /dev/stderr warn;
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/custom-rules.conf;
# Terminaison TLS ici, puis nouvelle connexion TLS vers l'entryPoint
# websecure du Traefik partagé (deploy/chart-gestion/traefik) : aucun
# changement requis côté Traefik/IngressRoute, il continue de router
# par Host comme si le client s'y connectait directement. Host
# préservé pour que le routage par Host de Traefik fonctionne.
location / {
proxy_pass https://{{ .Values.upstream.host }}:{{ .Values.upstream.port }};
proxy_ssl_server_name on;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
location = /healthz {
access_log off;
return 200 "ok";
add_header Content-Type text/plain;
}
}
custom-rules.conf: |
# Règles maison — le reste (SecRuleEngine, Include CRS, seuils de
# paranoïa) est déjà configuré par l'entrypoint de l'image
# owasp/modsecurity-crs à partir des variables d'env PARANOIA/
# ANOMALY_INBOUND/ANOMALY_OUTBOUND (voir values.yaml waf.*), même
# mécanisme que docker/waf (omnex-prod).
SecAuditLogParts ABIFHZ
SecRule IP:BANNED "@eq 1" \
"id:100000,phase:1,deny,status:403,log,\
msg:'IP is banned'"
SecRule IP:REPUTATION_SCORE "@ge 100" \
"id:100099,phase:1,deny,status:403,log,\
msg:'Critical reputation score',\
setvar:'ip.blocked=1',expirevar:'ip.blocked=86400'"
SecRule TX:SQL_INJECTION_SCORE "@ge 5" \
"id:100001,phase:2,deny,status:403,log,\
msg:'SQL Injection detected',\
setvar:'ip.banned=1',expirevar:'ip.banned=172800'"
SecRule TX:XSS_SCORE "@ge 5" \
"id:100010,phase:2,deny,status:403,log,\
msg:'XSS detected',\
setvar:'ip.banned=1',expirevar:'ip.banned=172800'"
SecRule TX:RCE_SCORE "@ge 5" \
"id:100020,phase:2,deny,status:403,log,\
msg:'RCE detected',\
setvar:'ip.banned=1',expirevar:'ip.banned=259200'"
SecRule TX:LFI_SCORE "@ge 5" \
"id:100030,phase:2,deny,status:403,log,\
msg:'LFI detected',\
setvar:'ip.banned=1',expirevar:'ip.banned=172800'"
SecRule TX:INBOUND_ANOMALY_SCORE "@ge 20" \
"id:100060,phase:2,deny,status:403,log,\
msg:'Critical anomaly score',\
setvar:'ip.banned=1',expirevar:'ip.banned=172800'"
# Suppressions CRS pour les routes légitimes de l'app démo (mêmes IDs
# que l'ancienne config coraza-http-wasm, voir
# deploy/chart-gestion/traefik/templates/middleware-waf.yaml — conservé
# à titre d'historique, plus utilisé, coraza-waf retiré de la chaîne).
SecRule REQUEST_URI "@streq /api/v2/admin/protected/products" \
"id:399001,phase:2,nolog,pass,ctl:ruleRemoveById=932235"
SecRule REQUEST_URI "@streq /api/v2/admin/protected/products" \
"id:399002,phase:2,nolog,pass,ctl:ruleRemoveById=920120,ctl:ruleRemoveById=920121"
SecRule REQUEST_URI "@streq /api/v1/panier/remove" \
"id:399010,phase:1,nolog,pass,ctl:ruleRemoveById=911100,ctl:ruleRemoveById=920350"
SecRule REQUEST_URI "@streq /api/v1/panier/clear" \
"id:399011,phase:1,nolog,pass,ctl:ruleRemoveById=911100,ctl:ruleRemoveById=920350"
# Fichiers uploadés — WAF désactivé (lecture seule, contenu déjà validé
# à l'upload).
SecRule REQUEST_URI "@beginsWith /uploads/" \
"id:1000,phase:1,pass,nolog,ctl:ruleEngine=Off"
# Rate limit basique par IP (indépendant du RateLimit du control-plane
# sur /auth/login, et du middleware rate-limit de Traefik en aval).
SecAction \
"id:400161,phase:1,nolog,pass,\
setvar:'ip.request_window_1sec=+1',\
expirevar:'ip.request_window_1sec=1'"
SecRule IP:REQUEST_WINDOW_1SEC "@gt 20" \
"id:400160,phase:1,deny,status:429,log,\
msg:'Too many requests'"
@@ -0,0 +1,105 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "waf-cluster.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "waf-cluster.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "waf-cluster.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "waf-cluster.selectorLabels" . | nindent 8 }}
annotations:
# Force un rollout des pods quand la ConfigMap (nginx.conf,
# custom-rules.conf) change — sinon nginx garde l'ancienne config en
# mémoire jusqu'au prochain redémarrage manuel (les changements de
# ConfigMap seule ne déclenchent pas de rollout par défaut).
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
# Pas de readOnlyRootFilesystem ici : l'image owasp/modsecurity-crs
# écrit à plusieurs endroits internes à /etc/nginx/modsec (activation
# des plugins CRS, génération de la config paranoia/anomaly) au
# démarrage — voir les logs "Running CRS plugin activation" /
# "Running CRS rule configuration". Même posture que docker/waf
# (omnex-prod), qui tourne avec cette même image sans souci. À
# durcir plus tard une fois le mapping exact des chemins écrits
# identifié (cf. la mésaventure readOnlyRootFilesystem sur le
# frontend, deploy/chart-gestion/frontend).
securityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: waf
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
env:
- name: DISABLE_MODSEC_ENV_SUBST
value: "true"
- name: PARANOIA
value: "{{ .Values.waf.paranoia }}"
- name: ANOMALY_INBOUND
value: "{{ .Values.waf.anomalyInbound }}"
- name: ANOMALY_OUTBOUND
value: "{{ .Values.waf.anomalyOutbound }}"
- name: MODSEC_AUDIT_LOG
value: "/var/log/modsec/modsec_audit.log"
ports:
- name: http
containerPort: 80
protocol: TCP
- name: https
containerPort: 443
protocol: TCP
volumeMounts:
- name: conf
mountPath: /etc/nginx/conf.d/app.conf
subPath: app.conf
- name: conf
mountPath: /etc/nginx/modsec/custom-rules.conf
subPath: custom-rules.conf
- name: certs
mountPath: /etc/nginx/certs
readOnly: true
- name: modsec-log
mountPath: /var/log/modsec
livenessProbe:
httpGet:
path: /healthz
port: https
scheme: HTTPS
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /healthz
port: https
scheme: HTTPS
initialDelaySeconds: 5
periodSeconds: 10
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: conf
configMap:
name: {{ include "waf-cluster.fullname" . }}-conf
- name: certs
secret:
secretName: {{ .Values.tls.secretName }}
- name: modsec-log
emptyDir: {}
@@ -0,0 +1,24 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "waf-cluster.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "waf-cluster.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
# Cluster (pas Local) : même raison que Traefik (voir
# deploy/chart-gestion/traefik/values.yaml) — bare-metal multi-nœuds, tout
# nœud contacté doit pouvoir router vers le pod WAF où qu'il tourne.
externalTrafficPolicy: Cluster
selector:
{{- include "waf-cluster.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
- name: https
port: 443
targetPort: https
protocol: TCP
@@ -0,0 +1,45 @@
# WAF partagé (ModSecurity natif, nginx) devant le Traefik partagé — un seul
# déploiement pour toutes les démos, comme deploy/chart-gestion/traefik.
# Doit être installé dans le MÊME namespace que le Traefik partagé (accès au
# Secret TLS wildcard, pas de partage de Secret cross-namespace en k8s).
replicaCount: 2
image:
repository: owasp/modsecurity-crs
tag: nginx-alpine
pullPolicy: IfNotPresent
imagePullSecrets: []
# Certificat wildcard émis par cert-manager (voir deploy/cert-manager/), même
# Secret que consomme le TLSStore de Traefik (templates/tlsstore.yaml).
tls:
secretName: wildcard-demo-tls
# Service + port internes du Traefik partagé (voir deploy/chart-gestion/traefik) :
# ce WAF termine le TLS public puis rouvre une connexion TLS interne vers
# l'entryPoint websecure de Traefik (aucune modification requise côté
# IngressRoute/entryPoints — Traefik ne voit aucune différence).
upstream:
host: gestion-traefik.traefik.svc.cluster.local
# Port du Service Kubernetes "gestion-traefik" (websecure), pas le port
# interne du conteneur (8443, le Service l'abstrait derrière son propre
# port 443 — voir kubectl -n traefik get svc gestion-traefik).
port: 443
waf:
paranoia: 2
anomalyInbound: 5
anomalyOutbound: 4
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
service:
type: LoadBalancer
+197
View File
@@ -0,0 +1,197 @@
# RBAC du control-plane (worker Go qui provisionne les démos) — moindre
# privilège : ce ServiceAccount N'A PAS accès aux Secrets/Deployments/etc.
# des autres namespaces (cert-manager, longhorn-system, kube-system...).
#
# Principe :
# - omnex-namespace-manager (ClusterRole) : gérer les objets Namespace eux-
# mêmes. Cluster-scoped par nature (RBAC ne permet pas de restreindre par
# préfixe de nom "demo-*"/"premium-*").
# - omnex-demo-workload-manager (ClusterRole) : tout ce dont les charts
# Helm ont besoin (Deployment, StatefulSet, Secret, ConfigMap, PVC,
# NetworkPolicy, ResourceQuota, LimitRange, HPA, les CRD Traefik). JAMAIS
# liée cluster-wide — seulement via un RoleBinding créé par le code Go
# lui-même dans CHAQUE namespace qu'il crée (voir
# control-plane/api/internal/demos/helm_provisioner.go:createNamespace/
# ensureWorkloadRoleBinding). C'est ce qui borne l'accès à un seul
# namespace à la fois.
# - omnex-rbac-binder (ClusterRole) : permet au ServiceAccount de créer ce
# RoleBinding lui-même, dans n'importe quel namespace (obligatoire :
# les namespaces de démo n'existent pas encore au moment où ce fichier
# est appliqué). Verbe "bind" scopé par resourceName à
# omnex-demo-workload-manager UNIQUEMENT — mécanisme RBAC standard
# anti-escalade : posséder "bind" sur une ClusterRole précise autorise à
# la référencer dans un RoleBinding sans posséder soi-même les droits
# qu'elle contient. Le ServiceAccount ne peut donc lier AUCUNE autre
# ClusterRole (ex: cluster-admin) à lui-même.
apiVersion: v1
kind: Namespace
metadata:
name: omnex-system
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: omnex-control-plane
namespace: omnex-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: omnex-demo-workload-manager
rules:
- apiGroups: [""]
resources:
- configmaps
- secrets
- services
- persistentvolumeclaims
- resourcequotas
- limitranges
# Le subchart officiel traefik/traefik crée son propre ServiceAccount
# (nom = release Helm) — pas utilisé par nos propres charts
# (backend/frontend/postgresql/redis/lbtelegram), mais nécessaire pour
# que "helm install traefik" passe.
- serviceaccounts
verbs: [get, list, watch, create, update, patch, delete]
- apiGroups: [""]
resources: [pods]
verbs: [get, list, watch]
- apiGroups: [""]
# Exec dans le pod postgres pour pg_dump/restore lors d'un passage en
# premium (voir HelmProvisioner.execInPod / migratePostgresData).
resources: [pods/exec]
verbs: [create]
- apiGroups: ["metrics.k8s.io"]
# Usage CPU/mémoire live affiché dans le back-office (voir
# HelmProvisioner.GetResourceState) — best-effort côté Go (montre juste
# la phase du pod si absent), mais sans ce droit c'est TOUJOURS absent
# (Forbidden, pas juste "metrics-server indisponible").
resources: [pods]
verbs: [get, list]
- apiGroups: ["apps"]
resources: [deployments, statefulsets]
verbs: [get, list, watch, create, update, patch, delete]
- apiGroups: ["apps"]
# Lecture seule : "helm --wait" liste les ReplicaSet d'un Deployment pour
# vérifier l'état du rollout (jamais créés/gérés directement par nous).
resources: [replicasets]
verbs: [get, list, watch]
- apiGroups: ["autoscaling"]
resources: [horizontalpodautoscalers]
verbs: [get, list, watch, create, update, patch, delete]
- apiGroups: ["networking.k8s.io"]
resources: [networkpolicies]
verbs: [get, list, watch, create, update, patch, delete]
- apiGroups: ["traefik.io"]
resources: [ingressroutes, middlewares, tlsstores]
verbs: [get, list, watch, create, update, patch, delete]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: omnex-namespace-manager
rules:
- apiGroups: [""]
resources: [namespaces]
verbs: [get, list, watch, create, delete]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: omnex-rbac-binder
rules:
- apiGroups: ["rbac.authorization.k8s.io"]
resources: [rolebindings]
verbs: [get, list, create]
- apiGroups: ["rbac.authorization.k8s.io"]
resources: [clusterroles]
resourceNames: [omnex-demo-workload-manager]
verbs: [bind]
# Lecture seule, cluster-wide : nécessaire pour que "helm upgrade --install"
# du chart traefik partagé (voir EnsureSharedInfra) puisse vérifier l'état
# du ClusterRole/ClusterRoleBinding que le subchart officiel traefik/traefik
# crée pour SA PROPRE identité ("gestion-traefik-traefik", pas la nôtre).
# Ne permet ni de créer ni de modifier quoi que ce soit — seulement de
# constater qu'ils existent déjà (installés une fois par un opérateur admin,
# voir le bootstrap manuel documenté dans le plan de déploiement).
- apiGroups: ["rbac.authorization.k8s.io"]
resources: [clusterroles, clusterrolebindings]
verbs: [get, list, watch]
# Même besoin : le subchart officiel traefik/traefik crée aussi son propre
# IngressClass (ressource cluster-scoped), déjà installée une fois par un
# opérateur admin — lecture seule ici aussi.
- apiGroups: ["networking.k8s.io"]
resources: [ingressclasses]
verbs: [get, list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: omnex-control-plane-namespaces
subjects:
- kind: ServiceAccount
name: omnex-control-plane
namespace: omnex-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: omnex-namespace-manager
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: omnex-control-plane-rbac-binder
subjects:
- kind: ServiceAccount
name: omnex-control-plane
namespace: omnex-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: omnex-rbac-binder
---
# Token longue durée : le control-plane tourne en docker-compose (hors du
# cluster) avec un kubeconfig statique monté en fichier, pas de rotation
# automatique de type projected-token. Voir ansible/playbook-deploy-site.yml.
apiVersion: v1
kind: Secret
metadata:
name: omnex-control-plane-token
namespace: omnex-system
annotations:
kubernetes.io/service-account.name: omnex-control-plane
type: kubernetes.io/service-account-token
---
# Role namespacé (pas de RoleBinding auto-créé ici : "velero" est un
# namespace fixe préexistant, pas un namespace de démo créé dynamiquement
# par le control-plane — pas besoin du mécanisme "bind" utilisé pour
# omnex-demo-workload-manager). Portée strictement limitée aux Schedule/
# Backup Velero — aucun accès aux Secrets (dont cloud-credentials, la clé
# du bucket S3) ni au reste du namespace velero.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: omnex-control-plane
namespace: velero
rules:
- apiGroups: ["velero.io"]
# Un Schedule + les Backup qu'il crée par démo (voir
# HelmProvisioner.ensureDemoBackupSchedule/deleteDemoBackups) — jamais
# de wildcard multi-namespace, pour pouvoir supprimer les sauvegardes
# d'UNE démo sans toucher aux autres.
resources: [schedules, backups]
verbs: [get, list, watch, create, delete]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: omnex-control-plane
namespace: velero
subjects:
- kind: ServiceAccount
name: omnex-control-plane
namespace: omnex-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: omnex-control-plane
+23 -35
View File
@@ -1,36 +1,24 @@
# Sauvegarde quotidienne de tous les namespaces de démo (postgres, redis,
# uploads, et le reste : Secrets/ConfigMaps/Deployments pour une
# restauration complète) vers le bucket S3 configuré dans values.yaml.
# S'applique par défaut à TOUT namespace non exclu ci-dessous — une nouvelle
# démo créée dynamiquement (demo-xxxxx, premium-xxxxx) est donc couverte
# automatiquement, sans modifier ce fichier.
# Il n'y a plus de Schedule Velero statique/global ici (l'ancienne version de
# ce fichier en définissait deux : demos-daily et demos-frequent, couvrant
# TOUS les namespaces en un seul objet Backup par exécution).
#
# À appliquer après l'installation de Velero (voir values.yaml) :
# kubectl apply -f deploy/velero/schedule.yml
apiVersion: velero.io/v1
kind: Schedule
metadata:
name: demos-daily
namespace: velero
spec:
# 03h00 UTC, tous les jours — heure creuse pour ce cluster.
schedule: "0 3 * * *"
template:
includedNamespaces:
- "*"
excludedNamespaces:
- kube-system
- kube-public
- kube-node-lease
- default
- velero
- traefik
- longhorn-system
- cert-manager
# Sauvegarde le contenu réel des PVC (postgres/redis/uploads) via
# File System Backup — voir values.yaml.
defaultVolumesToFsBackup: true
storageLocation: default
# Conservation 30 jours : au-delà, la sauvegarde devient éligible à la
# purge automatique (garbage collection Velero).
ttl: 720h0m0s
# Pourquoi ce changement : Velero ne permet de supprimer qu'un objet Backup
# ENTIER, jamais une portion (ex: "juste les données de la démo X"). Avec un
# planning global multi-namespace, détruire une démo n'aurait donc jamais pu
# supprimer ses sauvegardes sans supprimer aussi celles des autres démos
# présentes dans le même Backup.
#
# À la place, le control-plane crée maintenant un Schedule Velero DÉDIÉ par
# démo (voir HelmProvisioner.ensureDemoBackupSchedule dans
# control-plane/api/internal/demos/helm_provisioner.go) :
# - à la création de la démo (Provision) et lors d'un passage en premium
# (MigrateToPremiumNamespace, un nouveau namespace = un nouveau planning) ;
# - toutes les 15 min, conservées 24h (fenêtre de perte de données
# minimale en cas de problème sur le cluster) ;
# - supprimé avec ses Backup déjà pris (HelmProvisioner.deleteDemoBackups)
# quand la démo est détruite — expiration du TTL ou destruction forcée
# par un admin, les deux passent par Service.Delete → Teardown.
#
# Rien à appliquer manuellement ici : ces Schedule sont gérés entièrement
# par le control-plane, dans le namespace "velero", nommés
# "demo-backup-<namespace>".
+11 -8
View File
@@ -25,7 +25,8 @@
# helm install velero vmware-tanzu/velero \
# --namespace velero --version 12.1.0 \
# -f deploy/velero/values.yaml
# kubectl apply -f deploy/velero/schedule.yml
# Rien d'autre à appliquer : les plannings de sauvegarde sont créés
# dynamiquement par démo par le control-plane, voir deploy/velero/schedule.yml.
#
# 5. Sécurité du bucket — à vérifier CÔTÉ RustFS/MinIO (rien de tout ça ne
# se configure depuis ce repo) : chaque sauvegarde contient les Secrets
@@ -68,21 +69,23 @@ configuration:
- name: default
provider: aws
# Nom du bucket créé à l'étape 1. Requis.
bucket: "CHANGE_ME_bucket_name"
bucket: "backup-omnex"
default: true
credential:
name: cloud-credentials
key: cloud
config:
region: "us-east-1"
s3Url: "CHANGE_ME_https://votre-endpoint-rustfs-ou-minio"
s3Url: "https://backup-omnex.uber-stup.club"
s3ForcePathStyle: "true"
insecureSkipTLSVerify: "false"
# Chiffrement côté serveur du contenu des sauvegardes (SSE-S3,
# AES256 géré par le bucket) — RustFS et MinIO le supportent tous
# les deux. Ne dispense pas de vérifier le chiffrement at-rest réel
# du stockage sous-jacent (voir étape 5 ci-dessus).
serverSideEncryption: "AES256"
# Pas de serverSideEncryption ici : ce RustFS refuse le SSE-S3 tant
# que RUSTFS_SSE_S3_MASTER_KEY (clé base64 32 octets) n'est pas
# configurée côté serveur ("InvalidRequest: SSE-S3 requires
# RUSTFS_SSE_S3_MASTER_KEY..."), réglage hors de portée depuis ce
# repo. Le transport reste chiffré (HTTPS) ; pour du chiffrement
# at-rest, configurer cette variable côté serveur RustFS puis
# remettre serverSideEncryption: "AES256" ici.
volumeSnapshotLocation: []