Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker
This commit is contained in:
30
demo/orchestrator/Dockerfile
Normal file
30
demo/orchestrator/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
# Build context attendu : racine du repo LoreMind.
|
||||
# Appele depuis demo/docker-compose.infra.yml avec context: ../
|
||||
|
||||
# --- Etage 1 : build Angular statique ---
|
||||
FROM node:20-alpine AS web-build
|
||||
WORKDIR /build
|
||||
COPY web/package*.json ./
|
||||
RUN npm ci
|
||||
COPY web/ .
|
||||
RUN npm run build -- --configuration production
|
||||
|
||||
# --- Etage 2 : build orchestrateur Go ---
|
||||
FROM golang:1.22-alpine AS go-build
|
||||
WORKDIR /src
|
||||
COPY demo/orchestrator/ ./
|
||||
# go mod tidy resout le go.sum au build pour eviter d'avoir a le committer.
|
||||
RUN go mod tidy && CGO_ENABLED=0 go build -o /orchestrator .
|
||||
|
||||
# --- Etage final : runtime minimal ---
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates
|
||||
WORKDIR /app
|
||||
COPY --from=go-build /orchestrator /app/orchestrator
|
||||
COPY --from=web-build /build/dist/web /app/static
|
||||
COPY demo/orchestrator/preparing.html /app/preparing.html
|
||||
EXPOSE 80
|
||||
ENV STATIC_DIR=/app/static \
|
||||
PREPARING_PAGE=/app/preparing.html
|
||||
ENTRYPOINT ["/app/orchestrator"]
|
||||
64
demo/orchestrator/config.go
Normal file
64
demo/orchestrator/config.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config centralise les parametres lus depuis les variables d'env au boot.
|
||||
type Config struct {
|
||||
Registry string
|
||||
Tag string
|
||||
MaxSessions int
|
||||
SessionTTL time.Duration
|
||||
CoreMemoryBytes int64
|
||||
BrainMemoryBytes int64
|
||||
PostgresMemoryBytes int64
|
||||
SessionsNetwork string
|
||||
BrainSecretDefault string
|
||||
StaticDir string
|
||||
PreparingPage string
|
||||
RateLimitWindow time.Duration
|
||||
MaxBodyBytes int64
|
||||
}
|
||||
|
||||
func loadConfig() *Config {
|
||||
return &Config{
|
||||
Registry: envStr("REGISTRY", "git.igmlcreation.fr"),
|
||||
Tag: envStr("TAG", "latest"),
|
||||
MaxSessions: envInt("MAX_SESSIONS", 10),
|
||||
SessionTTL: time.Duration(envInt("SESSION_TTL_MINUTES", 20)) * time.Minute,
|
||||
CoreMemoryBytes: int64(envInt("CORE_MEMORY_MB", 700)) * 1024 * 1024,
|
||||
BrainMemoryBytes: int64(envInt("BRAIN_MEMORY_MB", 300)) * 1024 * 1024,
|
||||
PostgresMemoryBytes: int64(envInt("POSTGRES_MEMORY_MB", 200)) * 1024 * 1024,
|
||||
SessionsNetwork: envStr("SESSIONS_NETWORK", "loremind-demo-sessions"),
|
||||
BrainSecretDefault: envStr("BRAIN_INTERNAL_SECRET_DEFAULT", "change-me"),
|
||||
StaticDir: envStr("STATIC_DIR", "/app/static"),
|
||||
PreparingPage: envStr("PREPARING_PAGE", "/app/preparing.html"),
|
||||
RateLimitWindow: time.Duration(envInt("RATE_LIMIT_WINDOW_SECONDS", 60)) * time.Second,
|
||||
// 10 Mo : aligne avec la limite d'upload d'image cote core.
|
||||
MaxBodyBytes: int64(envInt("MAX_BODY_MB", 10)) * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
func envStr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
i, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
log.Printf("warning: env %s=%q not a number, using default %d", key, v, def)
|
||||
return def
|
||||
}
|
||||
return i
|
||||
}
|
||||
247
demo/orchestrator/docker.go
Normal file
247
demo/orchestrator/docker.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
// DockerClient encapsule les operations Docker necessaires a la demo.
|
||||
type DockerClient struct {
|
||||
cli *client.Client
|
||||
}
|
||||
|
||||
func newDockerClient() (*DockerClient, error) {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker client: %w", err)
|
||||
}
|
||||
return &DockerClient{cli: cli}, nil
|
||||
}
|
||||
|
||||
// SpawnTrio cree les 3 conteneurs d'une session (postgres, brain, core) et
|
||||
// les branche sur le reseau interne. Ils partagent un label "demo-session=<id>"
|
||||
// pour faciliter le nettoyage.
|
||||
func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Config) error {
|
||||
pgName := "demo-" + sessionID + "-postgres"
|
||||
brainName := "demo-" + sessionID + "-brain"
|
||||
coreName := "demo-" + sessionID + "-core"
|
||||
pgPassword := randomHex(16)
|
||||
brainSecret := randomHex(32)
|
||||
adminPassword := randomHex(16)
|
||||
|
||||
labels := map[string]string{
|
||||
"demo-session": sessionID,
|
||||
"demo-role": "", // rempli par conteneur
|
||||
}
|
||||
|
||||
// --- Postgres (tmpfs => ephemere) ---
|
||||
pgLabels := copyLabels(labels, "postgres")
|
||||
if err := d.runContainer(ctx, runSpec{
|
||||
Name: pgName,
|
||||
Image: "postgres:16-alpine",
|
||||
Env: []string{"POSTGRES_DB=loremind", "POSTGRES_USER=loremind", "POSTGRES_PASSWORD=" + pgPassword},
|
||||
Labels: pgLabels,
|
||||
Memory: cfg.PostgresMemoryBytes,
|
||||
Tmpfs: map[string]string{"/var/lib/postgresql/data": "rw,size=200m"},
|
||||
Net: cfg.SessionsNetwork,
|
||||
Alias: pgName,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("spawn postgres: %w", err)
|
||||
}
|
||||
|
||||
// --- Brain ---
|
||||
brainLabels := copyLabels(labels, "brain")
|
||||
if err := d.runContainer(ctx, runSpec{
|
||||
Name: brainName,
|
||||
Image: cfg.Registry + "/ietm64/brain:" + cfg.Tag,
|
||||
Env: []string{
|
||||
"INTERNAL_SHARED_SECRET=" + brainSecret,
|
||||
// Pas de provider LLM configure en demo : les features IA repondront
|
||||
// en erreur, la demo sert principalement a explorer l'edition.
|
||||
"LLM_PROVIDER=ollama",
|
||||
"OLLAMA_BASE_URL=http://localhost:1", // endpoint mort volontairement
|
||||
},
|
||||
Labels: brainLabels,
|
||||
Memory: cfg.BrainMemoryBytes,
|
||||
Net: cfg.SessionsNetwork,
|
||||
Alias: brainName,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("spawn brain: %w", err)
|
||||
}
|
||||
|
||||
// --- Core ---
|
||||
coreLabels := copyLabels(labels, "core")
|
||||
if err := d.runContainer(ctx, runSpec{
|
||||
Name: coreName,
|
||||
Image: cfg.Registry + "/ietm64/core:" + cfg.Tag,
|
||||
Env: []string{
|
||||
"SPRING_DATASOURCE_URL=jdbc:postgresql://" + pgName + ":5432/loremind",
|
||||
"SPRING_DATASOURCE_USERNAME=loremind",
|
||||
"SPRING_DATASOURCE_PASSWORD=" + pgPassword,
|
||||
"BRAIN_BASE_URL=http://" + brainName + ":8000",
|
||||
"BRAIN_INTERNAL_SECRET=" + brainSecret,
|
||||
"ADMIN_USERNAME=admin",
|
||||
"ADMIN_PASSWORD=" + adminPassword,
|
||||
"DEMO_MODE=true",
|
||||
"CORS_ALLOWED_ORIGINS=*",
|
||||
// MinIO volontairement non fourni : le client init en lazy, seul
|
||||
// l'upload d'images echouera (500). A masquer plus tard si besoin.
|
||||
},
|
||||
Labels: coreLabels,
|
||||
Memory: cfg.CoreMemoryBytes,
|
||||
Net: cfg.SessionsNetwork,
|
||||
Alias: coreName,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("spawn core: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSpec regroupe les parametres d'un container pour runContainer.
|
||||
type runSpec struct {
|
||||
Name string
|
||||
Image string
|
||||
Env []string
|
||||
Labels map[string]string
|
||||
Memory int64
|
||||
Tmpfs map[string]string
|
||||
Net string
|
||||
Alias string
|
||||
}
|
||||
|
||||
// runContainer pull l'image si absente puis cree et demarre le conteneur.
|
||||
func (d *DockerClient) runContainer(ctx context.Context, s runSpec) error {
|
||||
// Pull silencieux si image absente localement. Ignore les erreurs (l'image
|
||||
// peut exister localement sans etre atteignable au registre, ex: builds dev).
|
||||
if reader, err := d.cli.ImagePull(ctx, s.Image, types.ImagePullOptions{}); err == nil {
|
||||
// Drain le body, sinon le pull n'est pas termine quand on continue.
|
||||
_, _ = io.Copy(io.Discard, reader)
|
||||
reader.Close()
|
||||
}
|
||||
|
||||
pidsLimit := int64(200)
|
||||
hostCfg := &container.HostConfig{
|
||||
RestartPolicy: container.RestartPolicy{Name: "no"},
|
||||
Resources: container.Resources{
|
||||
Memory: s.Memory,
|
||||
NanoCPUs: 1_000_000_000, // 1 vCPU par conteneur
|
||||
PidsLimit: &pidsLimit, // Anti fork-bomb : max 200 threads/processus.
|
||||
},
|
||||
Tmpfs: s.Tmpfs,
|
||||
// no-new-privileges : interdit a un processus du conteneur de gagner
|
||||
// plus de privileges que son parent (bloque les exploits setuid courants).
|
||||
SecurityOpt: []string{"no-new-privileges:true"},
|
||||
// CapDrop/CapAdd volontairement non configures : les images core (JVM
|
||||
// Spring Boot) et brain (Python) n'ont pas ete auditees pour un
|
||||
// fonctionnement avec capabilities restreintes ; un drop trop agressif
|
||||
// peut casser le demarrage de maniere non triviale. A revoir si besoin.
|
||||
}
|
||||
|
||||
netCfg := &network.NetworkingConfig{
|
||||
EndpointsConfig: map[string]*network.EndpointSettings{
|
||||
s.Net: {Aliases: []string{s.Alias}},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := d.cli.ContainerCreate(ctx, &container.Config{
|
||||
Image: s.Image,
|
||||
Env: s.Env,
|
||||
Labels: s.Labels,
|
||||
}, hostCfg, netCfg, nil, s.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", s.Name, err)
|
||||
}
|
||||
if err := d.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
||||
return fmt.Errorf("start %s: %w", s.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WaitReady poll l'endpoint /api/config du core pendant timeout, retourne true
|
||||
// des qu'il repond 200. Utilise par le manager pour passer en Status=ready.
|
||||
func (d *DockerClient) WaitReady(ctx context.Context, sessionID string, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
url := "http://demo-" + sessionID + "-core:8080/api/config"
|
||||
httpClient := &http.Client{Timeout: 2 * time.Second}
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := httpClient.Get(url)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// KillTrio arrete et supprime tous les conteneurs avec le label demo-session=<id>.
|
||||
func (d *DockerClient) KillTrio(ctx context.Context, sessionID string) error {
|
||||
f := filters.NewArgs()
|
||||
f.Add("label", "demo-session="+sessionID)
|
||||
list, err := d.cli.ContainerList(ctx, container.ListOptions{All: true, Filters: f})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range list {
|
||||
_ = d.cli.ContainerRemove(ctx, c.ID, container.RemoveOptions{Force: true})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListSessionIDs retourne les IDs de session detectes dans les labels Docker.
|
||||
// Utile au demarrage pour nettoyer les orphelins (conteneurs d'une vie
|
||||
// anterieure de l'orchestrateur).
|
||||
func (d *DockerClient) ListSessionIDs(ctx context.Context) ([]string, error) {
|
||||
f := filters.NewArgs()
|
||||
f.Add("label", "demo-session")
|
||||
list, err := d.cli.ContainerList(ctx, container.ListOptions{All: true, Filters: f})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, c := range list {
|
||||
if v, ok := c.Labels["demo-session"]; ok && v != "" {
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func copyLabels(base map[string]string, role string) map[string]string {
|
||||
out := make(map[string]string, len(base))
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
out["demo-role"] = role
|
||||
return out
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
8
demo/orchestrator/go.mod
Normal file
8
demo/orchestrator/go.mod
Normal file
@@ -0,0 +1,8 @@
|
||||
module github.com/loremind/demo-orchestrator
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/docker/docker v27.3.1+incompatible
|
||||
github.com/google/uuid v1.6.0
|
||||
)
|
||||
2
demo/orchestrator/go.sum
Normal file
2
demo/orchestrator/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
231
demo/orchestrator/main.go
Normal file
231
demo/orchestrator/main.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const cookieName = "loremind-demo-session"
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
|
||||
docker, err := newDockerClient()
|
||||
if err != nil {
|
||||
log.Fatalf("docker init: %v", err)
|
||||
}
|
||||
|
||||
mgr := newManager(docker, cfg)
|
||||
limiter := newRateLimiter(cfg.RateLimitWindow)
|
||||
|
||||
// Nettoyage des sessions residuelles au boot (redemarrage orchestrateur).
|
||||
cleanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
mgr.CleanupOrphans(cleanCtx)
|
||||
cancel()
|
||||
|
||||
go mgr.RunGC(context.Background())
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/_demo/ready", readyHandler(mgr))
|
||||
mux.HandleFunc("/api/", apiHandler(mgr, cfg))
|
||||
mux.HandleFunc("/", rootHandler(mgr, limiter, cfg))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":80",
|
||||
Handler: mux,
|
||||
// Timeouts anti-slowloris. WriteTimeout laisse de la marge pour le
|
||||
// streaming SSE (ai/chat/stream) qui peut durer plusieurs minutes.
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 10 * time.Minute,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
// Headers max : 1 Mo (defaut Go), suffisant.
|
||||
}
|
||||
|
||||
log.Printf("orchestrator listening on :80 (max sessions=%d, ttl=%s, rate window=%s)",
|
||||
cfg.MaxSessions, cfg.SessionTTL, cfg.RateLimitWindow)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
log.Fatalf("http server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// rootHandler gere toutes les routes non-API : sert l'Angular statique si le
|
||||
// visiteur a deja une session prete, sinon cree une session (sous rate limit)
|
||||
// et renvoie la page de preparation.
|
||||
func rootHandler(mgr *Manager, limiter *rateLimiter, cfg *Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := currentSession(r, mgr)
|
||||
|
||||
// Visiteur connu et session prete -> sert l'app normalement.
|
||||
if sess != nil && sess.Status == StatusReady {
|
||||
serveStatic(w, r, cfg.StaticDir)
|
||||
return
|
||||
}
|
||||
|
||||
// On ne spawn qu'a la navigation initiale (GET d'un document HTML).
|
||||
// Les assets secondaires (JS/CSS/favicon) ne doivent pas declencher
|
||||
// de nouvelle session.
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "No session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !acceptsHTML(r) {
|
||||
http.Error(w, "No session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Session inexistante (ou expiree) -> en creer une, sous rate limit.
|
||||
if sess == nil {
|
||||
ip := clientIP(r)
|
||||
if !limiter.Allow(ip) {
|
||||
http.Error(w, "Trop de tentatives. Merci d'attendre "+
|
||||
strconv.Itoa(int(cfg.RateLimitWindow.Seconds()))+"s.",
|
||||
http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
newSess, err := mgr.Create(r.Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrCapacity) {
|
||||
http.Error(w, "La demo est pleine (max "+
|
||||
strconv.Itoa(cfg.MaxSessions)+
|
||||
" sessions simultanees). Merci de reessayer plus tard.",
|
||||
http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Impossible de creer la session : "+err.Error(),
|
||||
http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sess = newSess
|
||||
setCookie(w, sess.ID, cfg.SessionTTL)
|
||||
}
|
||||
|
||||
servePreparingPage(w, cfg.PreparingPage)
|
||||
}
|
||||
}
|
||||
|
||||
// apiHandler proxifie /api/* vers le core de la session.
|
||||
// Bride la taille des bodies a MaxBodyBytes pour limiter les DoS memoire.
|
||||
func apiHandler(mgr *Manager, cfg *Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := currentSession(r, mgr)
|
||||
if sess == nil {
|
||||
http.Error(w, "No session", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if sess.Status != StatusReady {
|
||||
http.Error(w, "Session not ready", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodyBytes)
|
||||
}
|
||||
proxy := sessionProxy(sess)
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// sessionProxy renvoie (et cree si besoin) un reverse proxy cache dans la
|
||||
// session via sync.Once : garantit une seule creation meme sous requetes
|
||||
// concurrentes, sans mutex explicite.
|
||||
func sessionProxy(sess *Session) *httputil.ReverseProxy {
|
||||
sess.proxyOnce.Do(func() {
|
||||
target, _ := url.Parse("http://" + sess.CoreHost + ":8080")
|
||||
p := httputil.NewSingleHostReverseProxy(target)
|
||||
p.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Printf("proxy error session=%s: %v", sess.ID, err)
|
||||
http.Error(w, "Upstream error", http.StatusBadGateway)
|
||||
}
|
||||
sess.proxy = p
|
||||
})
|
||||
return sess.proxy.(*httputil.ReverseProxy)
|
||||
}
|
||||
|
||||
// readyHandler renvoie l'etat de la session en JSON pour le polling client.
|
||||
// N'expose aucun ID de session ni d'information sur les autres sessions.
|
||||
func readyHandler(mgr *Manager) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := currentSession(r, mgr)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if sess == nil {
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "none"})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": string(sess.Status),
|
||||
"error": sess.Err,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// currentSession lit le cookie et retrouve la session en memoire.
|
||||
// Si le cookie pointe vers une session disparue (redemarrage orchestrateur ou
|
||||
// TTL expire), retourne nil -> le handler traitera comme un nouveau visiteur.
|
||||
func currentSession(r *http.Request, mgr *Manager) *Session {
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil || c.Value == "" {
|
||||
return nil
|
||||
}
|
||||
return mgr.Get(c.Value)
|
||||
}
|
||||
|
||||
func setCookie(w http.ResponseWriter, id string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: id,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true, // Traefik termine le TLS ; le browser ne doit envoyer ce cookie qu'en HTTPS.
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// serveStatic sert les fichiers de l'Angular build avec fallback sur index.html
|
||||
// pour que le routeur cote client fonctionne (SPA).
|
||||
// Le check HasPrefix apres Join + Clean empeche les path traversals (..).
|
||||
func serveStatic(w http.ResponseWriter, r *http.Request, dir string) {
|
||||
reqPath := r.URL.Path
|
||||
if reqPath == "/" || reqPath == "" {
|
||||
reqPath = "/index.html"
|
||||
}
|
||||
fullPath := filepath.Join(dir, filepath.Clean(reqPath))
|
||||
if !strings.HasPrefix(fullPath, dir) {
|
||||
http.Error(w, "bad path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
|
||||
http.ServeFile(w, r, fullPath)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
|
||||
}
|
||||
|
||||
// servePreparingPage sert la page de chargement statique. Le cookie vient
|
||||
// d'etre pose, le JS de la page utilisera sessionId implicitement via le
|
||||
// cookie pour poller /_demo/ready.
|
||||
func servePreparingPage(w http.ResponseWriter, path string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
http.Error(w, "Preparing page not found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func acceptsHTML(r *http.Request) bool {
|
||||
accept := r.Header.Get("Accept")
|
||||
return strings.Contains(accept, "text/html") || accept == "" || accept == "*/*"
|
||||
}
|
||||
127
demo/orchestrator/preparing.html
Normal file
127
demo/orchestrator/preparing.html
Normal file
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LoreMind — Demo en preparation</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #1a1625;
|
||||
color: #e4def5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
}
|
||||
.card {
|
||||
max-width: 440px;
|
||||
padding: 2.5rem 2rem;
|
||||
}
|
||||
.logo {
|
||||
font-size: 2rem;
|
||||
color: #b794f4;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.subtitle {
|
||||
letter-spacing: 0.2em;
|
||||
font-size: 0.75rem;
|
||||
color: #9880c4;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
p {
|
||||
color: #aaa0c5;
|
||||
line-height: 1.6;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 1.5rem auto 0;
|
||||
border: 3px solid rgba(183, 148, 244, 0.2);
|
||||
border-top-color: #b794f4;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.error {
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.error.visible { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<div class="logo">✦ LoreMind</div>
|
||||
<div class="subtitle">THE DIGITAL CODEX</div>
|
||||
<h1>Preparation de votre demo…</h1>
|
||||
<p>
|
||||
Nous initialisons une instance isolee rien que pour vous.
|
||||
Cela prend generalement 20 a 40 secondes.
|
||||
</p>
|
||||
<p style="font-size: 0.8rem; color: #7d6ba0; margin-top: 1rem;">
|
||||
Votre session sera automatiquement reinitialisee au bout de 20 minutes.
|
||||
</p>
|
||||
<div class="spinner"></div>
|
||||
<div id="err" class="error"></div>
|
||||
</main>
|
||||
<script>
|
||||
(function () {
|
||||
var errBox = document.getElementById('err');
|
||||
var attempts = 0;
|
||||
var maxAttempts = 90; // 90 * 2s = 3 min max
|
||||
|
||||
function poll() {
|
||||
attempts++;
|
||||
fetch('/_demo/ready', { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.status === 'ready') {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
if (data.status === 'failed') {
|
||||
errBox.textContent = 'Echec du demarrage : ' + (data.error || 'raison inconnue') + '. Rechargez la page pour reessayer.';
|
||||
errBox.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
if (attempts >= maxAttempts) {
|
||||
errBox.textContent = 'Timeout. Rechargez la page pour reessayer.';
|
||||
errBox.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 2000);
|
||||
})
|
||||
.catch(function () {
|
||||
if (attempts >= maxAttempts) {
|
||||
errBox.textContent = 'Connexion perdue. Rechargez la page pour reessayer.';
|
||||
errBox.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 2000);
|
||||
});
|
||||
}
|
||||
poll();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
72
demo/orchestrator/ratelimit.go
Normal file
72
demo/orchestrator/ratelimit.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rateLimiter autorise au plus une action par IP dans une fenetre glissante.
|
||||
// Pas de token bucket : pour un endpoint de creation de session, "1 par
|
||||
// fenetre" est largement suffisant et plus simple a raisonner.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
lastSeen map[string]time.Time
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func newRateLimiter(window time.Duration) *rateLimiter {
|
||||
rl := &rateLimiter{
|
||||
lastSeen: make(map[string]time.Time),
|
||||
window: window,
|
||||
}
|
||||
go rl.cleanupLoop()
|
||||
return rl
|
||||
}
|
||||
|
||||
// Allow renvoie true si l'IP n'a pas deja declenche d'action dans la fenetre.
|
||||
// Sur true, l'horloge de l'IP est reinitialisee.
|
||||
func (rl *rateLimiter) Allow(ip string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
now := time.Now()
|
||||
if last, ok := rl.lastSeen[ip]; ok && now.Sub(last) < rl.window {
|
||||
return false
|
||||
}
|
||||
rl.lastSeen[ip] = now
|
||||
return true
|
||||
}
|
||||
|
||||
// cleanupLoop purge les entrees plus anciennes que 2x la fenetre pour eviter
|
||||
// la croissance non bornee de la map sous trafic varie.
|
||||
func (rl *rateLimiter) cleanupLoop() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
cutoff := time.Now().Add(-2 * rl.window)
|
||||
rl.mu.Lock()
|
||||
for ip, t := range rl.lastSeen {
|
||||
if t.Before(cutoff) {
|
||||
delete(rl.lastSeen, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP extrait l'IP reelle en prenant la derniere entree de X-Forwarded-For.
|
||||
// Justification : Traefik APPEND l'IP du peer au header existant, donc la
|
||||
// derniere valeur est celle que Traefik a observe directement (le vrai client).
|
||||
// Prendre la premiere serait une faille : un attaquant peut preremplir le header.
|
||||
func clientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(parts[len(parts)-1])
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
177
demo/orchestrator/sessions.go
Normal file
177
demo/orchestrator/sessions.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionStatus reflete l'etat du cycle de vie d'un trio de session.
|
||||
type SessionStatus string
|
||||
|
||||
const (
|
||||
StatusStarting SessionStatus = "starting"
|
||||
StatusReady SessionStatus = "ready"
|
||||
StatusFailed SessionStatus = "failed"
|
||||
)
|
||||
|
||||
// Session represente une demo isolee pour un visiteur.
|
||||
// CoreHost est le hostname Docker interne du conteneur core de cette session
|
||||
// (ex: "demo-abc123-core"), vers lequel l'orchestrateur proxifie les /api/*.
|
||||
type Session struct {
|
||||
ID string
|
||||
CreatedAt time.Time
|
||||
Status SessionStatus
|
||||
CoreHost string
|
||||
Err string
|
||||
// proxy et proxyOnce : reverse-proxy cache, cree au plus une fois via
|
||||
// sync.Once (evite la race entre deux requetes concurrentes sur la meme
|
||||
// session). proxy est typee any pour ne pas contraindre sessions.go a
|
||||
// importer net/http/httputil.
|
||||
proxy any
|
||||
proxyOnce sync.Once
|
||||
}
|
||||
|
||||
// Manager gere le cycle de vie des sessions (creation, acces, cleanup).
|
||||
// Thread-safe : le mutex protege la map contre les acces concurrents (HTTP
|
||||
// handlers + goroutine de GC).
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
docker *DockerClient
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
func newManager(docker *DockerClient, cfg *Config) *Manager {
|
||||
return &Manager{
|
||||
sessions: make(map[string]*Session),
|
||||
docker: docker,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// ErrCapacity est retournee quand MAX_SESSIONS est atteint.
|
||||
var ErrCapacity = errors.New("demo at capacity")
|
||||
|
||||
// Create reserve un slot et lance le spawn des conteneurs en arriere-plan.
|
||||
// Retourne immediatement avec Status=starting. L'etat bascule a "ready" quand
|
||||
// les conteneurs sont up et que core repond a /api/config.
|
||||
func (m *Manager) Create(ctx context.Context) (*Session, error) {
|
||||
m.mu.Lock()
|
||||
if len(m.sessions) >= m.cfg.MaxSessions {
|
||||
m.mu.Unlock()
|
||||
return nil, ErrCapacity
|
||||
}
|
||||
id := newShortID()
|
||||
sess := &Session{
|
||||
ID: id,
|
||||
CreatedAt: time.Now(),
|
||||
Status: StatusStarting,
|
||||
CoreHost: "demo-" + id + "-core",
|
||||
}
|
||||
m.sessions[id] = sess
|
||||
m.mu.Unlock()
|
||||
|
||||
// Spawn asynchrone : l'utilisateur voit immediatement la page "preparation".
|
||||
go func() {
|
||||
spawnCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if err := m.docker.SpawnTrio(spawnCtx, id, m.cfg); err != nil {
|
||||
log.Printf("session %s spawn failed: %v", id, err)
|
||||
m.mu.Lock()
|
||||
sess.Status = StatusFailed
|
||||
sess.Err = err.Error()
|
||||
m.mu.Unlock()
|
||||
// Nettoyage best-effort des conteneurs partiellement crees.
|
||||
_ = m.docker.KillTrio(context.Background(), id)
|
||||
return
|
||||
}
|
||||
// Attente que core reponde (sinon proxy retourne 502 aux premieres requetes).
|
||||
if m.docker.WaitReady(spawnCtx, id, 90*time.Second) {
|
||||
m.mu.Lock()
|
||||
sess.Status = StatusReady
|
||||
m.mu.Unlock()
|
||||
log.Printf("session %s ready", id)
|
||||
} else {
|
||||
log.Printf("session %s never became ready", id)
|
||||
m.mu.Lock()
|
||||
sess.Status = StatusFailed
|
||||
sess.Err = "timeout waiting for core"
|
||||
m.mu.Unlock()
|
||||
_ = m.docker.KillTrio(context.Background(), id)
|
||||
}
|
||||
}()
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// Get renvoie la session associee a un ID, ou nil si elle n'existe plus.
|
||||
func (m *Manager) Get(id string) *Session {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.sessions[id]
|
||||
}
|
||||
|
||||
// RunGC boucle toutes les minutes pour supprimer les sessions expirees.
|
||||
// A lancer en goroutine au demarrage.
|
||||
func (m *Manager) RunGC(ctx context.Context) {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.gcOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) gcOnce() {
|
||||
cutoff := time.Now().Add(-m.cfg.SessionTTL)
|
||||
m.mu.Lock()
|
||||
var expired []string
|
||||
for id, s := range m.sessions {
|
||||
if s.CreatedAt.Before(cutoff) {
|
||||
expired = append(expired, id)
|
||||
}
|
||||
}
|
||||
for _, id := range expired {
|
||||
delete(m.sessions, id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, id := range expired {
|
||||
log.Printf("session %s expired, killing containers", id)
|
||||
if err := m.docker.KillTrio(context.Background(), id); err != nil {
|
||||
log.Printf("kill %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupOrphans tue les conteneurs demo-* qui ne correspondent a aucune
|
||||
// session en memoire. Appele au demarrage pour gerer un redemarrage brutal.
|
||||
func (m *Manager) CleanupOrphans(ctx context.Context) {
|
||||
ids, err := m.docker.ListSessionIDs(ctx)
|
||||
if err != nil {
|
||||
log.Printf("list orphans: %v", err)
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
log.Printf("cleaning orphan session %s", id)
|
||||
_ = m.docker.KillTrio(ctx, id)
|
||||
}
|
||||
}
|
||||
|
||||
// newShortID genere un identifiant hexadecimal de 32 caracteres (128 bits).
|
||||
// 128 bits d'entropie rendent les collisions et le brute-force statistiquement
|
||||
// impossibles, meme si un attaquant pouvait tenter des millions de cookies.
|
||||
func newShortID() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
Reference in New Issue
Block a user