diff --git a/backend/Dockerfile b/backend/Dockerfile index a6f5e2b..06ca443 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/cmd/gen-modules/main.go b/backend/cmd/gen-modules/main.go new file mode 100644 index 0000000..c6ca334 --- /dev/null +++ b/backend/cmd/gen-modules/main.go @@ -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) +} diff --git a/backend/internal/api/settings.go b/backend/internal/api/settings.go index 789768f..6ce2478 100644 --- a/backend/internal/api/settings.go +++ b/backend/internal/api/settings.go @@ -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, }) } diff --git a/backend/internal/db/migrations/006_has_backend.sql b/backend/internal/db/migrations/006_has_backend.sql new file mode 100644 index 0000000..8ba294f --- /dev/null +++ b/backend/internal/db/migrations/006_has_backend.sql @@ -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; diff --git a/backend/internal/docker/client.go b/backend/internal/docker/client.go new file mode 100644 index 0000000..28db2fd --- /dev/null +++ b/backend/internal/docker/client.go @@ -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 +} diff --git a/backend/main.go b/backend/main.go index d92df0f..7ccf814 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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 diff --git a/backend/registered_modules.go b/backend/registered_modules.go new file mode 100644 index 0000000..159afc8 --- /dev/null +++ b/backend/registered_modules.go @@ -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) {} diff --git a/docker-compose.yml b/docker-compose.yml index 571f7e4..02275c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,10 +17,25 @@ services: volumes: # Volume persistant pour SQLite, clés JWT, clé maître AES - panel-data:/app/data + # Socket Docker — permet au backend de reconstruire son propre container lors d'un install de module + - /var/run/docker.sock:/var/run/docker.sock + group_add: + # GID du groupe docker sur l'hôte (docker group = accès au socket). + # Trouver avec : getent group docker | cut -d: -f3 + # Surcharger avec : DOCKER_GID=xxx docker compose up -d + - "${DOCKER_GID:-999}" environment: - DATA_DIR=/app/data - LISTEN_ADDR=:3001 - APP_ENV=production + # Identité de ce container (utilisé pour l'auto-rebuild des modules) + - CONTAINER_NAME=proxmoxpanel-backend + # Repo git du CORE (pour docker build --remote lors d'un install module) + - GIT_REPO=https://git.geronzi.fr/proxmoxPanel/core.git + # Branche git à utiliser pour le rebuild + - GIT_BRANCH=frontend/alpine + # Tag de l'image Docker construite + - IMAGE_TAG=proxmoxpanel-backend:latest # Pas de réseau host — le container reste isolé # Les connexions SSH sortantes vers le host Proxmox sont autorisées via le réseau Docker networks: