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:
enzo 2026-03-22 16:54:21 +01:00
parent dcf3b937fa
commit a61f805cd0
8 changed files with 658 additions and 27 deletions

View file

@ -1,18 +1,36 @@
# ── Étape 1 : Build du binaire Go ──────────────────────────────────────────
# Build context = core/backend/ (context: ./backend dans docker-compose.yml)
# ARG MODULES : IDs des modules à compiler, séparés par des virgules (ex: "viewLogs,viewServices")
FROM golang:1.26-alpine AS builder
# Dépendances de compilation (git pour les modules Go)
RUN apk add --no-cache git
RUN apk add --no-cache git ca-certificates
WORKDIR /build
ARG MODULES=""
ENV MODULES=${MODULES}
# Copier les fichiers de dépendances en premier (optimise le cache Docker)
WORKDIR /workspace/core/backend
# Copier les sources du CORE (build context = backend/)
COPY go.mod go.sum ./
RUN go mod download
# Copier tout le code source
COPY . .
# Cloner les modules demandés et les ajouter au go.mod.
# Les replace directives utilisent ../../{module} ce qui correspond à /workspace/{module} ✓
RUN if [ -n "$MODULES" ]; then \
for mod in $(echo "$MODULES" | tr ',' ' '); do \
echo "→ Clonage du module $mod..." && \
git clone "https://git.geronzi.fr/proxmoxPanel/$mod" "/workspace/$mod" && \
printf "\nrequire git.geronzi.fr/proxmoxPanel/$mod v0.0.0\n" >> go.mod && \
printf "\nreplace git.geronzi.fr/proxmoxPanel/$mod => ../../$mod\n" >> go.mod; \
done; \
fi
# Générer registered_modules.go avec les imports et appels RegisterModule corrects
RUN go run ./cmd/gen-modules
# Résoudre et télécharger toutes les dépendances (modules inclus)
RUN go mod tidy && go mod download
# Compiler le binaire de façon statique
# -ldflags="-s -w" : supprime les infos de debug pour réduire la taille
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
@ -21,26 +39,21 @@ RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
# ── Étape 2 : Image finale minimale ────────────────────────────────────────
FROM alpine:3.20
# Certificats CA pour les requêtes HTTPS vers l'API Proxmox
RUN apk add --no-cache ca-certificates tzdata
# Créer un utilisateur non-root pour la sécurité
# Utilisateur non-root pour la sécurité
RUN addgroup -g 1001 pxp && adduser -u 1001 -G pxp -s /bin/sh -D pxp
WORKDIR /app
# Copier le binaire compilé
COPY --from=builder /bin/proxmoxpanel /app/proxmoxpanel
# Créer les répertoires de données avec les bonnes permissions
RUN mkdir -p /app/data && chown -R pxp:pxp /app
USER pxp
# Port d'écoute du backend
EXPOSE 3001
# Variables d'environnement par défaut
ENV DATA_DIR=/app/data \
LISTEN_ADDR=:3001 \
APP_ENV=production

View file

@ -0,0 +1,104 @@
// cmd/gen-modules génère registered_modules.go à partir de la variable d'env MODULES.
// Appelé pendant le build Docker : go run ./cmd/gen-modules
// MODULES = liste d'IDs séparés par des virgules (ex: "viewLogs,viewServices")
// Le fichier généré déclare RegisterModules(*modules.Loader) qui enregistre chaque module.
package main
import (
"fmt"
"os"
"strings"
"text/template"
)
const outputFile = "registered_modules.go"
// moduleDef décrit un module à importer.
type moduleDef struct {
ID string // ex: "viewLogs"
Alias string // ex: "viewlogs" (alias Go valide)
Pkg string // ex: "git.geronzi.fr/proxmoxPanel/viewLogs"
}
// tplNoModules : fichier généré quand aucun module n'est installé.
const tplNoModules = `// Code généré automatiquement par cmd/gen-modules — ne pas modifier manuellement.
// Régénéré lors du build Docker avec la liste des modules compilés.
package main
import "git.geronzi.fr/proxmoxPanel/core/backend/modules"
// RegisterModules enregistre les modules compilés dans le binaire.
func RegisterModules(l *modules.Loader) {}
`
// tplWithModules : fichier généré avec des modules.
const tplWithModules = `// Code généré automatiquement par cmd/gen-modules — ne pas modifier manuellement.
// Régénéré lors du build Docker avec la liste des modules compilés.
package main
import (
"git.geronzi.fr/proxmoxPanel/core/backend/modules"
{{- range .}}
{{.Alias}} "{{.Pkg}}"
{{- end}}
)
// RegisterModules enregistre les modules compilés dans le binaire.
func RegisterModules(l *modules.Loader) {
{{- range .}}
l.RegisterModule({{.Alias}}.New())
{{- end}}
}
`
func main() {
modulesEnv := strings.TrimSpace(os.Getenv("MODULES"))
// Pas de modules : générer la version vide
if modulesEnv == "" {
if err := os.WriteFile(outputFile, []byte(tplNoModules), 0644); err != nil {
fmt.Fprintf(os.Stderr, "Erreur écriture %s : %v\n", outputFile, err)
os.Exit(1)
}
fmt.Printf("Généré %s (aucun module)\n", outputFile)
return
}
// Parser la liste des modules
ids := strings.Split(modulesEnv, ",")
defs := make([]moduleDef, 0, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
// Alias Go = ID en minuscules sans tirets ni underscores
alias := strings.ToLower(strings.NewReplacer("-", "", "_", "").Replace(id))
defs = append(defs, moduleDef{
ID: id,
Alias: alias,
Pkg: "git.geronzi.fr/proxmoxPanel/" + id,
})
}
// Générer le fichier avec les imports et les appels RegisterModule
tmpl, err := template.New("modules").Parse(tplWithModules)
if err != nil {
fmt.Fprintf(os.Stderr, "Erreur template : %v\n", err)
os.Exit(1)
}
f, err := os.Create(outputFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Erreur création %s : %v\n", outputFile, err)
os.Exit(1)
}
defer f.Close()
if err := tmpl.Execute(f, defs); err != nil {
fmt.Fprintf(os.Stderr, "Erreur génération : %v\n", err)
os.Exit(1)
}
fmt.Printf("Généré %s avec %d module(s) : %s\n", outputFile, len(defs), modulesEnv)
}

View file

@ -11,6 +11,7 @@ import (
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
dockerclient "git.geronzi.fr/proxmoxPanel/core/backend/internal/docker"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/logbuffer"
"github.com/go-chi/chi/v5"
)
@ -20,11 +21,12 @@ type SettingsHandler struct {
db *db.DB
auditLogger *audit.Logger
encryptor *crypto.Encryptor
docker *dockerclient.Client
}
// NewSettingsHandler crée un SettingsHandler.
func NewSettingsHandler(database *db.DB, auditLog *audit.Logger, enc *crypto.Encryptor) *SettingsHandler {
return &SettingsHandler{db: database, auditLogger: auditLog, encryptor: enc}
func NewSettingsHandler(database *db.DB, auditLog *audit.Logger, enc *crypto.Encryptor, docker *dockerclient.Client) *SettingsHandler {
return &SettingsHandler{db: database, auditLogger: auditLog, encryptor: enc, docker: docker}
}
// paramètres publics (non-sensibles) accessibles par les admins.
@ -134,6 +136,7 @@ type moduleResp struct {
Version string `json:"version"`
IsCore bool `json:"is_core"`
IsEnabled bool `json:"is_enabled"`
HasBackend bool `json:"has_backend"`
NavHref string `json:"nav_href"`
NavIcon string `json:"nav_icon"`
NavColor string `json:"nav_color"`
@ -144,7 +147,7 @@ type moduleResp struct {
// GET /api/modules
func (h *SettingsHandler) GetModules(w http.ResponseWriter, r *http.Request) {
rows, err := h.db.Query(`
SELECT id, name, description, version, is_core, is_enabled,
SELECT id, name, description, version, is_core, is_enabled, has_backend,
COALESCE(nav_href,''), COALESCE(nav_icon,''), COALESCE(nav_color,''), COALESCE(nav_label_key,'')
FROM modules ORDER BY is_core DESC, name ASC
`)
@ -157,11 +160,12 @@ func (h *SettingsHandler) GetModules(w http.ResponseWriter, r *http.Request) {
var modules []moduleResp
for rows.Next() {
var m moduleResp
var isCore, isEnabled int
rows.Scan(&m.ID, &m.Name, &m.Description, &m.Version, &isCore, &isEnabled,
var isCore, isEnabled, hasBackend int
rows.Scan(&m.ID, &m.Name, &m.Description, &m.Version, &isCore, &isEnabled, &hasBackend,
&m.NavHref, &m.NavIcon, &m.NavColor, &m.NavLabelKey)
m.IsCore = isCore == 1
m.IsEnabled = isEnabled == 1
m.HasBackend = hasBackend == 1
modules = append(modules, m)
}
@ -186,7 +190,10 @@ func (h *SettingsHandler) EnableModule(w http.ResponseWriter, r *http.Request) {
}
h.auditLogger.Log(&claims.UserID, claims.Username, "module_enable", id, nil, clientIP(r))
JSONResponse(w, http.StatusOK, map[string]string{"message": "Module activé (redémarrage requis pour prendre effet)"})
// Si le module a du code backend, redémarrer le container pour que LoadActive() le prenne en compte.
restart := h.triggerRestartIfBackend(id)
JSONResponse(w, http.StatusOK, map[string]interface{}{"message": "Module activé", "restarting": restart})
}
// DisableModule désactive un module (ne peut pas désactiver les modules CORE).
@ -208,7 +215,43 @@ func (h *SettingsHandler) DisableModule(w http.ResponseWriter, r *http.Request)
h.db.Exec(`UPDATE modules SET is_enabled = 0 WHERE id = ?`, id)
h.auditLogger.Log(&claims.UserID, claims.Username, "module_disable", id, nil, clientIP(r))
JSONResponse(w, http.StatusOK, map[string]string{"message": "Module désactivé"})
restart := h.triggerRestartIfBackend(id)
JSONResponse(w, http.StatusOK, map[string]interface{}{"message": "Module désactivé", "restarting": restart})
}
// triggerRestartIfBackend déclenche un redémarrage Docker si le module a du code backend.
// Retourne true si un redémarrage a été déclenché.
func (h *SettingsHandler) triggerRestartIfBackend(moduleID string) bool {
if h.docker == nil || !h.docker.Available() {
return false
}
var hasBackend int
if err := h.db.QueryRow(`SELECT has_backend FROM modules WHERE id = ?`, moduleID).Scan(&hasBackend); err != nil {
return false
}
if hasBackend == 1 {
h.docker.Restart()
return true
}
return false
}
// enabledBackendModuleIDs retourne les IDs de tous les modules installés avec has_backend=1.
// Ces modules doivent être compilés dans le binaire lors d'un rebuild.
func (h *SettingsHandler) enabledBackendModuleIDs() ([]string, error) {
rows, err := h.db.Query(`SELECT id FROM modules WHERE has_backend = 1`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
rows.Scan(&id)
ids = append(ids, id)
}
return ids, nil
}
// GetLogs retourne les dernières lignes du log applicatif (tampon mémoire).
@ -350,6 +393,7 @@ type moduleJSON struct {
NavColor string `json:"nav_color"`
NavLabelKey string `json:"nav_label_key"`
CoreMinVersion string `json:"core_min_version"`
HasBackend bool `json:"has_backend"` // true = nécessite un rebuild Docker pour compilation
}
// InstallRegistryModule installe un module depuis le store Forgejo.
@ -399,17 +443,23 @@ func (h *SettingsHandler) InstallRegistryModule(w http.ResponseWriter, r *http.R
// URL du repo
repoURL := fmt.Sprintf("https://git.geronzi.fr/proxmoxPanel/%s", id)
hasBackend := 0
if mod.HasBackend {
hasBackend = 1
}
// Insérer ou remplacer en DB
_, err = h.db.Exec(`
INSERT INTO modules (id, name, description, version, is_core, is_enabled,
INSERT INTO modules (id, name, description, version, is_core, is_enabled, has_backend,
nav_href, nav_icon, nav_color, nav_label_key, repo_url, installed_at)
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name, description=excluded.description, version=excluded.version,
has_backend=excluded.has_backend,
nav_href=excluded.nav_href, nav_icon=excluded.nav_icon, nav_color=excluded.nav_color,
nav_label_key=excluded.nav_label_key, repo_url=excluded.repo_url,
installed_at=CURRENT_TIMESTAMP
`, mod.ID, mod.Name, mod.Description, mod.Version,
`, mod.ID, mod.Name, mod.Description, mod.Version, hasBackend,
mod.NavHref, mod.NavIcon, mod.NavColor, mod.NavLabelKey, repoURL)
if err != nil {
JSONError(w, fmt.Sprintf("Erreur installation module : %v", err), http.StatusInternalServerError)
@ -417,9 +467,21 @@ func (h *SettingsHandler) InstallRegistryModule(w http.ResponseWriter, r *http.R
}
h.auditLogger.Log(&claims.UserID, claims.Username, "module_install", mod.ID,
map[string]string{"version": mod.Version}, clientIP(r))
map[string]string{"version": mod.Version, "has_backend": fmt.Sprintf("%v", mod.HasBackend)}, clientIP(r))
JSONResponse(w, http.StatusOK, map[string]string{
"message": fmt.Sprintf("Module %s installé — rebuild requis pour activation", mod.ID),
// Si le module a du code backend : déclencher un rebuild Docker.
// Le rebuild compile le nouveau module dans le binaire et recrée le container.
rebuilding := false
if mod.HasBackend && h.docker != nil && h.docker.Available() {
moduleIDs, err := h.enabledBackendModuleIDs()
if err == nil {
h.docker.RebuildAndRestart(moduleIDs)
rebuilding = true
}
}
JSONResponse(w, http.StatusAccepted, map[string]interface{}{
"message": fmt.Sprintf("Module %s installé", mod.ID),
"rebuilding": rebuilding,
})
}

View file

@ -0,0 +1,3 @@
-- Migration 006 : ajout colonne has_backend aux modules
-- Indique si le module contient du code Go backend (nécessite un rebuild Docker pour installation).
ALTER TABLE modules ADD COLUMN has_backend INTEGER NOT NULL DEFAULT 0;

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

View file

@ -13,6 +13,7 @@ import (
"git.geronzi.fr/proxmoxPanel/core/backend/internal/auth"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
dockerclient "git.geronzi.fr/proxmoxPanel/core/backend/internal/docker"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/logbuffer"
sshpool "git.geronzi.fr/proxmoxPanel/core/backend/internal/ssh"
"git.geronzi.fr/proxmoxPanel/core/backend/internal/websocket"
@ -26,6 +27,14 @@ func main() {
// Brancher le buffer de logs (stderr + mémoire) avant tout autre log
log.SetOutput(io.MultiWriter(os.Stderr, logbuffer.Global))
// ── Gestion du remplacement de container (rebuild module) ──────────────
// Si REPLACING_CONTAINER est défini, ce container est un successeur issu d'un rebuild.
// Il arrête l'ancien container et se renomme avant de continuer le démarrage normal.
docker := dockerclient.New()
if docker.Available() {
docker.HandleReplacement()
}
// Répertoire de données persistantes (volume Docker)
dataDir := getEnv("DATA_DIR", "/app/data")
@ -68,6 +77,7 @@ func main() {
// ── Chargement des modules actifs ──────────────────────────────────────
loader := modules.NewLoader(database, sshPool, encryptor)
RegisterModules(loader) // généré par cmd/gen-modules selon les modules compilés
if err := loader.LoadActive(); err != nil {
log.Fatalf("Erreur chargement modules : %v", err)
}
@ -77,7 +87,7 @@ func main() {
authHandler := api.NewAuthHandler(database, jwtManager, sshAuthenticator, auditLogger)
proxmoxHandler := api.NewProxmoxHandler(database, hub, auditLogger, encryptor)
updatesHandler := api.NewUpdatesHandler(database, sshPool, hub, auditLogger, encryptor)
settingsHandler := api.NewSettingsHandler(database, auditLogger, encryptor)
settingsHandler := api.NewSettingsHandler(database, auditLogger, encryptor, docker)
terminalHandler := api.NewTerminalHandler(database, auditLogger, encryptor)
// Démarrer le polling Proxmox en arrière-plan

View file

@ -0,0 +1,8 @@
// Code généré automatiquement par cmd/gen-modules — ne pas modifier manuellement.
// Régénéré lors du build Docker avec la liste des modules compilés.
package main
import "git.geronzi.fr/proxmoxPanel/core/backend/modules"
// RegisterModules enregistre les modules compilés dans le binaire.
func RegisterModules(l *modules.Loader) {}