chore: build
ci-api / test (push) Successful in 23m32s
ci-web / test (push) Successful in 14m7s

This commit is contained in:
Xor290
2026-09-20 18:52:09 +02:00
parent 9e11f01c2c
commit 8d857faa45
66 changed files with 3673 additions and 1564 deletions
@@ -1451,6 +1451,28 @@ func componentKey(podName string) string {
}
}
// podUsage : usage CPU (millicores) et mémoire (Mi) de chaque pod du namespace,
// par nom de pod. Best-effort : sans metrics-server la map est vide.
func (h *HelmProvisioner) podUsage(ctx context.Context, namespace string) map[string][2]int64 {
usage := map[string][2]int64{} // pod name -> [cpuMilli, memMi]
if h.metricsClient == nil {
return usage
}
list, err := h.metricsClient.MetricsV1beta1().PodMetricses(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
return usage
}
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}
}
return usage
}
// 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.
@@ -1467,19 +1489,7 @@ func (h *HelmProvisioner) GetResourceState(
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}
}
}
}
usage := h.podUsage(ctx, namespace)
var state ResourceState
@@ -1,9 +1,13 @@
package demos
import (
"context"
"fmt"
"log"
"strconv"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// Projets vitrine (voir internal/projects) : même infrastructure que les
@@ -121,6 +125,70 @@ func (h *HelmProvisioner) TeardownProject(namespace string) error {
return h.Teardown(Demo{Namespace: namespace})
}
// Valeur du label app.kubernetes.io/name des pods de chaque composant d'un
// projet vitrine (voir deploy/chart-vitrine/*/templates). Les pods des Jobs de
// migration et de création du compte admin portent d'autres noms
// (vitrine-backend-migrate / vitrine-backend-seed) : ils ne sont donc jamais
// pris pour le backend.
const (
vitrineLabelBackend = "vitrine-backend"
vitrineLabelFrontend = "vitrine-frontend"
vitrineLabelPostgreSQL = "postgresql"
vitrineLabelRedis = "redis"
)
// ProjectState : phase + usage CPU/mémoire live de chaque composant d'un projet
// vitrine, dans le même format que GetResourceState pour les démos (le panneau
// du back-office est commun). L'usage (metrics-server) est best-effort. Les
// limites viennent du pod lui-même : elles suivent les values des charts sans
// table à maintenir ici.
func (h *HelmProvisioner) ProjectState(ctx context.Context, namespace string) (ResourceState, error) {
pods, err := h.k8sClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
return ResourceState{}, err
}
return buildProjectState(pods.Items, h.podUsage(ctx, namespace)), nil
}
// buildProjectState range les pods d'un projet par composant (voir
// ProjectState). usage : nom de pod -> [cpuMilli, memMi].
func buildProjectState(pods []corev1.Pod, usage map[string][2]int64) ResourceState {
var state ResourceState
for i := range pods {
pod := &pods[i]
var target *ComponentState
switch pod.Labels["app.kubernetes.io/name"] {
case vitrineLabelBackend:
target = &state.API
case vitrineLabelFrontend:
target = &state.Web
case vitrineLabelPostgreSQL:
target = &state.DB
case vitrineLabelRedis:
target = &state.DBM
default:
continue
}
cs := ComponentState{Phase: string(pod.Status.Phase)}
for _, c := range pod.Spec.Containers {
cs.CPULimitMilli += c.Resources.Limits.Cpu().MilliValue()
cs.MemoryLimitMi += c.Resources.Limits.Memory().Value() / (1024 * 1024)
}
if u, ok := usage[pod.Name]; ok {
cs.CPUMilli, cs.MemoryMi = u[0], u[1]
}
// Pendant un rolling update deux pods coexistent : celui qui tourne
// prime sur l'ancien en cours d'arrêt.
if target.Phase == string(corev1.PodRunning) && cs.Phase != string(corev1.PodRunning) {
continue
}
*target = cs
}
return state
}
func (h *HelmProvisioner) buildVitrinePostgresValues(password string) map[string]interface{} {
// Nom de base et utilisateur : défauts du chart (vitrine_db / postgres),
// alignés sur database.name / database.user du chart backend.
@@ -8,6 +8,9 @@ import (
"testing"
"go.yaml.in/yaml/v2"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/omnex/control-plane/api/internal/config"
)
@@ -88,3 +91,72 @@ func TestVitrineValuesRenderWithCharts(t *testing.T) {
}
func writeFile(path string, data []byte) error { return os.WriteFile(path, data, 0o600) }
func vitrinePod(name, component string, phase corev1.PodPhase, cpuLimit, memLimit string) corev1.Pod {
return corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: name, Labels: map[string]string{"app.kubernetes.io/name": component}},
Spec: corev1.PodSpec{Containers: []corev1.Container{{
Resources: corev1.ResourceRequirements{Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse(cpuLimit),
corev1.ResourceMemory: resource.MustParse(memLimit),
}},
}}},
Status: corev1.PodStatus{Phase: phase},
}
}
func TestBuildProjectState_MapsComponentsByLabelWithPodLimits(t *testing.T) {
pods := []corev1.Pod{
vitrinePod("postgres-0", "postgresql", corev1.PodRunning, "1", "1Gi"),
vitrinePod("redis-0", "redis", corev1.PodRunning, "500m", "512Mi"),
vitrinePod("vitrine-ab12-backend-vitrine-backend-7d5-x", "vitrine-backend", corev1.PodRunning, "500m", "256Mi"),
vitrinePod("vitrine-ab12-frontend-vitrine-frontend-9c-y", "vitrine-frontend", corev1.PodPending, "200m", "128Mi"),
}
usage := map[string][2]int64{"vitrine-ab12-backend-vitrine-backend-7d5-x": {42, 100}}
st := buildProjectState(pods, usage)
if st.DB.Phase != "Running" || st.DB.CPULimitMilli != 1000 || st.DB.MemoryLimitMi != 1024 {
t.Errorf("postgres (pod \"postgres-0\") mal reconnu : %+v", st.DB)
}
if st.DBM.Phase != "Running" || st.DBM.MemoryLimitMi != 512 {
t.Errorf("redis : %+v", st.DBM)
}
if st.API != (ComponentState{Phase: "Running", CPUMilli: 42, CPULimitMilli: 500, MemoryMi: 100, MemoryLimitMi: 256}) {
t.Errorf("backend : %+v", st.API)
}
if st.Web.Phase != "Pending" {
t.Errorf("frontend : %+v", st.Web)
}
if st.LB.Phase != "" {
t.Errorf("le load-balancer Telegram n'existe pas pour un projet : %+v", st.LB)
}
}
func TestBuildProjectState_IgnoresJobPodsAndOldReplicas(t *testing.T) {
pods := []corev1.Pod{
vitrinePod("x-backend-vitrine-backend-7d5-run", "vitrine-backend", corev1.PodRunning, "500m", "256Mi"),
// Les pods des Jobs contiennent "backend" dans leur nom mais ne sont pas le backend.
vitrinePod("x-backend-vitrine-backend-seed-abc", "vitrine-backend-seed", corev1.PodSucceeded, "500m", "256Mi"),
vitrinePod("x-backend-vitrine-backend-migrate-abc", "vitrine-backend-migrate", corev1.PodFailed, "500m", "256Mi"),
// Ancien replica en cours d'arrêt, listé après le nouveau.
vitrinePod("x-backend-vitrine-backend-old-zzz", "vitrine-backend", corev1.PodFailed, "500m", "256Mi"),
// Pod étranger au projet (sans label du chart).
{ObjectMeta: metav1.ObjectMeta{Name: "debug-shell"}},
}
st := buildProjectState(pods, nil)
if st.API.Phase != "Running" {
t.Errorf("le backend doit rester Running : %+v", st.API)
}
if st.Web.Phase != "" || st.DB.Phase != "" || st.DBM.Phase != "" {
t.Errorf("composants absents non vides : %+v", st)
}
}
func TestBuildProjectState_NoPodsIsEmpty(t *testing.T) {
if st := buildProjectState(nil, nil); st != (ResourceState{}) {
t.Errorf("état non vide : %+v", st)
}
}
@@ -71,6 +71,22 @@ func (h *Handler) List(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": items})
}
// Details : GET /projects/:id/details — état live des pods du projet, même
// format que POST /demos/details (le panneau du back-office est commun).
// Le namespace vient de la base, jamais de la requête.
func (h *Handler) Details(c *gin.Context) {
p, state, err := h.svc.State(c.Request.Context(), c.Param("id"))
if err != nil {
if errors.Is(err, ErrNotFound) {
h.writeError(c, err)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "impossible de récupérer l'état des ressources"})
return
}
c.JSON(http.StatusOK, gin.H{"namespace": p.Namespace, "state": state})
}
// Get : GET /projects/:id.
func (h *Handler) Get(c *gin.Context) {
p, err := h.svc.Get(c.Param("id"))
@@ -1,12 +1,16 @@
package projects
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/omnex/control-plane/api/internal/demos"
)
func newTestRouter(t *testing.T) (*gin.Engine, *fakeProv) {
@@ -20,6 +24,7 @@ func newTestRouter(t *testing.T) (*gin.Engine, *fakeProv) {
r.GET("/projects/:id", h.Get)
r.DELETE("/projects/:id", h.Delete)
r.POST("/projects/:id/extend", h.Extend)
r.GET("/projects/:id/details", h.Details)
return r, prov
}
@@ -91,3 +96,42 @@ func TestHandlerExtend_BoundsAndNotFound(t *testing.T) {
t.Errorf("sans durée : HTTP %d, attendu 400", w.Code)
}
}
func TestHandlerDetails(t *testing.T) {
r, prov := newTestRouter(t)
prov.state = demos.ResourceState{API: demos.ComponentState{Phase: "Running"}}
if w := do(r, "GET", "/projects/inconnu/details", ""); w.Code != http.StatusNotFound {
t.Errorf("projet inconnu : %d, attendu 404", w.Code)
}
w := do(r, "POST", "/projects", body("1"))
if w.Code != http.StatusAccepted {
t.Fatalf("Create: %d %s", w.Code, w.Body)
}
var created Project
if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
<-prov.provisioned
w = do(r, "GET", "/projects/"+created.ID+"/details", "")
if w.Code != http.StatusOK {
t.Fatalf("Details: %d %s", w.Code, w.Body)
}
var got struct {
Namespace string `json:"namespace"`
State demos.ResourceState `json:"state"`
}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Namespace != created.Namespace || got.State.API.Phase != "Running" {
t.Errorf("réponse inattendue : %+v", got)
}
prov.stateErr = errors.New("api k8s indisponible")
if w := do(r, "GET", "/projects/"+created.ID+"/details", ""); w.Code != http.StatusInternalServerError || strings.Contains(w.Body.String(), "k8s") {
t.Errorf("erreur cluster : %d %s (500 sans détail interne attendu)", w.Code, w.Body)
}
}
@@ -31,6 +31,7 @@ var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9]{3,64}$`)
type Provisioner interface {
ProvisionProject(spec demos.ProjectSpec) error
TeardownProject(namespace string) error
ProjectState(ctx context.Context, namespace string) (demos.ResourceState, error)
}
// NoopProvisioner : aucun déploiement réel (dev en mémoire, tests).
@@ -38,6 +39,9 @@ type NoopProvisioner struct{}
func (NoopProvisioner) ProvisionProject(demos.ProjectSpec) error { return nil }
func (NoopProvisioner) TeardownProject(string) error { return nil }
func (NoopProvisioner) ProjectState(context.Context, string) (demos.ResourceState, error) {
return demos.ResourceState{}, nil
}
// Config du service (injectable pour les tests).
type Config struct {
@@ -176,6 +180,24 @@ func (s *Service) Get(id string) (Project, error) {
func (s *Service) List() ([]Project, error) { return s.store.List() }
// State : état live des composants du projet (phase + CPU/mémoire). Un projet
// qui n'a pas (ou plus) de pods (échec, suppression) renvoie un état vide sans
// interroger le cluster.
func (s *Service) State(ctx context.Context, id string) (Project, demos.ResourceState, error) {
p, ok := s.store.Get(id)
if !ok {
return Project{}, demos.ResourceState{}, ErrNotFound
}
if p.Status != StatusProvisioning && p.Status != StatusReady {
return p, demos.ResourceState{}, nil
}
state, err := s.prov.ProjectState(ctx, p.Namespace)
if err != nil {
return p, demos.ResourceState{}, err
}
return p, state, nil
}
// Extend ajoute months mois à l'abonnement, à partir de l'échéance courante
// (ou de maintenant si elle est dépassée).
func (s *Service) Extend(id string, months int) (Project, error) {
@@ -1,6 +1,7 @@
package projects
import (
"context"
"errors"
"sync"
"testing"
@@ -16,6 +17,9 @@ type fakeProv struct {
specs []demos.ProjectSpec
tornDown []string
provErr error
state demos.ResourceState
stateErr error
stateCalls []string
provisioned chan struct{}
}
@@ -35,6 +39,13 @@ func (f *fakeProv) TeardownProject(ns string) error {
return nil
}
func (f *fakeProv) ProjectState(_ context.Context, ns string) (demos.ResourceState, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.stateCalls = append(f.stateCalls, ns)
return f.state, f.stateErr
}
var t0 = time.Date(2026, time.September, 20, 12, 0, 0, 0, time.UTC)
func newTestService(t *testing.T) (*Service, *fakeProv, *time.Time) {
@@ -277,3 +288,48 @@ func TestDelete_IsIdempotentAndImmediate(t *testing.T) {
t.Errorf("erreur %v, attendu ErrNotFound", err)
}
}
func TestState_ReadyProjectQueriesItsOwnNamespace(t *testing.T) {
svc, prov, _ := newTestService(t)
prov.state = demos.ResourceState{API: demos.ComponentState{Phase: "Running", CPUMilli: 12}}
p := createReady(t, svc, prov, validInput())
got, state, err := svc.State(context.Background(), p.ID)
if err != nil {
t.Fatal(err)
}
if got.Namespace != p.Namespace || state.API.Phase != "Running" || state.API.CPUMilli != 12 {
t.Errorf("namespace=%q state=%+v", got.Namespace, state)
}
if len(prov.stateCalls) != 1 || prov.stateCalls[0] != p.Namespace {
t.Errorf("namespace interrogé=%v, attendu [%s]", prov.stateCalls, p.Namespace)
}
}
func TestState_NoClusterCallWithoutPods(t *testing.T) {
svc, prov, _ := newTestService(t)
p := createReady(t, svc, prov, validInput())
if _, err := svc.Delete(p.ID); err != nil {
t.Fatal(err)
}
_, state, err := svc.State(context.Background(), p.ID)
if err != nil || state != (demos.ResourceState{}) {
t.Errorf("err=%v state=%+v, attendu un état vide", err, state)
}
if len(prov.stateCalls) != 0 {
t.Errorf("cluster interrogé pour un projet supprimé : %v", prov.stateCalls)
}
}
func TestState_UnknownProjectAndClusterError(t *testing.T) {
svc, prov, _ := newTestService(t)
if _, _, err := svc.State(context.Background(), "inconnu"); !errors.Is(err, ErrNotFound) {
t.Errorf("erreur %v, attendu ErrNotFound", err)
}
p := createReady(t, svc, prov, validInput())
prov.stateErr = errors.New("api k8s indisponible")
if _, _, err := svc.State(context.Background(), p.ID); err == nil {
t.Error("erreur du cluster avalée")
}
}
@@ -117,6 +117,7 @@ func New(d Deps) *gin.Engine {
admin.POST("/projects", d.ProjectsH.Create)
admin.GET("/projects", d.ProjectsH.List)
admin.GET("/projects/:id", d.ProjectsH.Get)
admin.GET("/projects/:id/details", d.ProjectsH.Details)
admin.DELETE("/projects/:id", d.ProjectsH.Delete)
admin.POST("/projects/:id/extend", d.ProjectsH.Extend)
}
+58 -2
View File
@@ -58,7 +58,7 @@ spec:
ingress:
- from:
- ipBlock:
cidr: CHANGE_ME_apiserver_ip/32
cidr: 176.116.0.189/32
ports:
- protocol: TCP
port: 10250
@@ -97,7 +97,7 @@ spec:
egress:
- to:
- ipBlock:
cidr: CHANGE_ME_apiserver_ip/32
cidr: 176.116.0.189/32
ports:
- protocol: TCP
port: 6443
@@ -125,3 +125,59 @@ spec:
ports:
- protocol: TCP
port: 443
---
# Calico tourne en VXLAN : un appel de l'API server (sur le master) vers le
# webhook cert-manager, situé sur un autre nœud, sort avec l'IP du tunnel VXLAN
# du master comme source, pas avec son IP de nœud (seule autorisée par
# allow-ingress-apiserver-webhook). Sans cette règle le webhook est
# injoignable : toute création/renouvellement de Certificate, Issuer ou
# CertificateRequest échoue avec "failed calling webhook ... Client.Timeout".
# IP à mettre à jour si le nœud est recréé :
# kubectl get node master-k3s -o jsonpath='{.metadata.annotations.projectcalico\.org/IPv4VXLANTunnelAddr}'
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-apiserver-webhook-vxlan
namespace: cert-manager
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- ipBlock:
cidr: 192.168.207.64/32
ports:
- protocol: TCP
port: 10250
---
# Challenge DNS-01 : avant de demander la validation à Let's Encrypt,
# cert-manager vérifie que son enregistrement TXT est visible en interrogeant
# directement les serveurs de noms autoritaires de la zone (chez Cloudflare) en
# TCP/UDP 53 : allow-egress-internet-https ne laissait passer que le 443, d'où
# "Waiting for DNS-01 challenge propagation: dial tcp <ip>:53: i/o timeout".
# Même règle nécessaire au renouvellement de wildcard-demo-tls (omnex.sbs).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-dns-public
namespace: cert-manager
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
@@ -11,3 +11,12 @@ metadata:
spec:
defaultCertificate:
secretName: {{ .Values.tls.secretName }}
{{- with .Values.tls.extraSecretNames }}
# Certificats supplémentaires (SNI) : ex. le wildcard des projets vitrine.
# Traefik choisit le certificat d'après le nom d'hôte demandé ; le
# defaultCertificate ci-dessus reste celui des hôtes non couverts.
certificates:
{{- range . }}
- secretName: {{ . }}
{{- end }}
{{- end }}
+11
View File
@@ -11,6 +11,17 @@
# ce certificat.
tls:
secretName: wildcard-demo-tls
# Certificats supplémentaires servis par SNI (voir templates/tlsstore.yaml).
# NB : les clients ne voient PAS ce certificat — le TLS public est terminé par
# le WAF (deploy/chart-gestion/waf-cluster, `tls.extra`) ; celui-ci ne couvre
# que la connexion interne WAF -> Traefik.
# wildcard-vitrine-tls : *.vitrine-omnex.club, projets vitrine (voir
# deploy/chart-vitrine/cert-manager/wildcard-certificate.yml). Le Certificate
# doit exister AVANT le prochain démarrage du control-plane (qui refait un
# `helm upgrade --install` de ce chart) : sinon Traefik journalise une erreur
# pour ce secret manquant (les autres certificats restent servis).
extraSecretNames:
- wildcard-vitrine-tls
waf:
paranoia: 2
@@ -14,16 +14,24 @@ data:
return 301 https://$host$request_uri;
}
{{- /* Un bloc HTTPS par certificat : le premier (server_name _) est le défaut
pour les hôtes non reconnus ; chaque entrée de tls.extra ajoute un bloc
sélectionné par SNI, avec son propre certificat. */}}
{{- $servers := list (dict "name" "_" "dir" "/etc/nginx/certs") }}
{{- range $i, $e := .Values.tls.extra }}
{{- $servers = append $servers (dict "name" $e.serverName "dir" (printf "/etc/nginx/certs-extra-%d" $i)) }}
{{- end }}
{{- range $servers }}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name _;
server_name {{ .name }};
server_tokens off;
ssl_certificate /etc/nginx/certs/tls.crt;
ssl_certificate_key /etc/nginx/certs/tls.key;
ssl_certificate {{ .dir }}/tls.crt;
ssl_certificate_key {{ .dir }}/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;
@@ -55,7 +63,7 @@ data:
# 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_pass https://{{ $.Values.upstream.host }}:{{ $.Values.upstream.port }};
proxy_ssl_server_name on;
proxy_http_version 1.1;
proxy_set_header Connection "";
@@ -74,6 +82,7 @@ data:
add_header Content-Type text/plain;
}
}
{{- end }}
custom-rules.conf: |
# Règles maison — le reste (SecRuleEngine, Include CRS, seuils de
@@ -76,6 +76,11 @@ spec:
- name: certs
mountPath: /etc/nginx/certs
readOnly: true
{{- range $i, $e := .Values.tls.extra }}
- name: certs-extra-{{ $i }}
mountPath: /etc/nginx/certs-extra-{{ $i }}
readOnly: true
{{- end }}
- name: modsec-log
mountPath: /var/log/modsec
livenessProbe:
@@ -101,5 +106,10 @@ spec:
- name: certs
secret:
secretName: {{ .Values.tls.secretName }}
{{- range $i, $e := .Values.tls.extra }}
- name: certs-extra-{{ $i }}
secret:
secretName: {{ $e.secretName }}
{{- end }}
- name: modsec-log
emptyDir: {}
@@ -16,6 +16,14 @@ imagePullSecrets: []
# Secret que consomme le TLSStore de Traefik (templates/tlsstore.yaml).
tls:
secretName: wildcard-demo-tls
# Certificats supplémentaires sélectionnés par SNI (un bloc HTTPS chacun).
# Le WAF termine le TLS côté client : le certificat d'un domaine doit donc
# être ici, pas seulement dans le TLSStore de Traefik. Les secrets doivent
# exister dans ce namespace. wildcard-vitrine-tls : projets vitrine (voir
# deploy/chart-vitrine/cert-manager/wildcard-certificate.yml).
extra:
- serverName: "*.vitrine-omnex.club"
secretName: wildcard-vitrine-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
+81
View File
@@ -0,0 +1,81 @@
# chart-vitrine
Charts du projet vitrine déployé **par le control-plane Omnex** (jamais à la main) pour un
client, sur abonnement de 1 à 12 mois. Même cluster, mêmes sauvegardes Velero que les démos
(`deploy/chart-gestion`), mais un produit différent : les charts de mêmes noms
(`backend`, `frontend`, `postgresql`, `redis`, `ingressroute`) vivent donc dans ce dossier
séparé, monté dans le control-plane sur `/charts-vitrine` (volume du `docker-compose.yml`).
`network-policy`, `resource-quota` et `registry-credentials` sont ceux de `chart-gestion`.
| Chart | Rôle |
|---|---|
| `postgresql` | Postgres 16 (StatefulSet + PVC Longhorn), Service `postgres` |
| `redis` | Redis 7 avec mot de passe, Service `redis` |
| `backend` | API Go/Gin ; migrations (hook pre-install/upgrade) et compte admin (hook post-install) |
| `frontend` | SPA Vite/React derrière nginx |
| `ingressroute` | IngressRoute Traefik : `/api/` et `/uploads/` → backend, `/` → frontend, sur un même host |
| `cert-manager/` | Manifestes bruts (pas un chart) : issuers DNS-01 et certificat wildcard partagé |
## Cycle de vie (internal/projects)
1. L'admin déploie depuis le dashboard : nom du client, **durée 1 à 12 mois**, compte admin
(identifiant, mot de passe 12+ caractères) et **nombre d'admins** (`ADMIN_NUMBER`, 1 à 20).
Le projet est servi sur `https://vitrine-<id>.vitrine-omnex.club`.
2. `POST /projects/:id/extend {"months": 1..12}` ajoute des mois à partir de l'échéance
(ou de maintenant si elle est dépassée). Le paiement est hors périmètre : l'admin renouvelle
quand le client a payé.
3. À l'échéance le projet est **supprimé** (namespace, données et sauvegardes Velero), sans délai
de grâce : il faut donc renouveler avant l'échéance. Un `DELETE /projects/:id` fait la même chose
immédiatement.
Sauvegardes : un `Schedule` Velero par namespace, créé au déploiement et supprimé avec le
projet, identique à celui des démos (toutes les 15 min, conservé 24 h, File System Backup).
## Prérequis du cluster (à faire une seule fois)
Ne pas réinstaller cert-manager, Velero, Longhorn, Traefik ni le WAF : ils sont partagés.
1. **Token Cloudflare** (`Zone:DNS:Edit` sur `vitrine-omnex.club`), jamais dans un manifeste :
```bash
kubectl create secret generic cloudflare-api-token-vitrine -n cert-manager \
--from-literal=api-token='<token>'
```
2. **Issuers puis certificat wildcard partagé** :
```bash
kubectl apply -f deploy/chart-vitrine/cert-manager/cluster-issuer.yml
kubectl apply -f deploy/chart-vitrine/cert-manager/wildcard-certificate.yml
kubectl get certificate -n traefik # wildcard-vitrine-tls : READY=True
```
Premier essai : remplacer l'issuer du certificat par `letsencrypt-dns-vitrine-staging`.
3. **WAF (terminaison TLS publique)** : le trafic entre par `gestion-waf-cluster` (nginx +
ModSecurity), qui termine le TLS avec un certificat par domaine choisi par SNI. Le certificat
des projets est déclaré dans `deploy/chart-gestion/waf-cluster/values.yaml` (`tls.extra`) :
```bash
helm upgrade gestion-waf-cluster deploy/chart-gestion/waf-cluster -n traefik --reset-then-reuse-values
```
Le secret `wildcard-vitrine-tls` (étape 2) doit exister avant : sans lui les Pods du WAF ne
démarrent pas. Vérifier :
`echo | openssl s_client -connect <ip>:443 -servername x.vitrine-omnex.club | openssl x509 -noout -subject`
(CN=vitrine-omnex.club attendu). Le TLSStore de Traefik (`tls.extraSecretNames`) référence aussi
ce secret, mais il ne concerne que la connexion interne WAF → Traefik, non vérifiée.
4. **RBAC** : `kubectl apply -f deploy/rbac/control-plane.yml` (ajoute le droit sur les `jobs`,
nécessaire aux hooks Helm de migration et de création du compte admin).
5. **DNS** (Cloudflare, enregistrements `A` en **DNS only**, pas « Proxied » : le WAF et le
rate-limit ont besoin de l'IP réelle des clients) : `*.vitrine-omnex.club` → IPs des nœuds
(le LoadBalancer écoute sur chacun).
6. **Images** : pousser `vitrine-backend` et `vitrine-frontend` sur un registry, puis renseigner
`VITRINE_BACKEND_IMAGE` et `VITRINE_FRONTEND_IMAGE` (ex. `registry/vitrine-backend:1.0.0`)
dans l'environnement du control-plane.
## Développement
```bash
helm lint deploy/chart-vitrine/<chart> --set secrets.JWT_SECRET=x --set host=a.vitrine-omnex.club --set auth.password=pw
cd control-plane/api && go test ./internal/projects/ ./internal/demos/
```
`TestVitrineValuesRenderWithCharts` génère les valeurs comme le control-plane et rend les charts
avec helm : il échoue si un nom de Service ou une clé de valeurs diverge entre le Go et les charts.
Le mode « autonome » (installation manuelle d'un seul projet, certificat par namespace) reste
disponible dans `ingressroute` : `tls.secretName=wildcard-vitrine-tls` et
`tls.certificate.enabled=true`.
+13
View File
@@ -0,0 +1,13 @@
# Patterns to ignore when building packages.
.DS_Store
.git/
.gitignore
*.swp
*.bak
*.tmp
*.orig
*~
.project
.idea/
*.tmproj
.vscode/
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: vitrine-backend
description: Go API backend (Gin) — storefront platform
type: application
version: 0.1.0
appVersion: "1.0.0"
@@ -0,0 +1,30 @@
Backend deployed as {{ include "backend.fullname" . }} ({{ .Values.replicaCount }} replica{{ if ne (int .Values.replicaCount) 1 }}s{{ end }}).
{{- if .Values.migrationJob.enabled }}
Migrations (backend/migrations) ran as a pre-install/pre-upgrade hook.
{{- else }}
migrationJob.enabled is false -- you must run backend/migrations against
DATABASE_URL yourself before the API will work against an empty database.
{{- end }}
{{- if .Values.seedJob.enabled }}
The initial admin account was created by the post-install seed Job. Check
its logs for the username (`kubectl logs job/{{ include "backend.fullname" . }}-seed -n {{ .Release.Namespace }}`)
-- it will NOT run again on upgrade, and re-enabling it once the admin
already exists will fail the Job.
{{- end }}
This chart creates no ingress: routing is done by the ingressroute chart
(charts/ingressroute), which claims "/api/" and "/uploads/" for this Service
on the same host as the frontend.
In-cluster, the API is reachable at:
http://{{ include "backend.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.port }}
{{- if eq .Values.env.MEDIA_STORAGE_DRIVER "local" }}
MEDIA_STORAGE_DRIVER=local: uploaded files live on PersistentVolumeClaims
(uploads: {{ if .Values.persistence.uploads.enabled }}{{ .Values.persistence.uploads.size }}{{ else }}DISABLED -- uploads will be lost on pod restart{{ end }},
verification: {{ if .Values.persistence.verification.enabled }}{{ .Values.persistence.verification.size }}{{ else }}DISABLED -- files will be lost on pod restart{{ end }}).
This only works correctly with replicaCount=1 / autoscaling disabled.
Switch to MEDIA_STORAGE_DRIVER=s3 before scaling beyond one replica.
{{- end }}
@@ -0,0 +1,25 @@
{{- define "backend.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "backend.fullname" -}}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "backend.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
app.kubernetes.io/name: {{ include "backend.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "backend.selectorLabels" -}}
app.kubernetes.io/name: {{ include "backend.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- define "backend.secretName" -}}
{{ include "backend.fullname" . }}-secrets
{{- end }}
@@ -0,0 +1,10 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "backend.fullname" . }}
labels:
{{- include "backend.labels" . | nindent 4 }}
data:
{{- range $k, $v := .Values.env }}
{{ $k }}: {{ $v | quote }}
{{- end }}
@@ -0,0 +1,90 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "backend.fullname" . }}
labels:
{{- include "backend.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
{{- if or .Values.persistence.uploads.enabled .Values.persistence.verification.enabled }}
# Volumes ReadWriteOnce : un rolling update bloquerait (l'ancien Pod garde le
# volume, le nouveau ne peut pas le monter).
strategy:
type: Recreate
{{- end }}
selector:
matchLabels:
{{- include "backend.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "backend.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["/app/api"]
ports:
- name: http
containerPort: {{ .Values.env.PORT | int }}
protocol: TCP
envFrom:
- configMapRef:
name: {{ include "backend.fullname" . }}
- secretRef:
name: {{ include "backend.secretName" . }}
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
{{- if .Values.persistence.uploads.enabled }}
- name: uploads
mountPath: {{ .Values.env.MEDIA_LOCAL_DIR }}
{{- end }}
{{- if .Values.persistence.verification.enabled }}
- name: verification
mountPath: {{ .Values.env.VERIFICATION_UPLOAD_DIR }}
{{- end }}
volumes:
{{- if .Values.persistence.uploads.enabled }}
- name: uploads
persistentVolumeClaim:
claimName: {{ include "backend.fullname" . }}-uploads
{{- end }}
{{- if .Values.persistence.verification.enabled }}
- name: verification
persistentVolumeClaim:
claimName: {{ include "backend.fullname" . }}-verification
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -0,0 +1,22 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "backend.fullname" . }}
labels:
{{- include "backend.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "backend.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
@@ -0,0 +1,58 @@
{{- if .Values.migrationJob.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "backend.fullname" . }}-migrate-{{ .Release.Revision }}
labels:
{{- include "backend.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: {{ .Values.migrationJob.backoffLimit }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "backend.name" . }}-migrate
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: migrate
spec:
restartPolicy: Never
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
# Migrations are baked into the backend image (see backend/Dockerfile)
# so they always match the version being deployed; this initContainer
# just hands them to the golang-migrate image below via a shared
# emptyDir, since that image has the `migrate` binary but not our SQL.
initContainers:
- name: copy-migrations
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
command: ["sh", "-c", "cp -r /app/migrations/. /migrations/"]
volumeMounts:
- name: migrations
mountPath: /migrations
containers:
- name: migrate
image: "{{ .Values.migrationJob.image.repository }}:{{ .Values.migrationJob.image.tag }}"
imagePullPolicy: {{ .Values.migrationJob.image.pullPolicy }}
args:
- "-path=/migrations"
- "-database=$(DATABASE_URL)"
- "up"
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: {{ include "backend.secretName" . }}
key: DATABASE_URL
volumeMounts:
- name: migrations
mountPath: /migrations
readOnly: true
volumes:
- name: migrations
emptyDir: {}
{{- end }}
@@ -0,0 +1,20 @@
{{- range $name, $pvc := .Values.persistence }}
{{- if $pvc.enabled }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "backend.fullname" $ }}-{{ $name }}
labels:
{{- include "backend.labels" $ | nindent 4 }}
spec:
accessModes:
- {{ $pvc.accessMode }}
{{- if $pvc.storageClass }}
storageClassName: {{ $pvc.storageClass }}
{{- end }}
resources:
requests:
storage: {{ $pvc.size }}
{{- end }}
{{- end }}
@@ -0,0 +1,32 @@
{{- if or (not .Values.secrets.JWT_SECRET) (eq .Values.secrets.JWT_SECRET "change-me-to-a-long-random-secret") }}
{{- fail "secrets.JWT_SECRET must be overridden to a strong, unique value (e.g. `openssl rand -base64 48`)" }}
{{- end }}
{{- if and .Values.seedJob.enabled (not .Values.secrets.SEED_ADMIN_PASSWORD) }}
{{- fail "secrets.SEED_ADMIN_PASSWORD is required when seedJob.enabled=true" }}
{{- end }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "backend.secretName" . }}
labels:
{{- include "backend.labels" . | nindent 4 }}
annotations:
# Hook pre-install/pre-upgrade : le Job de migration (hook lui aussi) tourne
# AVANT la création des ressources normales du chart et lit DATABASE_URL
# depuis ce Secret. Sans ce hook, le Job démarrerait sans Secret.
# Conséquence : `helm uninstall` ne supprime pas ce Secret.
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-10"
"helm.sh/hook-delete-policy": before-hook-creation
type: Opaque
stringData:
{{- range $k, $v := .Values.secrets }}
{{ $k }}: {{ $v | quote }}
{{- end }}
# Assemblées ici (plutôt que passées telles quelles) pour que
# database.*/redis.* restent des champs structurés côté values.yaml, à
# l'image de postgresql.auth/redis.auth des charts ../postgresql, ../redis
# -- alors que backend/internal/platform/config ne sait lire qu'une seule
# chaîne de connexion.
DATABASE_URL: {{ printf "postgres://%s:%s@%s:%v/%s?sslmode=%s" (.Values.database.user | urlquery) (.Values.secrets.DB_PASSWORD | urlquery) .Values.database.host .Values.database.port .Values.database.name .Values.database.sslmode | quote }}
REDIS_URL: {{ printf "redis://:%s@%s:%v/%v" (.Values.secrets.REDIS_PASSWORD | urlquery) .Values.redis.host .Values.redis.port .Values.redis.db | quote }}
@@ -0,0 +1,40 @@
{{- if .Values.seedJob.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "backend.fullname" . }}-seed
labels:
{{- include "backend.labels" . | nindent 4 }}
annotations:
# post-install only, never post-upgrade: cmd/seed hard-fails if
# SEED_ADMIN_USERNAME already exists (see backend/cmd/seed/main.go), so
# it is not safe to re-run on every upgrade. Turn seedJob.enabled back
# off after the first successful install.
"helm.sh/hook": post-install
"helm.sh/hook-weight": "5"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
backoffLimit: {{ .Values.seedJob.backoffLimit }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "backend.name" . }}-seed
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: seed
spec:
restartPolicy: Never
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: seed
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["/app/seed"]
envFrom:
- configMapRef:
name: {{ include "backend.fullname" . }}
- secretRef:
name: {{ include "backend.secretName" . }}
{{- end }}
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "backend.fullname" . }}
labels:
{{- include "backend.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "backend.selectorLabels" . | nindent 4 }}
+160
View File
@@ -0,0 +1,160 @@
# 1 seul replica tant que les médias sont sur des volumes ReadWriteOnce
# (persistence.* ci-dessous) ; passer à S3 avant d'en mettre plus.
replicaCount: 1
image:
repository: vitrine-backend
tag: latest
pullPolicy: IfNotPresent
# Injecté par qui déploie (voir deploy/chart-gestion/registry-credentials
# dans omnex/deploy pour le modèle) si un compte registry 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
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 5
targetCPUUtilizationPercentage: 70
# Deux volumes distincts : uploads (médias publics, servis via /uploads) et
# verification (pièces d'identité clients, jamais servies publiquement --
# voir backend/internal/modules/customerverification). Seulement pertinent
# pour media.storageDriver=local ci-dessous ; avec S3 les deux sont désactivables.
persistence:
uploads:
enabled: true
size: 5Gi
storageClass: "longhorn-replicated"
accessMode: ReadWriteOnce
verification:
enabled: true
size: 2Gi
storageClass: "longhorn-replicated"
accessMode: ReadWriteOnce
# Postgres/Redis de CE namespace (voir ../postgresql, ../redis) -- host par
# défaut = le nom de Service que ces charts produisent avec leur
# fullnameOverride par défaut ("postgres"/"redis"). name/user = auth.database /
# auth.username par défaut de ../postgresql.
# Le mot de passe vit dans secrets.DB_PASSWORD / secrets.REDIS_PASSWORD
# ci-dessous (jamais ici), et DATABASE_URL/REDIS_URL sont assemblés par
# templates/secret.yaml à partir des deux.
database:
host: postgres
port: 5432
name: vitrine_db
user: postgres
sslmode: disable
redis:
host: redis
port: 6379
db: 0
# Variables non sensibles (backend/internal/platform/config). PORT doit
# rester égal à service.port ci-dessus / containerPort (templates/deployment.yaml).
env:
PORT: "8080"
GIN_MODE: release
# Laisser vide si frontend et backend sont routés sur le même host par
# l'IngressRoute partagée (voir ../ingressroute) -- l'origine est alors la
# même pour le navigateur et CORS n'entre pas en jeu.
CORS_ORIGIN: ""
ACCESS_TOKEN_TTL_MINUTES: "15"
REFRESH_TOKEN_TTL_HOURS: "168"
COOKIE_SECURE: "true"
# Nombre maximum de comptes admin : saisi par l'admin au déploiement
# (dashboard Omnex), surchargé par le control-plane.
ADMIN_NUMBER: "1"
SEED_ADMIN_USERNAME: admin
MEDIA_STORAGE_DRIVER: local
MEDIA_MAX_UPLOAD_MB: "10"
MEDIA_LOCAL_DIR: /app/uploads
# URL publique sous laquelle /uploads est servi -- à fixer explicitement
# une fois le host de l'IngressRoute connu (ex: https://shop.example.com/uploads).
MEDIA_LOCAL_BASE_URL: ""
VERIFICATION_UPLOAD_DIR: /app/verification-uploads
S3_BUCKET: ""
S3_REGION: ""
S3_ENDPOINT: ""
S3_USE_PATH_STYLE: "false"
S3_PUBLIC_BASE_URL: ""
# Valeurs sensibles -- à surcharger via --set-string ou un Secret Manager
# externe, jamais commitées avec une vraie valeur. DATABASE_URL et REDIS_URL
# ne sont PAS ici : templates/secret.yaml les construit à partir de
# database.* / redis.* ci-dessus + DB_PASSWORD / REDIS_PASSWORD.
secrets:
JWT_SECRET: "change-me-to-a-long-random-secret"
DB_PASSWORD: ""
REDIS_PASSWORD: ""
SEED_ADMIN_PASSWORD: ""
S3_ACCESS_KEY_ID: ""
S3_SECRET_ACCESS_KEY: ""
# Bootstrap du compte admin initial (cmd/seed). PAS idempotent -- échoue si
# SEED_ADMIN_USERNAME existe déjà (voir backend/cmd/seed/main.go), donc ce
# Job ne tourne qu'en post-install (jamais post-upgrade). À repasser à false
# après la première installation réussie.
seedJob:
enabled: false
backoffLimit: 0
# Applique backend/migrations (embarquées dans l'image) sur DATABASE_URL en
# hook pre-install/pre-upgrade, via l'image officielle golang-migrate.
# `migrate up` est idempotent -- rien n'empêche de le laisser actif à chaque
# upgrade, contrairement au seed Job ci-dessus.
migrationJob:
enabled: true
backoffLimit: 2
image:
repository: migrate/migrate
tag: v4.18.1
pullPolicy: IfNotPresent
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 20
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 10
podSecurityContext:
runAsNonRoot: true
runAsUser: 101
runAsGroup: 101
fsGroup: 101
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL
podAnnotations: {}
@@ -0,0 +1,59 @@
# Cluster déjà équipé pour omnex.sbs (cert-manager + ClusterIssuers
# `letsencrypt-dns` / `letsencrypt-dns-staging` limités à la zone omnex.sbs).
# Ce fichier AJOUTE des issuers dédiés à vitrine-omnex.club, avec des noms
# distincts : rien d'existant n'est modifié ni écrasé.
#
# Ne PAS réinstaller cert-manager : il est déjà là. Vérifier :
# kubectl get pods -n cert-manager
#
# Un certificat wildcard (*.vitrine-omnex.club) ne peut être émis que via un
# challenge DNS-01 : cert-manager crée des TXT chez Cloudflare.
#
# Le Secret n'est volontairement PAS dans ce fichier (ne jamais committer un
# token). Token Cloudflare avec "Zone - DNS - Edit" ET "Zone - Zone - Read" sur la
# zone vitrine-omnex.club (Zone Read : cert-manager retrouve la zone avec)
# (https://dash.cloudflare.com/profile/api-tokens), à créer une seule fois :
# kubectl create secret generic cloudflare-api-token-vitrine -n cert-manager \
# --from-literal=api-token='<token>'
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-dns-vitrine
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@omnex.sbs # À adapter
privateKeySecretRef:
name: letsencrypt-dns-vitrine-account-key
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token-vitrine
key: api-token
selector:
dnsZones:
- "vitrine-omnex.club"
---
# Issuer de test (hors rate-limit Let's Encrypt prod) : à utiliser dans
# wildcard-certificate.yml pour valider la chaîne DNS-01 avant de basculer.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-dns-vitrine-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: admin@omnex.sbs # À adapter
privateKeySecretRef:
name: letsencrypt-dns-vitrine-staging-account-key
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: cloudflare-api-token-vitrine
key: api-token
selector:
dnsZones:
- "vitrine-omnex.club"
@@ -0,0 +1,24 @@
# Certificat wildcard PARTAGÉ par tous les projets vitrine (un seul, renouvelé
# automatiquement par cert-manager ~30 jours avant expiration). Il vit dans le
# namespace du Traefik partagé et est servi via son TLSStore (voir
# deploy/chart-gestion/traefik : `tls.extraSecretNames`), comme
# wildcard-demo-tls pour les démos (*.omnex.sbs).
#
# Secret distinct (`wildcard-vitrine-tls`) : celui des démos n'est pas touché.
# À appliquer UNE FOIS, après cluster-issuer.yml, AVANT de référencer ce
# secret dans le TLSStore. Vérifier : kubectl get certificate -n traefik
# (premier essai : remplacer l'issuer par letsencrypt-dns-vitrine-staging).
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: wildcard-vitrine-tls
namespace: traefik
spec:
secretName: wildcard-vitrine-tls
issuerRef:
name: letsencrypt-dns-vitrine
kind: ClusterIssuer
commonName: "vitrine-omnex.club"
dnsNames:
- "vitrine-omnex.club"
- "*.vitrine-omnex.club"
+13
View File
@@ -0,0 +1,13 @@
# Patterns to ignore when building packages.
.DS_Store
.git/
.gitignore
*.swp
*.bak
*.tmp
*.orig
*~
.project
.idea/
*.tmproj
.vscode/
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: vitrine-frontend
description: Helm chart for the storefront/admin SPA (Vite/React), served as a static build behind nginx.
type: application
version: 0.1.0
appVersion: "1.0.0"
@@ -0,0 +1,15 @@
Frontend deployed as {{ include "frontend.fullname" . }} ({{ .Values.replicaCount }} replica{{ if ne (int .Values.replicaCount) 1 }}s{{ end }}).
{{- if .Values.ingress.enabled }}
Reachable at:
{{ if .Values.ingress.tls.enabled }}https{{ else }}http{{ end }}://{{ .Values.ingress.host }}{{ .Values.ingress.path }}
For the API calls made from this SPA (relative /api, /uploads paths) to
work, deploy the backend chart's ingress on the SAME host so the more
specific "/api" and "/uploads" paths take precedence over this chart's "/".
{{- else }}
ingress.enabled is false. Reach it in-cluster at:
http://{{ include "frontend.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.port }}
{{- end }}
@@ -0,0 +1,48 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "frontend.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "frontend.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{- define "frontend.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- define "frontend.labels" -}}
helm.sh/chart: {{ include "frontend.chart" . }}
{{ include "frontend.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "frontend.selectorLabels" -}}
app.kubernetes.io/name: {{ include "frontend.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- define "frontend.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "frontend.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
@@ -0,0 +1,57 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "frontend.fullname" . }}
labels:
{{- include "frontend.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "frontend.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "frontend.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "frontend.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -0,0 +1,22 @@
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "frontend.fullname" . }}
labels:
{{- include "frontend.labels" . | nindent 4 }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "frontend.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "frontend.fullname" . }}
labels:
{{- include "frontend.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "frontend.selectorLabels" . | nindent 4 }}
@@ -0,0 +1,14 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "frontend.serviceAccountName" . }}
labels:
{{- include "frontend.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
# nginx ne parle jamais à l'API Kubernetes : pas de jeton monté dans le pod.
automountServiceAccountToken: false
{{- end }}
+79
View File
@@ -0,0 +1,79 @@
replicaCount: 2
image:
repository: vitrine-frontend
tag: "latest"
pullPolicy: IfNotPresent
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
create: true
annotations: {}
name: ""
podAnnotations: {}
podSecurityContext:
runAsNonRoot: true
runAsUser: 101
fsGroup: 101
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop: ["ALL"]
service:
type: ClusterIP
port: 80
targetPort: 8080
# Ce chart ne crée pas d'Ingress : le routage est fait par charts/ingressroute
# (Traefik), qui envoie "/" ici et "/api/", "/uploads/" au backend sur le MÊME
# host -- le navigateur ne voit qu'une origine (cf. proxy dev de
# vite.config.ts). Ce bloc ne sert qu'à l'affichage de NOTES.txt : laisser
# enabled=false.
ingress:
enabled: false
host: ""
path: /
pathType: Prefix
annotations: {}
tls:
enabled: false
secretName: ""
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
autoscaling:
enabled: false
minReplicas: 2
maxReplicas: 6
targetCPUUtilizationPercentage: 75
nodeSelector: {}
tolerations: []
affinity: {}
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 3
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 3
periodSeconds: 10
@@ -0,0 +1,6 @@
apiVersion: v2
name: vitrine-ingressroute
description: Routage Traefik + certificat TLS d'un host (Services de son propre namespace) — pointe vers le Traefik partagé (namespace traefik)
type: application
version: 0.1.0
appVersion: "1.0.0"
@@ -0,0 +1,14 @@
{{- define "ingressroute.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "ingressroute.backendService" -}}
{{- .Values.backend.serviceName | default (printf "%s-backend-vitrine-backend" .Release.Namespace) }}
{{- end }}
{{- define "ingressroute.frontendService" -}}
{{- .Values.frontend.serviceName | default (printf "%s-frontend-vitrine-frontend" .Release.Namespace) }}
{{- end }}
@@ -0,0 +1,19 @@
{{- if and .Values.tls.secretName .Values.tls.certificate.enabled }}
# Certificat émis par cert-manager (DNS-01) dans le namespace de la release :
# Traefik ne lit `tls.secretName` que dans le namespace de l'IngressRoute.
# Les ClusterIssuers sont définis dans ../cert-manager.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ .Values.tls.secretName }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "ingressroute.labels" . | nindent 4 }}
spec:
secretName: {{ .Values.tls.secretName }}
issuerRef:
name: {{ .Values.tls.certificate.issuerName }}
kind: {{ .Values.tls.certificate.issuerKind }}
dnsNames:
{{- toYaml .Values.tls.certificate.dnsNames | nindent 4 }}
{{- end }}
@@ -0,0 +1,58 @@
{{- if not .Values.host }}
{{- fail "ingressroute: .Values.host est requis (ex: vitrine-abc12.vitrine-omnex.club)" }}
{{- end }}
# Routage HTTPS. Le certificat est celui de `tls.secretName` (SNI) ; sans
# secretName, Traefik retombe sur le certificat par défaut du TLSStore
# partagé. Le redirect HTTP -> HTTPS est géré au niveau de l'entrypoint
# Traefik (pas dupliqué ici).
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: {{ .Release.Namespace }}-routes
namespace: {{ .Release.Namespace }}
labels:
{{- include "ingressroute.labels" . | nindent 4 }}
spec:
entryPoints:
- websecure
routes:
# API backend — WAF + headers + rate limit (middlewares partagés, définis
# dans le namespace du Traefik commun, référencés cross-namespace).
- match: Host(`{{ .Values.host }}`) && (PathPrefix(`/api/`))
kind: Rule
priority: 20
middlewares:
- name: waf-chain
namespace: {{ .Values.traefikNamespace }}
services:
- name: {{ include "ingressroute.backendService" . }}
port: {{ .Values.backend.servicePort }}
# Fichiers uploadés — WAF désactivé dans les règles Coraza pour ce path
- match: Host(`{{ .Values.host }}`) && PathPrefix(`/uploads/`)
kind: Rule
priority: 15
middlewares:
- name: security-headers
namespace: {{ .Values.traefikNamespace }}
services:
- name: {{ include "ingressroute.backendService" . }}
port: {{ .Values.backend.servicePort }}
# Frontend SPA — WAF + headers + rate limit
- match: Host(`{{ .Values.host }}`)
kind: Rule
priority: 1
middlewares:
- name: waf-chain
namespace: {{ .Values.traefikNamespace }}
services:
- name: {{ include "ingressroute.frontendService" . }}
port: {{ .Values.frontend.servicePort }}
{{- if .Values.tls.secretName }}
tls:
secretName: {{ .Values.tls.secretName }}
{{- else }}
tls: {}
{{- end }}
@@ -0,0 +1,40 @@
# Host complet servi par cette IngressRoute, ex: vitrine-abc12.vitrine-omnex.club
# (calculé par le control-plane : <namespace>.<domaine vitrine>).
host: ""
# Namespace où tourne le Traefik partagé et ses Middlewares (waf-chain,
# rate-limit, coraza-waf, security-headers) — voir deploy/chart-gestion/traefik.
traefikNamespace: traefik
# TLS. Mode PARTAGÉ (défaut, celui du control-plane) : secretName vide, donc
# `tls: {}` -- Traefik choisit le certificat dans son TLSStore d'après le nom
# d'hôte (SNI) : le wildcard *.vitrine-omnex.club (Certificate dans le
# namespace traefik, voir ../cert-manager/wildcard-certificate.yml) y est
# référencé via `tls.extraSecretNames` du chart traefik. Aucun certificat par
# projet : un par projet dépasserait les limites Let's Encrypt.
#
# Mode AUTONOME (installation manuelle d'un seul projet, sans TLSStore) :
# secretName: wildcard-vitrine-tls et certificate.enabled: true -- le chart
# crée alors le Certificate dans le namespace de la release.
tls:
secretName: ""
certificate:
enabled: false
# Utiliser letsencrypt-dns-vitrine-staging pour un premier essai.
issuerName: letsencrypt-dns-vitrine
issuerKind: ClusterIssuer
dnsNames:
- vitrine-omnex.club
- "*.vitrine-omnex.club"
# Services de CE namespace. Par défaut ceux produits par le control-plane
# (release "<namespace>-backend" / "<namespace>-frontend" + nom de chart
# vitrine-backend / vitrine-frontend). À surcharger si les releases portent
# d'autres noms. servicePort doit égaler `service.port` du chart correspondant.
backend:
serviceName: ""
servicePort: 8080
frontend:
serviceName: ""
servicePort: 80
@@ -0,0 +1,6 @@
apiVersion: v2
name: vitrine-postgresql
description: PostgreSQL 16 — gestion-commande
type: application
version: 0.1.0
appVersion: "16"
@@ -0,0 +1,20 @@
{{- define "postgresql.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-postgresql" .Release.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- define "postgresql.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
app.kubernetes.io/name: postgresql
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "postgresql.selectorLabels" -}}
app.kubernetes.io/name: postgresql
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
@@ -0,0 +1,20 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "postgresql.fullname" . }}-config
labels:
{{- include "postgresql.labels" . | nindent 4 }}
data:
postgresql.conf: |
max_connections = {{ .Values.config.maxConnections }}
shared_buffers = {{ .Values.config.sharedBuffers }}
effective_cache_size = {{ .Values.config.effectiveCacheSize }}
maintenance_work_mem = {{ .Values.config.maintenanceWorkMem }}
wal_buffers = {{ .Values.config.walBuffers }}
default_statistics_target = {{ .Values.config.defaultStatisticsTarget }}
random_page_cost = {{ .Values.config.randomPageCost }}
log_min_duration_statement = {{ .Values.config.logMinDurationStatement }}
log_timezone = 'UTC'
timezone = 'UTC'
# Connexions sécurisées uniquement depuis le réseau interne cluster
listen_addresses = '*'
@@ -0,0 +1,17 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "postgresql.fullname" . }}-data
labels:
{{- include "postgresql.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.accessMode }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass }}
{{- end }}
{{- end }}
@@ -0,0 +1,14 @@
{{- if not .Values.auth.password }}
{{- fail "auth.password is required (--set-string auth.password=...)" }}
{{- end }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "postgresql.fullname" . }}
labels:
{{- include "postgresql.labels" . | nindent 4 }}
type: Opaque
stringData:
POSTGRES_PASSWORD: {{ .Values.auth.password | quote }}
POSTGRES_USER: {{ .Values.auth.username | quote }}
POSTGRES_DB: {{ .Values.auth.database | quote }}
@@ -0,0 +1,32 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "postgresql.fullname" . }}
labels:
{{- include "postgresql.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "postgresql.selectorLabels" . | nindent 4 }}
ports:
- name: postgresql
port: {{ .Values.service.port }}
targetPort: postgresql
protocol: TCP
---
# Service headless pour le StatefulSet
apiVersion: v1
kind: Service
metadata:
name: {{ include "postgresql.fullname" . }}-headless
labels:
{{- include "postgresql.labels" . | nindent 4 }}
spec:
type: ClusterIP
clusterIP: None
selector:
{{- include "postgresql.selectorLabels" . | nindent 4 }}
ports:
- name: postgresql
port: {{ .Values.service.port }}
targetPort: postgresql
@@ -0,0 +1,78 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "postgresql.fullname" . }}
labels:
{{- include "postgresql.labels" . | nindent 4 }}
spec:
serviceName: {{ include "postgresql.fullname" . }}-headless
replicas: 1
selector:
matchLabels:
{{- include "postgresql.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "postgresql.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: postgresql
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
ports:
- name: postgresql
containerPort: 5432
protocol: TCP
envFrom:
- secretRef:
name: {{ include "postgresql.fullname" . }}
env:
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
- name: config
mountPath: /etc/postgresql/postgresql.conf
subPath: postgresql.conf
args:
- "-c"
- "config_file=/etc/postgresql/postgresql.conf"
livenessProbe:
exec:
command:
- sh
- -c
- pg_isready -U $POSTGRES_USER -d $POSTGRES_DB
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 6
readinessProbe:
exec:
command:
- sh
- -c
- pg_isready -U $POSTGRES_USER -d $POSTGRES_DB
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "postgresql.fullname" . }}-data
{{- else }}
emptyDir: {}
{{- end }}
- name: config
configMap:
name: {{ include "postgresql.fullname" . }}-config
@@ -0,0 +1,65 @@
# Nom des ressources (dont le Service ClusterIP joint par le backend).
# "postgres" = valeur attendue par backend/values.yaml (database.host / redis.host).
fullnameOverride: "postgres"
image:
repository: postgres
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: vitrine_db
# À surcharger via --set auth.password=xxx
password: ""
persistence:
enabled: true
size: 10Gi
# Répliqué sur 2 nœuds (Longhorn) : survit à la perte d'un nœud. Voir
# deploy/longhorn/storageclass.yml pour le prérequis d'installation.
storageClass: "longhorn-replicated"
accessMode: ReadWriteOnce
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
service:
type: ClusterIP
port: 5432
# Paramètres PostgreSQL (postgresql.conf)
config:
maxConnections: "200"
sharedBuffers: "256MB"
effectiveCacheSize: "768MB"
maintenanceWorkMem: "64MB"
walBuffers: "16MB"
defaultStatisticsTarget: "100"
randomPageCost: "1.1"
logMinDurationStatement: "1000"
podSecurityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
capabilities:
drop:
- ALL
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: vitrine-redis
description: Redis 7 — gestion-commande
type: application
version: 0.1.0
appVersion: "7"
@@ -0,0 +1,20 @@
{{- define "redis.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-redis" .Release.Name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- define "redis.labels" -}}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
app.kubernetes.io/name: redis
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{- define "redis.selectorLabels" -}}
app.kubernetes.io/name: redis
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
@@ -0,0 +1,20 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "redis.fullname" . }}-config
labels:
{{- include "redis.labels" . | nindent 4 }}
data:
redis.conf: |
maxmemory {{ .Values.config.maxmemory }}
maxmemory-policy {{ .Values.config.maxmemoryPolicy }}
appendonly {{ .Values.config.appendonly }}
appendfsync {{ .Values.config.appendfsync }}
{{- if .Values.config.save }}
save {{ .Values.config.save }}
{{- else }}
save ""
{{- end }}
protected-mode no
loglevel notice
# Le mot de passe est injecté au démarrage via --requirepass
@@ -0,0 +1,17 @@
{{- if .Values.persistence.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "redis.fullname" . }}-data
labels:
{{- include "redis.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.accessMode }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass }}
{{- end }}
{{- end }}
@@ -0,0 +1,12 @@
{{- if not .Values.auth.password }}
{{- fail "auth.password is required (--set-string auth.password=...)" }}
{{- end }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "redis.fullname" . }}
labels:
{{- include "redis.labels" . | nindent 4 }}
type: Opaque
stringData:
REDIS_PASSWORD: {{ .Values.auth.password | quote }}
@@ -0,0 +1,31 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "redis.fullname" . }}
labels:
{{- include "redis.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "redis.selectorLabels" . | nindent 4 }}
ports:
- name: redis
port: {{ .Values.service.port }}
targetPort: redis
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "redis.fullname" . }}-headless
labels:
{{- include "redis.labels" . | nindent 4 }}
spec:
type: ClusterIP
clusterIP: None
selector:
{{- include "redis.selectorLabels" . | nindent 4 }}
ports:
- name: redis
port: {{ .Values.service.port }}
targetPort: redis
@@ -0,0 +1,84 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "redis.fullname" . }}
labels:
{{- include "redis.labels" . | nindent 4 }}
spec:
serviceName: {{ include "redis.fullname" . }}-headless
replicas: 1
selector:
matchLabels:
{{- include "redis.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "redis.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: redis
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
ports:
- name: redis
containerPort: 6379
protocol: TCP
command:
- redis-server
- /etc/redis/redis.conf
- "--requirepass"
- "$(REDIS_PASSWORD)"
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "redis.fullname" . }}
key: REDIS_PASSWORD
volumeMounts:
- name: data
mountPath: /data
- name: config
mountPath: /etc/redis/redis.conf
subPath: redis.conf
- name: tmp
mountPath: /tmp
livenessProbe:
exec:
command:
- sh
- -c
- redis-cli --no-auth-warning -a $REDIS_PASSWORD ping | grep PONG
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 6
readinessProbe:
exec:
command:
- sh
- -c
- redis-cli --no-auth-warning -a $REDIS_PASSWORD ping | grep PONG
initialDelaySeconds: 5
periodSeconds: 5
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "redis.fullname" . }}-data
{{- else }}
emptyDir: {}
{{- end }}
- name: config
configMap:
name: {{ include "redis.fullname" . }}-config
- name: tmp
emptyDir: {}
+61
View File
@@ -0,0 +1,61 @@
# Nom des ressources (dont le Service ClusterIP joint par le backend).
# "redis" = valeur attendue par backend/values.yaml (database.host / redis.host).
fullnameOverride: "redis"
image:
repository: redis
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: ""
persistence:
enabled: true
size: 2Gi
# Répliqué sur 2 nœuds (Longhorn) : survit à la perte d'un nœud. Voir
# deploy/longhorn/storageclass.yml pour le prérequis d'installation.
storageClass: "longhorn-replicated"
accessMode: ReadWriteOnce
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
service:
type: ClusterIP
port: 6379
config:
maxmemory: "256mb"
# allkeys-lru : expulse les clés les moins récemment utilisées quand la mémoire est pleine
maxmemoryPolicy: "allkeys-lru"
appendonly: "yes"
appendfsync: "everysec"
# Désactiver les snapshots RDB (AOF suffit)
save: ""
podSecurityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
+4
View File
@@ -76,6 +76,10 @@ rules:
# vérifier l'état du rollout (jamais créés/gérés directement par nous).
resources: [replicasets]
verbs: [get, list, watch]
# Jobs de migration/seed des projets vitrine (hooks Helm du chart backend).
- apiGroups: ["batch"]
resources: [jobs]
verbs: [get, list, watch, create, update, patch, delete]
- apiGroups: ["autoscaling"]
resources: [horizontalpodautoscalers]
verbs: [get, list, watch, create, update, patch, delete]
-1510
View File
File diff suppressed because one or more lines are too long
+1510
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Omnex — Plateforme de gestion de commandes & livraison</title>
<meta name="description" content="Déployez en un clic une démo complète de la plateforme de gestion de commandes et de livraison." />
<script type="module" crossorigin src="/assets/index-DkbJvUQ3.js"></script>
<script type="module" crossorigin src="/assets/index-rDhSK02l.js"></script>
</head>
<body>
<div id="root"></div>
+2
View File
@@ -248,6 +248,8 @@ export const api = {
transferDemoToPremium: (id: string) => request<Demo>('POST', `/demos/${id}/premium`),
listProjects: () => request<{ items: Project[] }>('GET', '/projects'),
// État live des pods d'un projet (même format que getDemoDetails).
getProjectDetails: (id: string) => request<DemoDetails>('GET', `/projects/${id}/details`),
createProject: (params: CreateProjectParams) =>
request<Project>('POST', '/projects', {
client_name: params.clientName,
+151 -34
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useState } from 'react'
import { Fragment, useCallback, useEffect, useState } from 'react'
import {
Badge,
Box,
Button,
Collapse,
Flex,
Heading,
HStack,
Icon,
Link,
Spacer,
Spinner,
@@ -20,15 +23,19 @@ import {
Wrap,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, type Project } from '../../lib/api'
import { api, ApiError, type DemoDetails, type Project } from '../../lib/api'
import { formatDate, monthsLabel, projectStatusColor, projectStatusLabel, timeRemaining } from '../../lib/format'
import { ConfirmDialog } from '../../components/ConfirmDialog'
import { CreateProjectModal } from '../../components/CreateProjectModal'
import { ExtendProjectModal } from '../../components/ExtendProjectModal'
import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
// Un déploiement en cours => on rafraîchit régulièrement.
const POLL_MS = 5000
// Rafraîchissement de l'état des pods pendant que le détail d'une ligne est déplié.
const DETAILS_POLL_MS = 5000
// Projets vitrine déployés pour des clients sur abonnement de 1 à 12 mois.
export function Projects() {
const toast = useToast()
@@ -40,6 +47,11 @@ export function Projects() {
const [toExtend, setToExtend] = useState<Project | null>(null)
const [toDelete, setToDelete] = useState<Project | null>(null)
// --- Ligne dépliée (état live des pods) ---
const [expandedId, setExpandedId] = useState<string | null>(null)
const [details, setDetails] = useState<DemoDetails | null>(null)
const [detailsLoading, setDetailsLoading] = useState(false)
const load = useCallback(async () => {
try {
const res = await api.listProjects()
@@ -75,6 +87,55 @@ export function Projects() {
}
}
// Seul un projet déployé (ou en cours de déploiement) a des pods à afficher.
const hasPods = (p: Project) => p.status === 'ready' || p.status === 'provisioning'
const toggleRow = async (p: Project) => {
if (!hasPods(p)) return
if (expandedId === p.id) {
setExpandedId(null)
setDetails(null)
return
}
setExpandedId(p.id)
setDetails(null)
setDetailsLoading(true)
try {
setDetails(await api.getProjectDetails(p.id))
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'État des pods indisponible', description: msg })
setExpandedId(null)
} finally {
setDetailsLoading(false)
}
}
// Tant qu'une ligne est dépliée, on rafraîchit l'état des pods en direct
// (silencieux : pas de spinner, juste la mise à jour des badges/jauges).
useEffect(() => {
if (!expandedId) return
const id = setInterval(() => {
api
.getProjectDetails(expandedId)
.then(setDetails)
.catch(() => {
/* échec silencieux : on garde le dernier état connu affiché */
})
}, DETAILS_POLL_MS)
return () => clearInterval(id)
}, [expandedId])
// Le projet déplié a été supprimé ou n'a plus de pods : on referme la ligne.
useEffect(() => {
if (!expandedId) return
const current = projects.find((p) => p.id === expandedId)
if (!current || !hasPods(current)) {
setExpandedId(null)
setDetails(null)
}
}, [projects, expandedId])
// Échéance affichée : date de suppression et temps restant.
const deadline = (p: Project) => (
<Text fontSize="sm">
@@ -113,40 +174,96 @@ export function Projects() {
<Tbody>
{projects.map((p) => {
const locked = busy === p.id
const canExtend = p.status === 'ready' || p.status === 'provisioning'
const canExtend = hasPods(p)
const isOpen = expandedId === p.id
return (
<Tr key={p.id}>
<Td>{p.client_name}</Td>
<Td>
<Badge colorScheme={projectStatusColor(p.status)}>{projectStatusLabel(p.status)}</Badge>
</Td>
<Td>
<Link href={p.url} isExternal fontFamily="mono" fontSize="sm">
{p.host}
</Link>
</Td>
<Td>{monthsLabel(p.months_purchased)} au total</Td>
<Td>{deadline(p)}</Td>
<Td>
{p.admin_username} · max {p.admin_number}
</Td>
<Td>
<Wrap justify="flex-end" spacing={2}>
<Button size="xs" colorScheme="primary" isDisabled={!canExtend || locked} onClick={() => setToExtend(p)}>
Renouveler
</Button>
<Button
size="xs"
variant="outline"
colorScheme="red"
isDisabled={p.status === 'deleting' || locked}
onClick={() => setToDelete(p)}
<Fragment key={p.id}>
<Tr
cursor={hasPods(p) ? 'pointer' : undefined}
_hover={hasPods(p) ? { bg: 'chakra-subtle-bg' } : undefined}
onClick={() => void toggleRow(p)}
>
<Td>
<HStack spacing={2}>
{hasPods(p) && (
<Icon
as={ChevronIcon}
boxSize={3}
color="gray.400"
transform={isOpen ? 'rotate(90deg)' : undefined}
transition="transform 0.15s"
/>
)}
<Text>{p.client_name}</Text>
</HStack>
</Td>
<Td>
<Badge colorScheme={projectStatusColor(p.status)}>{projectStatusLabel(p.status)}</Badge>
</Td>
<Td>
<Link
href={p.url}
isExternal
fontFamily="mono"
fontSize="sm"
onClick={(e) => e.stopPropagation()}
>
Supprimer
</Button>
</Wrap>
</Td>
</Tr>
{p.host}
</Link>
</Td>
<Td>{monthsLabel(p.months_purchased)} au total</Td>
<Td>{deadline(p)}</Td>
<Td>
{p.admin_username} · max {p.admin_number}
</Td>
<Td>
<Wrap justify="flex-end" spacing={2}>
<Button
size="xs"
colorScheme="primary"
isDisabled={!canExtend || locked}
onClick={(e) => {
e.stopPropagation()
setToExtend(p)
}}
>
Renouveler
</Button>
<Button
size="xs"
variant="outline"
colorScheme="red"
isDisabled={p.status === 'deleting' || locked}
onClick={(e) => {
e.stopPropagation()
setToDelete(p)
}}
>
Supprimer
</Button>
</Wrap>
</Td>
</Tr>
<Tr>
<Td p={0} border={isOpen ? undefined : 'none'} colSpan={7}>
<Collapse in={isOpen} unmountOnExit animateOpacity>
<Box p={4} bg="chakra-subtle-bg" borderTopWidth="1px">
{detailsLoading && !details ? (
<Flex justify="center" py={4}>
<Spinner size="sm" />
</Flex>
) : details ? (
<PodStatusPanel state={details.state} />
) : (
<Text color="gray.500" fontSize="sm">
Aucune donnée.
</Text>
)}
</Box>
</Collapse>
</Td>
</Tr>
</Fragment>
)
})}
</Tbody>