fix: multiple error
ci-api / test (push) Successful in 23m32s
ci-web / test (push) Successful in 13m52s

This commit is contained in:
Nuxgrid
2026-08-01 17:24:34 +02:00
parent a06205a608
commit 5d2a132c3c
9 changed files with 144 additions and 54 deletions
+12 -6
View File
@@ -118,12 +118,18 @@ func main() {
} }
// Traefik partagé + WAF Coraza : installé une fois pour toutes les démos // Traefik partagé + WAF Coraza : installé une fois pour toutes les démos
// (idempotent). Non-fatal : si ça échoue, l'API démarre quand même, mais // (idempotent). En arrière-plan et non-fatal : "helm upgrade --install
// les démos provisionnées ensuite ne seront ni routées ni protégées tant // --wait --timeout 5m" peut bloquer plusieurs minutes si le cluster est
// que ce n'est pas corrigé. // lent/injoignable, ce qui retardait le démarrage du serveur HTTP au
if err := helmProv.EnsureSharedInfra(); err != nil { // point de faire échouer le healthcheck Docker avant même que l'API
log.Printf("ATTENTION: infra partagée (Traefik/WAF) indisponible: %v", err) // n'écoute sur le port. Ici ça ne retarde plus rien : les démos
} // provisionnées avant la fin de cet appel ne seront juste ni routées ni
// protégées tant qu'il n'a pas fini.
go func() {
if err := helmProv.EnsureSharedInfra(); err != nil {
log.Printf("ATTENTION: infra partagée (Traefik/WAF) indisponible: %v", err)
}
}()
// Service de démos avec le provisioner Helm // Service de démos avec le provisioner Helm
demoSvc := demos.NewService(demoStore, demoPool, helmProv, demos.Config{ demoSvc := demos.NewService(demoStore, demoPool, helmProv, demos.Config{
+30 -16
View File
@@ -24,11 +24,14 @@ type createRequest struct {
} }
type DetailDemoUserRequest struct { type DetailDemoUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=64,alphanum"` // Namespace identifie la démo de façon unique. Le username ne suffit
// pas : un même client peut avoir plusieurs démos (historique, relances),
// et interroger par username renvoyait toujours la même (la plus
// ancienne), jamais celle réellement sélectionnée dans l'UI.
Namespace string `json:"namespace" binding:"required,min=3,max=63"`
} }
type DemoDetailsResponse struct { type DemoDetailsResponse struct {
Username string `json:"username"`
Namespace string `json:"namespace"` Namespace string `json:"namespace"`
State ResourceState `json:"state"` State ResourceState `json:"state"`
} }
@@ -42,11 +45,14 @@ func (h *Handler) Create(c *gin.Context) {
} }
d, err := h.svc.Create(req.LeadID, req.Username) d, err := h.svc.Create(req.LeadID, req.Username)
if err != nil { if err != nil {
if errors.Is(err, ErrCapacityReached) { switch {
case errors.Is(err, ErrCapacityReached):
c.JSON(http.StatusConflict, gin.H{"error": "capacité maximale de démos atteinte"}) c.JSON(http.StatusConflict, gin.H{"error": "capacité maximale de démos atteinte"})
return case errors.Is(err, ErrUserHasActiveDemo):
c.JSON(http.StatusConflict, gin.H{"error": "ce client a déjà une démo active"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
return return
} }
c.JSON(http.StatusAccepted, d) c.JSON(http.StatusAccepted, d)
@@ -62,6 +68,21 @@ func (h *Handler) List(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": items}) c.JSON(http.StatusOK, gin.H{"items": items})
} }
// ListMine : GET /demos/mine — démo(s) rattachée(s) au client authentifié.
func (h *Handler) ListMine(c *gin.Context) {
p := auth.PrincipalFrom(c)
if p == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
return
}
items, err := h.svc.ListForUser(p.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *Handler) ListDetails(c *gin.Context) { func (h *Handler) ListDetails(c *gin.Context) {
p := auth.PrincipalFrom(c) p := auth.PrincipalFrom(c)
if p == nil { if p == nil {
@@ -74,26 +95,19 @@ func (h *Handler) ListDetails(c *gin.Context) {
} }
var req DetailDemoUserRequest var req DetailDemoUserRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" { if err := c.ShouldBindJSON(&req); err != nil || req.Namespace == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "username requis"}) c.JSON(http.StatusBadRequest, gin.H{"error": "namespace requis"})
return return
} }
demo, err := h.svc.store.GetDemoByUsername(req.Username) state, err := h.helm.GetResourceState(c.Request.Context(), req.Namespace)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"})
return
}
state, err := h.helm.GetResourceState(c.Request.Context(), demo.Namespace)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "impossible de récupérer l'état des ressources"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "impossible de récupérer l'état des ressources"})
return return
} }
c.JSON(http.StatusOK, DemoDetailsResponse{ c.JSON(http.StatusOK, DemoDetailsResponse{
Username: req.Username, Namespace: req.Namespace,
Namespace: demo.Namespace,
State: state, State: state,
}) })
} }
+20 -5
View File
@@ -11,9 +11,10 @@ import (
) )
var ( var (
ErrCapacityReached = errors.New("capacité maximale de démos atteinte") ErrCapacityReached = errors.New("capacité maximale de démos atteinte")
ErrNotFound = errors.New("démo introuvable") ErrNotFound = errors.New("démo introuvable")
ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel") ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel")
ErrUserHasActiveDemo = errors.New("ce client a déjà une démo active")
) )
// Config du service (injectable pour les tests). // Config du service (injectable pour les tests).
@@ -50,8 +51,22 @@ func NewService(store Store, pool Pool, prov Provisioner, cfg Config) *Service {
} }
// Create réserve la capacité + le pool, persiste la démo et déclenche le worker. // Create réserve la capacité + le pool, persiste la démo et déclenche le worker.
// username (optionnel) rattache la démo à un client existant. // username (optionnel) rattache la démo à un client existant. Un client n'a
// droit qu'à une seule démo active à la fois.
func (s *Service) Create(leadID, username string) (Demo, error) { func (s *Service) Create(leadID, username string) (Demo, error) {
normalizedUsername := auth.NormalizeUsername(username)
if normalizedUsername != "" {
existing, err := s.store.ListByUsername(normalizedUsername)
if err != nil {
return Demo{}, err
}
for _, d := range existing {
if d.Status.Active() {
return Demo{}, ErrUserHasActiveDemo
}
}
}
active, err := s.store.CountActive() active, err := s.store.CountActive()
if err != nil { if err != nil {
return Demo{}, err return Demo{}, err
@@ -63,7 +78,7 @@ func (s *Service) Create(leadID, username string) (Demo, error) {
id := uuid.NewString() id := uuid.NewString()
demo := Demo{ demo := Demo{
ID: id, ID: id,
Username: auth.NormalizeUsername(username), Username: normalizedUsername,
LeadID: leadID, LeadID: leadID,
Status: StatusPending, Status: StatusPending,
Namespace: "demo-" + shortID(id), Namespace: "demo-" + shortID(id),
+16 -16
View File
@@ -35,13 +35,13 @@ func (h *Handler) UpdateUsernameById(c *gin.Context) {
return return
} }
id, ok := c.MustGet("id").(string) p := auth.PrincipalFrom(c)
if !ok { if p == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
return return
} }
user, err := h.store.UpdateUsername(id, u.Username) user, err := h.store.UpdateUsername(p.UserID, u.Username)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"}) c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"})
return return
@@ -57,9 +57,9 @@ func (h *Handler) UpdatePasswordById(c *gin.Context) {
return return
} }
id, ok := c.MustGet("id").(string) p := auth.PrincipalFrom(c)
if !ok { if p == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
return return
} }
@@ -69,7 +69,7 @@ func (h *Handler) UpdatePasswordById(c *gin.Context) {
return return
} }
user, err := h.store.UpdatePassword(id, hash) user, err := h.store.UpdatePassword(p.UserID, hash)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
return return
@@ -79,12 +79,12 @@ func (h *Handler) UpdatePasswordById(c *gin.Context) {
} }
func (h *Handler) GetTelegramById(c *gin.Context) { func (h *Handler) GetTelegramById(c *gin.Context) {
id, ok := c.MustGet("id").(string) p := auth.PrincipalFrom(c)
if !ok { if p == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
return return
} }
user, err := h.store.GetTelegram(id) user, err := h.store.GetTelegram(p.UserID)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
return return
@@ -98,12 +98,12 @@ func (h *Handler) SetTelegramById(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
return return
} }
id, ok := c.MustGet("id").(string) p := auth.PrincipalFrom(c)
if !ok { if p == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
return return
} }
user, err := h.store.SetTelegram(id, t.Telegram) user, err := h.store.SetTelegram(p.UserID, t.Telegram)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"}) c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"})
return return
@@ -63,6 +63,9 @@ func New(d Deps) *gin.Engine {
client.GET("/leads", d.LeadsH.List) client.GET("/leads", d.LeadsH.List)
client.PATCH("/leads/:id/status", d.LeadsH.SetStatus) client.PATCH("/leads/:id/status", d.LeadsH.SetStatus)
client.POST("/subscription", d.SubH.AddCode) client.POST("/subscription", d.SubH.AddCode)
if d.DemosH != nil {
client.GET("/demos/mine", d.DemosH.ListMine)
}
} }
// Espace admin : provisioning des démos (admin uniquement). // Espace admin : provisioning des démos (admin uniquement).
admin := authed.Group("") admin := authed.Group("")
+9 -3
View File
@@ -88,11 +88,17 @@ traefik:
enabled: true enabled: true
allowCrossNamespace: true allowCrossNamespace: true
# IP réelle via X-Forwarded-For (profondeur 1 = proxy direct) # Cluster (pas Local) : sur un cluster bare-metal multi-nœuds sans
# Le rate-limiter et les logs utilisent cette IP # LoadBalancer, "Local" fait DROP le trafic NodePort sur tout nœud qui ne
# fait pas tourner le pod Traefik (comportement kube-proxy documenté —
# "has no local endpoints"), donc l'URL casse selon le nœud contacté et
# où le pod est schedulé. "Cluster" relaie toujours vers le bon nœud, au
# prix de perdre l'IP réelle du client (SNAT par kube-proxy) — le
# rate-limiter/logs verront l'IP du nœud plutôt que celle du client tant
# qu'il n'y a pas de LoadBalancer (MetalLB) devant.
service: service:
spec: spec:
externalTrafficPolicy: Local externalTrafficPolicy: Cluster
# Métriques Prometheus # Métriques Prometheus
metrics: metrics:
+3 -3
View File
@@ -104,7 +104,6 @@ export interface DemoState {
} }
export interface DemoDetails { export interface DemoDetails {
username: string
namespace: string namespace: string
state: DemoState state: DemoState
} }
@@ -150,6 +149,7 @@ export const api = {
request<Lead>('PATCH', `/leads/${id}/status`, { status }), request<Lead>('PATCH', `/leads/${id}/status`, { status }),
listDemos: () => request<{ items: Demo[] }>('GET', '/demos'), listDemos: () => request<{ items: Demo[] }>('GET', '/demos'),
listMyDemos: () => request<{ items: Demo[] }>('GET', '/demos/mine'),
getDemo: (id: string) => request<Demo>('GET', `/demos/${id}`), getDemo: (id: string) => request<Demo>('GET', `/demos/${id}`),
createDemo: (username: string, leadId?: string) => createDemo: (username: string, leadId?: string) =>
request<Demo>('POST', '/demos', { request<Demo>('POST', '/demos', {
@@ -167,8 +167,8 @@ export const api = {
sendMessage: (username: string, telegram: string, sujet: string, message: string) => sendMessage: (username: string, telegram: string, sujet: string, message: string) =>
request<{ success: string }>('POST', '/send/message', { username, telegram, sujet, message }), request<{ success: string }>('POST', '/send/message', { username, telegram, sujet, message }),
getMessage: () => request<{ messages: Contact[] }>('GET', '/messages'), getMessage: () => request<{ messages: Contact[] }>('GET', '/messages'),
getDemoDetails: (username: string) => getDemoDetails: (namespace: string) =>
request<DemoDetails>('POST', '/demos/details', { username }), request<DemoDetails>('POST', '/demos/details', { namespace }),
updateUsername: (username: string) => updateUsername: (username: string) =>
request<UpdateUsername>('POST', '/profile/username', { username }), request<UpdateUsername>('POST', '/profile/username', { username }),
updatePassword: (password: string) => updatePassword: (password: string) =>
+3 -3
View File
@@ -140,7 +140,7 @@ export function Demos() {
setDetails(null) setDetails(null)
setDetailsLoading(true) setDetailsLoading(true)
try { try {
const res = await api.getDemoDetails(d.username) const res = await api.getDemoDetails(d.namespace)
setDetails(res) setDetails(res)
} catch (err) { } catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur' const msg = err instanceof ApiError ? err.message : 'Erreur'
@@ -156,10 +156,10 @@ export function Demos() {
useEffect(() => { useEffect(() => {
const current = demos.find((d) => d.id === expandedId) const current = demos.find((d) => d.id === expandedId)
if (!current) return if (!current) return
const username = current.username const namespace = current.namespace
const id = setInterval(() => { const id = setInterval(() => {
api api
.getDemoDetails(username) .getDemoDetails(namespace)
.then(setDetails) .then(setDetails)
.catch(() => { .catch(() => {
/* échec silencieux : on garde le dernier état connu affiché */ /* échec silencieux : on garde le dernier état connu affiché */
+48 -2
View File
@@ -9,6 +9,7 @@ import {
Heading, Heading,
HStack, HStack,
Input, Input,
Link,
Spacer, Spacer,
Spinner, Spinner,
Stack, Stack,
@@ -17,7 +18,8 @@ import {
useToast, useToast,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { api, ApiError } from '../../lib/api' import { api, ApiError, type Demo } from '../../lib/api'
import { statusColor, statusLabel } from '../../lib/format'
export function Subscription() { export function Subscription() {
const toast = useToast() const toast = useToast()
@@ -28,6 +30,8 @@ export function Subscription() {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [code, setCode] = useState('') const [code, setCode] = useState('')
const [showRenewForm, setShowRenewForm] = useState(false) const [showRenewForm, setShowRenewForm] = useState(false)
const [demos, setDemos] = useState<Demo[]>([])
const [demosLoading, setDemosLoading] = useState(true)
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@@ -42,9 +46,21 @@ export function Subscription() {
} }
}, [navigate, toast]) }, [navigate, toast])
const loadDemos = useCallback(async () => {
try {
const res = await api.listMyDemos()
setDemos(res.items ?? [])
} catch {
// Silencieux : l'absence de démo n'est pas une erreur à afficher ici.
} finally {
setDemosLoading(false)
}
}, [])
useEffect(() => { useEffect(() => {
void load() void load()
}, [load]) void loadDemos()
}, [load, loadDemos])
const submitCode = async (e: React.FormEvent) => { const submitCode = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -76,6 +92,36 @@ export function Subscription() {
return ( return (
<> <>
<Flex mb={6} align="center">
<Heading size="md">Ma démo</Heading>
<Spacer />
</Flex>
<Box mb={8} p={6} borderWidth="1px" borderRadius="lg" bg="bg-surface">
{demosLoading ? (
<Spinner size="sm" />
) : demos.length === 0 ? (
<Text color="gray.500">Aucune démo pour le moment.</Text>
) : (
<VStack align="stretch" spacing={3}>
{demos.map((d) => (
<HStack key={d.id} justify="space-between" flexWrap="wrap" rowGap={2}>
{d.status === 'ready' ? (
<Link href={d.url} color="primary.500" isExternal fontFamily="mono">
{d.url}
</Link>
) : (
<Text color="gray.400" fontFamily="mono">
{d.url || '—'}
</Text>
)}
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
</HStack>
))}
</VStack>
)}
</Box>
<Flex mb={6} align="center"> <Flex mb={6} align="center">
<Heading size="md">Mon abonnement</Heading> <Heading size="md">Mon abonnement</Heading>
<Spacer /> <Spacer />