diff --git a/demo/orchestrator/docker.go b/demo/orchestrator/docker.go index bb86bd3..873a035 100644 --- a/demo/orchestrator/docker.go +++ b/demo/orchestrator/docker.go @@ -1,37 +1,91 @@ package main import ( + "bytes" "context" "crypto/rand" "encoding/hex" + "encoding/json" "fmt" "io" "net/http" + "net/url" + "os" + "strings" "time" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/filters" - "github.com/docker/docker/api/types/image" - "github.com/docker/docker/api/types/network" - "github.com/docker/docker/client" ) -// DockerClient encapsule les operations Docker necessaires a la demo. +// 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 { - cli *client.Client + baseURL string + http *http.Client } func newDockerClient() (*DockerClient, error) { - cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return nil, fmt.Errorf("docker client: %w", err) + base := os.Getenv("DOCKER_HOST") + if base == "" { + return nil, fmt.Errorf("DOCKER_HOST non defini (attendu : tcp://dockerproxy:2375)") } - return &DockerClient{cli: cli}, nil + // 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 } -// SpawnTrio cree les 3 conteneurs d'une session (postgres, brain, core) et -// les branche sur le reseau interne. Ils partagent un label "demo-session=" -// pour faciliter le nettoyage. +// --- 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" @@ -40,18 +94,13 @@ func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Con brainSecret := randomHex(32) adminPassword := randomHex(16) - labels := map[string]string{ - "demo-session": sessionID, - "demo-role": "", // rempli par conteneur - } + labels := map[string]string{"demo-session": sessionID} - // --- 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, + Labels: copyLabels(labels, "postgres"), Memory: cfg.PostgresMemoryBytes, Tmpfs: map[string]string{"/var/lib/postgresql/data": "rw,size=200m"}, Net: cfg.SessionsNetwork, @@ -60,19 +109,17 @@ func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Con 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. + // 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", // endpoint mort volontairement + "OLLAMA_BASE_URL=http://localhost:1", }, - Labels: brainLabels, + Labels: copyLabels(labels, "brain"), Memory: cfg.BrainMemoryBytes, Net: cfg.SessionsNetwork, Alias: brainName, @@ -80,8 +127,6 @@ func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Con 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, @@ -95,10 +140,8 @@ func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Con "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, + Labels: copyLabels(labels, "core"), Memory: cfg.CoreMemoryBytes, Net: cfg.SessionsNetwork, Alias: coreName, @@ -109,74 +152,77 @@ func (d *DockerClient) SpawnTrio(ctx context.Context, sessionID string, cfg *Con 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, image.PullOptions{}); err == nil { - // Drain le body, sinon le pull n'est pas termine quand on continue. - _, _ = io.Copy(io.Discard, reader) - reader.Close() - } + // Pull best-effort : si l'image est deja locale, ContainerCreate la reprendra. + _ = d.pullImage(ctx, s.Image) - 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{ + spec := containerSpec{ Image: s.Image, Env: s.Env, Labels: s.Labels, - }, hostCfg, netCfg, nil, s.Name) + 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) } - if err := d.cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { + 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 } -// 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. +// 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) - url := "http://demo-" + sessionID + "-core:8080/api/config" - httpClient := &http.Client{Timeout: 2 * time.Second} + target := "http://demo-" + sessionID + "-core:8080/api/config" + c := &http.Client{Timeout: 2 * time.Second} for time.Now().Before(deadline) { - resp, err := httpClient.Get(url) + resp, err := c.Get(target) if err == nil { resp.Body.Close() if resp.StatusCode == 200 { @@ -192,32 +238,26 @@ func (d *DockerClient) WaitReady(ctx context.Context, sessionID string, timeout return false } -// KillTrio arrete et supprime tous les conteneurs avec le label demo-session=. +// KillTrio supprime tous les conteneurs labellises demo-session=. 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}) + containers, err := d.listContainersWithLabel(ctx, "demo-session="+sessionID) if err != nil { return err } - for _, c := range list { - _ = d.cli.ContainerRemove(ctx, c.ID, container.RemoveOptions{Force: true}) + for _, c := range containers { + _, _ = d.do(ctx, "DELETE", "/containers/"+c.ID+"?force=true", nil) } 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). +// ListSessionIDs : utilise au boot pour retrouver les conteneurs orphelins. 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}) + containers, err := d.listContainersWithLabel(ctx, "demo-session") if err != nil { return nil, err } - seen := make(map[string]bool) - for _, c := range list { + seen := map[string]bool{} + for _, c := range containers { if v, ok := c.Labels["demo-session"]; ok && v != "" { seen[v] = true } @@ -229,10 +269,58 @@ func (d *DockerClient) ListSessionIDs(ctx context.Context) ([]string, error) { 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)) + out := make(map[string]string, len(base)+1) for k, v := range base { out[k] = v } diff --git a/demo/orchestrator/go.mod b/demo/orchestrator/go.mod index d4b0356..47145d9 100644 --- a/demo/orchestrator/go.mod +++ b/demo/orchestrator/go.mod @@ -2,7 +2,7 @@ module github.com/loremind/demo-orchestrator go 1.23 -require ( - github.com/docker/docker v28.0.4+incompatible - github.com/google/uuid v1.6.0 -) +// Aucune dependance externe : on parle a Docker Engine en HTTP brut +// (cf. docker.go) plutot que d'utiliser github.com/docker/docker, dont le +// graphe transitif est instable d'une version a l'autre (sockets.DialPipe, +// errors.As/Is, otelhttp...).