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>
This commit is contained in:
parent
dcf3b937fa
commit
a61f805cd0
8 changed files with 658 additions and 27 deletions
416
backend/internal/docker/client.go
Normal file
416
backend/internal/docker/client.go
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue