package main import ( "bytes" "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strings" "time" ) // DockerClient parle a l'API Engine Docker en HTTP brut via le dockerproxy. // Pas de SDK externe : evite les conflits de versions transitives qui // rendaient github.com/docker/docker v27/v28 ininstallable proprement. // // L'API Engine v1.43 est exposee par Docker Engine 24+ (et le dockerproxy // la supporte sans config supplementaire). type DockerClient struct { baseURL string http *http.Client } func newDockerClient() (*DockerClient, error) { base := os.Getenv("DOCKER_HOST") if base == "" { return nil, fmt.Errorf("DOCKER_HOST non defini (attendu : tcp://dockerproxy:2375)") } // tcp://host:port -> http://host:port (le dockerproxy parle HTTP en clair). base = strings.Replace(base, "tcp://", "http://", 1) return &DockerClient{ baseURL: strings.TrimRight(base, "/") + "/v1.43", http: &http.Client{Timeout: 60 * time.Second}, }, nil } // --- Types serialises vers l'API Engine --- type containerSpec struct { Image string `json:"Image"` Env []string `json:"Env,omitempty"` Labels map[string]string `json:"Labels,omitempty"` HostConfig hostConfig `json:"HostConfig"` NetworkingConfig networkingConfig `json:"NetworkingConfig"` } type hostConfig struct { Memory int64 `json:"Memory,omitempty"` NanoCPUs int64 `json:"NanoCPUs,omitempty"` PidsLimit int64 `json:"PidsLimit,omitempty"` Tmpfs map[string]string `json:"Tmpfs,omitempty"` SecurityOpt []string `json:"SecurityOpt,omitempty"` RestartPolicy restartPolicy `json:"RestartPolicy"` } type restartPolicy struct { Name string `json:"Name"` } type networkingConfig struct { EndpointsConfig map[string]endpointSettings `json:"EndpointsConfig,omitempty"` } type endpointSettings struct { Aliases []string `json:"Aliases,omitempty"` } // runSpec : forme intermediate cote orchestrateur, mappee sur containerSpec // au moment d'envoyer la requete. type runSpec struct { Name string Image string Env []string Labels map[string]string Memory int64 Tmpfs map[string]string Net string Alias string } // --- Operations de haut niveau --- // SpawnTrio cree postgres + brain + core pour une session. 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} if err := d.runContainer(ctx, runSpec{ Name: pgName, Image: "postgres:16-alpine", Env: []string{"POSTGRES_DB=loremind", "POSTGRES_USER=loremind", "POSTGRES_PASSWORD=" + pgPassword}, Labels: copyLabels(labels, "postgres"), 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) } 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 echoueront // proprement, la demo sert principalement a explorer l'edition. "LLM_PROVIDER=ollama", "OLLAMA_BASE_URL=http://localhost:1", }, Labels: copyLabels(labels, "brain"), Memory: cfg.BrainMemoryBytes, Net: cfg.SessionsNetwork, Alias: brainName, }); err != nil { return fmt.Errorf("spawn brain: %w", err) } 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", // CorsConfig.java lit app.cors.allowed-origins (= APP_CORS_ALLOWED_ORIGINS // via le relaxed binding Spring). Necessaire meme en same-origin car // le browser envoie Origin sur les POST et le CorsFilter 403 les // origines inconnues. "APP_CORS_ALLOWED_ORIGINS=https://" + cfg.DemoHost, }, Labels: copyLabels(labels, "core"), Memory: cfg.CoreMemoryBytes, Net: cfg.SessionsNetwork, Alias: coreName, }); err != nil { return fmt.Errorf("spawn core: %w", err) } return nil } func (d *DockerClient) runContainer(ctx context.Context, s runSpec) error { // Pull best-effort : si l'image est deja locale, ContainerCreate la reprendra. _ = d.pullImage(ctx, s.Image) spec := containerSpec{ Image: s.Image, Env: s.Env, Labels: s.Labels, HostConfig: hostConfig{ Memory: s.Memory, NanoCPUs: 1_000_000_000, // 1 vCPU par conteneur PidsLimit: 200, // anti fork-bomb Tmpfs: s.Tmpfs, SecurityOpt: []string{"no-new-privileges:true"}, RestartPolicy: restartPolicy{Name: "no"}, }, NetworkingConfig: networkingConfig{ EndpointsConfig: map[string]endpointSettings{ s.Net: {Aliases: []string{s.Alias}}, }, }, } body, err := json.Marshal(spec) if err != nil { return err } createResp, err := d.do(ctx, "POST", "/containers/create?name="+url.QueryEscape(s.Name), body) if err != nil { return fmt.Errorf("create %s: %w", s.Name, err) } var created struct { ID string `json:"Id"` } if err := json.Unmarshal(createResp, &created); err != nil { return fmt.Errorf("parse create %s: %w", s.Name, err) } if _, err := d.do(ctx, "POST", "/containers/"+created.ID+"/start", nil); err != nil { return fmt.Errorf("start %s: %w", s.Name, err) } return nil } // pullImage drain le flux de progression. Erreur silencieuse : si le pull // echoue (registre prive sans auth, image deja locale), runContainer aura un // retour clair via ContainerCreate. func (d *DockerClient) pullImage(ctx context.Context, img string) error { req, err := http.NewRequestWithContext(ctx, "POST", d.baseURL+"/images/create?fromImage="+url.QueryEscape(img), nil) if err != nil { return err } resp, err := d.http.Do(req) if err != nil { return err } defer resp.Body.Close() _, _ = io.Copy(io.Discard, resp.Body) if resp.StatusCode >= 400 { return fmt.Errorf("pull %s: status %d", img, resp.StatusCode) } return nil } // WaitReady poll l'endpoint /api/config du core jusqu'a 200 ou timeout. func (d *DockerClient) WaitReady(ctx context.Context, sessionID string, timeout time.Duration) bool { deadline := time.Now().Add(timeout) target := "http://demo-" + sessionID + "-core:8080/api/config" c := &http.Client{Timeout: 2 * time.Second} for time.Now().Before(deadline) { resp, err := c.Get(target) 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 supprime tous les conteneurs labellises demo-session=. func (d *DockerClient) KillTrio(ctx context.Context, sessionID string) error { containers, err := d.listContainersWithLabel(ctx, "demo-session="+sessionID) if err != nil { return err } for _, c := range containers { _, _ = d.do(ctx, "DELETE", "/containers/"+c.ID+"?force=true", nil) } return nil } // ListSessionIDs : utilise au boot pour retrouver les conteneurs orphelins. func (d *DockerClient) ListSessionIDs(ctx context.Context) ([]string, error) { containers, err := d.listContainersWithLabel(ctx, "demo-session") if err != nil { return nil, err } seen := map[string]bool{} for _, c := range containers { 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 } type containerInfo struct { ID string `json:"Id"` Labels map[string]string `json:"Labels"` } func (d *DockerClient) listContainersWithLabel(ctx context.Context, label string) ([]containerInfo, error) { filters := map[string][]string{"label": {label}} filtersJSON, _ := json.Marshal(filters) q := url.Values{} q.Set("all", "true") q.Set("filters", string(filtersJSON)) body, err := d.do(ctx, "GET", "/containers/json?"+q.Encode(), nil) if err != nil { return nil, err } var list []containerInfo if err := json.Unmarshal(body, &list); err != nil { return nil, err } return list, nil } // do envoie une requete et renvoie le body. Une reponse 4xx/5xx est convertie // en erreur avec le contenu pour faciliter le debug. func (d *DockerClient) do(ctx context.Context, method, path string, body []byte) ([]byte, error) { var rdr io.Reader if body != nil { rdr = bytes.NewReader(body) } req, err := http.NewRequestWithContext(ctx, method, d.baseURL+path, rdr) if err != nil { return nil, err } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := d.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() out, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { return nil, fmt.Errorf("%s %s: HTTP %d %s", method, path, resp.StatusCode, out) } return out, nil } // --- helpers --- func copyLabels(base map[string]string, role string) map[string]string { out := make(map[string]string, len(base)+1) 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) }