core/backend/internal/docker/client.go
enzo a61f805cd0 feat: système de rebuild Docker pour installation de modules has_backend
- internal/docker/client.go : client HTTP brut sur socket Unix
  - BuildImage() : build depuis repo git avec ARG MODULES
  - RebuildAndRestart() : rebuild async + remplacement de container
  - HandleReplacement() : le container successeur arrête et renomme l'ancien
  - Restart() : redémarrage simple (enable/disable sans rebuild)
- cmd/gen-modules/main.go : générateur de registered_modules.go
  Lit MODULES env var, génère imports + appels RegisterModules()
- registered_modules.go : version par défaut (aucun module)
- main.go : appel RegisterModules(loader) + HandleReplacement() au démarrage
- settings.go : inject DockerClient, has_backend dans moduleResp/moduleJSON,
  trigger rebuild à l'install, restart à l'enable/disable
- migrations/006 : colonne has_backend sur table modules
- Dockerfile : ARG MODULES, git clone modules, go run ./cmd/gen-modules
- docker-compose.yml : socket Docker, group_add, env vars CONTAINER_NAME/GIT_REPO/GIT_BRANCH

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 16:54:21 +01:00

416 lines
14 KiB
Go

// 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
}