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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user