// Package docker fournit un client HTTP léger pour l'API Docker via socket Unix. // Utilisé pour reconstruire et redémarrer le container backend lors de l'installation d'un module. package docker import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "log" "net" "net/http" "net/url" "os" "strings" "sync/atomic" "time" ) // IsRebuilding est vrai lorsqu'un rebuild est en cours. var IsRebuilding atomic.Bool // Client communique avec le daemon Docker via le socket Unix monté en volume. type Client struct { http *http.Client socketPath string } // New crée un Client Docker. Utilise /var/run/docker.sock par défaut. func New() *Client { socketPath := envOr("DOCKER_SOCKET", "/var/run/docker.sock") transport := &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", socketPath) }, } return &Client{ http: &http.Client{Transport: transport, Timeout: 15 * time.Minute}, socketPath: socketPath, } } // Available indique si le socket Docker est accessible. func (c *Client) Available() bool { _, err := os.Stat(c.socketPath) return err == nil } // ---- Structures Docker API ---- // buildEvent est une ligne du stream JSON retourné par l'API build. type buildEvent struct { Stream string `json:"stream"` Error string `json:"error"` } // inspectResult contient les champs utiles retournés par /containers/{id}/json. type inspectResult struct { Name string `json:"Name"` Config struct { Image string `json:"Image"` Env []string `json:"Env"` Cmd []string `json:"Cmd"` Entrypoint []string `json:"Entrypoint"` WorkingDir string `json:"WorkingDir"` ExposedPorts map[string]struct{} `json:"ExposedPorts"` } `json:"Config"` HostConfig struct { Binds []string `json:"Binds"` RestartPolicy struct { Name string `json:"Name"` } `json:"RestartPolicy"` GroupAdd []string `json:"GroupAdd"` NetworkMode string `json:"NetworkMode"` } `json:"HostConfig"` NetworkSettings struct { Networks map[string]struct { NetworkID string `json:"NetworkID"` } `json:"Networks"` } `json:"NetworkSettings"` } // containerCreateBody correspond au corps de POST /containers/create. type containerCreateBody struct { Image string `json:"Image"` Env []string `json:"Env"` Cmd []string `json:"Cmd,omitempty"` Entrypoint []string `json:"Entrypoint,omitempty"` WorkingDir string `json:"WorkingDir,omitempty"` ExposedPorts map[string]struct{} `json:"ExposedPorts,omitempty"` HostConfig struct { Binds []string `json:"Binds"` RestartPolicy struct { Name string `json:"Name"` } `json:"RestartPolicy"` GroupAdd []string `json:"GroupAdd,omitempty"` NetworkMode string `json:"NetworkMode,omitempty"` } `json:"HostConfig"` NetworkingConfig struct { EndpointsConfig map[string]map[string]string `json:"EndpointsConfig"` } `json:"NetworkingConfig"` } // ---- Opérations Docker ---- // BuildImage construit une nouvelle image depuis le sous-répertoire d'un repo git. // Le build arg MODULES contient les IDs de modules séparés par des virgules. func (c *Client) BuildImage(ctx context.Context, imageTag, gitRepo, gitBranch, subdir string, moduleIDs []string) error { modulesStr := strings.Join(moduleIDs, ",") // Format Docker : repo.git#branch:sous-répertoire remoteCtx := fmt.Sprintf("%s#%s:%s", gitRepo, gitBranch, subdir) buildArgsJSON, _ := json.Marshal(map[string]string{"MODULES": modulesStr}) q := url.Values{} q.Set("remote", remoteCtx) q.Set("dockerfile", "Dockerfile") q.Set("t", imageTag) q.Set("buildargs", string(buildArgsJSON)) q.Set("rm", "1") resp, err := c.do(ctx, "POST", "/v1.47/build?"+q.Encode(), nil) if err != nil { return fmt.Errorf("appel API build : %w", err) } defer resp.Body.Close() // Lire le stream de build ligne par ligne — retourner dès qu'une erreur est détectée. scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { var ev buildEvent if err := json.Unmarshal(scanner.Bytes(), &ev); err != nil { continue } if ev.Error != "" { return fmt.Errorf("erreur build Docker : %s", ev.Error) } if ev.Stream != "" { log.Printf("[docker-build] %s", strings.TrimRight(ev.Stream, "\n")) } } return scanner.Err() } // InspectContainer retourne la configuration du container (pour le recréer). func (c *Client) InspectContainer(ctx context.Context, name string) (*inspectResult, error) { resp, err := c.do(ctx, "GET", "/v1.47/containers/"+name+"/json", nil) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var info inspectResult if err := json.Unmarshal(body, &info); err != nil { return nil, fmt.Errorf("parse inspect : %w", err) } return &info, nil } // CreateContainer crée un nouveau container et retourne son ID. func (c *Client) CreateContainer(ctx context.Context, name string, body *containerCreateBody) (string, error) { bodyJSON, _ := json.Marshal(body) resp, err := c.do(ctx, "POST", "/v1.47/containers/create?name="+url.QueryEscape(name), bytes.NewReader(bodyJSON)) if err != nil { return "", err } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) var result struct { ID string `json:"Id"` } json.Unmarshal(raw, &result) if result.ID == "" { return "", fmt.Errorf("création container échouée : %s", string(raw)) } return result.ID, nil } // StartContainer démarre un container par son ID. func (c *Client) StartContainer(ctx context.Context, id string) error { _, err := c.do(ctx, "POST", "/v1.47/containers/"+id+"/start", nil) return err } // StopContainer arrête un container (timeout 15s). func (c *Client) StopContainer(ctx context.Context, name string) error { _, err := c.do(ctx, "POST", "/v1.47/containers/"+name+"/stop?t=15", nil) return err } // RemoveContainer supprime un container arrêté. func (c *Client) RemoveContainer(ctx context.Context, name string) error { _, err := c.do(ctx, "DELETE", "/v1.47/containers/"+name, nil) return err } // RenameContainer renomme un container. func (c *Client) RenameContainer(ctx context.Context, current, newName string) error { _, err := c.do(ctx, "POST", fmt.Sprintf("/v1.47/containers/%s/rename?name=%s", current, url.QueryEscape(newName)), nil) return err } // ---- Orchestration rebuild ---- // RebuildAndRestart lance un rebuild en arrière-plan : // 1. Construit la nouvelle image avec les modules demandés. // 2. Crée un container de remplacement qui, à son démarrage, arrêtera l'ancien. // // Cette méthode est non-bloquante : elle retourne immédiatement. // La réponse HTTP doit être envoyée AVANT d'appeler cette méthode. func (c *Client) RebuildAndRestart(moduleIDs []string) { if !IsRebuilding.CompareAndSwap(false, true) { log.Println("[docker] Rebuild déjà en cours, ignoré") return } containerName := envOr("CONTAINER_NAME", "proxmoxpanel-backend") gitRepo := envOr("GIT_REPO", "https://git.geronzi.fr/proxmoxPanel/core.git") gitBranch := envOr("GIT_BRANCH", "frontend/alpine") imageTag := envOr("IMAGE_TAG", "proxmoxpanel-backend:latest") nextName := containerName + "-next" go func() { defer IsRebuilding.Store(false) log.Printf("[docker] Rebuild démarré — modules : %v", moduleIDs) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) defer cancel() // Étape 1 — Build de la nouvelle image if err := c.BuildImage(ctx, imageTag, gitRepo, gitBranch, "backend", moduleIDs); err != nil { log.Printf("[docker] Erreur build image : %v", err) return } log.Println("[docker] Nouvelle image construite") // Étape 2 — Inspecter le container courant pour récupérer sa config info, err := c.InspectContainer(ctx, containerName) if err != nil { log.Printf("[docker] Erreur inspection container : %v", err) return } // Étape 3 — Préparer la config du container de remplacement body := buildReplaceBody(info, imageTag, nextName, containerName) bodyJSON, _ := json.Marshal(body) log.Printf("[docker] Config container next : %s", string(bodyJSON)) // Supprimer le container "next" s'il existe déjà (rebuild précédent raté) c.StopContainer(ctx, nextName) c.RemoveContainer(ctx, nextName) // Étape 4 — Créer le container de remplacement nextID, err := c.CreateContainer(ctx, nextName, body) if err != nil { log.Printf("[docker] Erreur création container next : %v", err) return } // Étape 5 — Démarrer le container de remplacement // Il stoppera et remplacera l'actuel au démarrage (voir main.go : handleReplacement). if err := c.StartContainer(ctx, nextID); err != nil { log.Printf("[docker] Erreur démarrage container next : %v", err) return } log.Printf("[docker] Container %s démarré — remplacement de %s en cours", nextName, containerName) }() } // Restart redémarre le container courant en arrière-plan (même image). // Utilisé lors de l'activation/désactivation d'un module has_backend. func (c *Client) Restart() { containerName := envOr("CONTAINER_NAME", "proxmoxpanel-backend") go func() { time.Sleep(500 * time.Millisecond) log.Printf("[docker] Redémarrage du container %s...", containerName) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := c.StopContainer(ctx, containerName); err != nil { log.Printf("[docker] Erreur stop container : %v", err) } // La restart policy unless-stopped ne redémarre pas après un stop explicite. // On recrée le container à partir de son inspect. info, err := c.InspectContainer(ctx, containerName) if err != nil { log.Printf("[docker] Erreur inspect pour restart : %v", err) return } c.RemoveContainer(ctx, containerName) body := buildSameBody(info) id, err := c.CreateContainer(ctx, containerName, body) if err != nil { log.Printf("[docker] Erreur recréation container : %v", err) return } if err := c.StartContainer(ctx, id); err != nil { log.Printf("[docker] Erreur démarrage après restart : %v", err) } }() } // HandleReplacement gère le cas où ce container est un remplacement. // À appeler au tout début de main() si REPLACING_CONTAINER est défini. // Arrête l'ancien container, le supprime, se renomme. func (c *Client) HandleReplacement() { old := os.Getenv("REPLACING_CONTAINER") myName := envOr("CONTAINER_NAME", "proxmoxpanel-backend-next") if old == "" || !c.Available() { return } log.Printf("[docker] Mode remplacement : arrêt du container %s...", old) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Petit délai pour que l'ancien container finisse d'envoyer ses réponses HTTP en cours time.Sleep(2 * time.Second) if err := c.StopContainer(ctx, old); err != nil { log.Printf("[docker] Avertissement : impossible d'arrêter %s : %v", old, err) } if err := c.RemoveContainer(ctx, old); err != nil { log.Printf("[docker] Avertissement : impossible de supprimer %s : %v", old, err) } // Se renommer pour prendre l'identité du container remplacé if err := c.RenameContainer(ctx, myName, old); err != nil { log.Printf("[docker] Avertissement : impossible de renommer %s → %s : %v", myName, old, err) } else { log.Printf("[docker] Container renommé %s → %s", myName, old) } } // ---- Helpers internes ---- // buildReplaceBody construit la config du container de remplacement. // Ajoute REPLACING_CONTAINER et met à jour CONTAINER_NAME dans les env vars. func buildReplaceBody(info *inspectResult, newImage, newContainerName, replacingName string) *containerCreateBody { body := buildSameBody(info) body.Image = newImage // Mettre à jour les env vars de remplacement newEnv := make([]string, 0, len(info.Config.Env)+2) for _, e := range info.Config.Env { if strings.HasPrefix(e, "CONTAINER_NAME=") || strings.HasPrefix(e, "REPLACING_CONTAINER=") { continue } newEnv = append(newEnv, e) } newEnv = append(newEnv, "CONTAINER_NAME="+newContainerName, "REPLACING_CONTAINER="+replacingName, ) body.Env = newEnv return body } // buildSameBody construit la config d'un container identique (même image). func buildSameBody(info *inspectResult) *containerCreateBody { body := &containerCreateBody{} body.Image = info.Config.Image body.Env = info.Config.Env body.Cmd = info.Config.Cmd body.Entrypoint = info.Config.Entrypoint body.WorkingDir = info.Config.WorkingDir body.ExposedPorts = info.Config.ExposedPorts body.HostConfig.Binds = info.HostConfig.Binds body.HostConfig.RestartPolicy.Name = info.HostConfig.RestartPolicy.Name body.HostConfig.GroupAdd = info.HostConfig.GroupAdd body.HostConfig.NetworkMode = info.HostConfig.NetworkMode // Réseaux endpoints := make(map[string]map[string]string) for netName, netInfo := range info.NetworkSettings.Networks { endpoints[netName] = map[string]string{"NetworkID": netInfo.NetworkID} } body.NetworkingConfig.EndpointsConfig = endpoints return body } // do effectue une requête HTTP vers l'API Docker (http://docker = socket Unix). func (c *Client) do(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, method, "http://docker"+path, body) if err != nil { return nil, err } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.http.Do(req) if err != nil { return nil, err } // Les codes 2xx et 304 sont considérés comme succès if resp.StatusCode >= 300 && resp.StatusCode != 304 { raw, _ := io.ReadAll(resp.Body) resp.Body.Close() return nil, fmt.Errorf("Docker API %s %s → HTTP %d : %s", method, path, resp.StatusCode, string(raw)) } return resp, nil } func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def }