chore: build
This commit is contained in:
@@ -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,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,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
|
||||
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Patterns to ignore when building packages.
|
||||
.DS_Store
|
||||
.git/
|
||||
.gitignore
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -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 }}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,13 @@
|
||||
# Patterns to ignore when building packages.
|
||||
.DS_Store
|
||||
.git/
|
||||
.gitignore
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*.orig
|
||||
*~
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
Vendored
-1510
File diff suppressed because one or more lines are too long
Vendored
+1510
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user