slice 8C Phase B.2 + C.1/C.2: retire disk subsystem + rewire disk mgmt to agent
Retired (~12.3k LOC): internal/storage/* (scan/format/attach/migrate/safety), backup restic/crossdrive/restore_drives/disk_layout/local_infra/restore_scan/ paths + restore_app, report/infra_backup*/infra_pull, setup/scanner, monitor/watchdog+pinger, web/storage_handlers+handler_restore. Surgically split backup.Manager to app-data only (DB dumps + volume tars + app restore; dropped restic + cross-drive + snapshot history). Fixed router/main/web wiring. Added agent-backed disk API (web/agent_disk_handlers.go): /api/disks list/ assign/eject/format proxying agentapi; data-bearing format refusal -> HTTP 409 'operator authorization required'. report/config_pull.go keeps the setup fresh-install config download. go build + go test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,10 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
@@ -15,10 +12,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
@@ -38,27 +33,6 @@ type Server struct {
|
||||
tmpl *template.Template
|
||||
state *SetupState
|
||||
version string
|
||||
|
||||
// Scan state for async drive scanning
|
||||
scanMu sync.Mutex
|
||||
scanRunning bool
|
||||
scanResults []DriveBackup
|
||||
scanDone bool
|
||||
scanError string
|
||||
|
||||
// Restore progress
|
||||
restoreMu sync.Mutex
|
||||
restoreRunning bool
|
||||
restoreSteps []RestoreStep
|
||||
restoreError string
|
||||
restoreDone bool
|
||||
}
|
||||
|
||||
// RestoreStep tracks progress of a restore operation.
|
||||
type RestoreStep struct {
|
||||
Label string `json:"label"`
|
||||
Status string `json:"status"` // "pending", "running", "done", "failed"
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewServer creates a new setup wizard server.
|
||||
@@ -111,14 +85,10 @@ func (s *Server) loadTemplates() {
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Disk-recovery setup paths (drive scan, infra-backup restore) have moved to the
|
||||
// host agent (slice 8C). The wizard now offers fresh install (from Hub) + manual.
|
||||
mux.HandleFunc("/", s.handleRoot)
|
||||
mux.HandleFunc("/setup", s.handleWelcome)
|
||||
mux.HandleFunc("/setup/scan", s.handleScan)
|
||||
mux.HandleFunc("/setup/scan/status", s.handleScanStatus)
|
||||
mux.HandleFunc("/setup/hub-restore", s.handleHubRestore)
|
||||
mux.HandleFunc("/setup/hub-restore/select", s.handleHubVersionSelect)
|
||||
mux.HandleFunc("/setup/restore", s.handleRestore)
|
||||
mux.HandleFunc("/setup/restore/status", s.handleRestoreStatus)
|
||||
mux.HandleFunc("/setup/fresh", s.handleFreshHub)
|
||||
mux.HandleFunc("/setup/manual", s.handleManual)
|
||||
mux.HandleFunc("/setup/failed", s.handleFailed)
|
||||
@@ -161,110 +131,6 @@ func (s *Server) handleWelcome(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, "setup_welcome", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
|
||||
// Start scan if not already running
|
||||
s.scanMu.Lock()
|
||||
if !s.scanRunning && !s.scanDone {
|
||||
s.scanRunning = true
|
||||
go s.runDriveScan()
|
||||
}
|
||||
s.scanMu.Unlock()
|
||||
|
||||
s.state.SetStep("scan")
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
}
|
||||
s.render(w, "setup_scan", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleScanStatus(w http.ResponseWriter, r *http.Request) {
|
||||
s.scanMu.Lock()
|
||||
defer s.scanMu.Unlock()
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"running": s.scanRunning,
|
||||
"done": s.scanDone,
|
||||
"results": s.scanResults,
|
||||
"error": s.scanError,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleHubRestore(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if !validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.processHubRestore(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-process if credentials are pre-seeded (hub mode from docker-setup.sh)
|
||||
if s.isHubPreseeded() {
|
||||
customerID := s.state.GetFormField("customer_id")
|
||||
password := s.state.GetFormField("retrieval_password")
|
||||
s.autoProcessHubRestore(w, r, customerID, password)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"CustomerID": s.state.GetFormField("customer_id"),
|
||||
}
|
||||
s.render(w, "setup_hub_restore", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, "/setup", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
source := r.FormValue("source")
|
||||
switch source {
|
||||
case "local":
|
||||
drivePath := r.FormValue("drive_path")
|
||||
historyFile := r.FormValue("history_file")
|
||||
go s.executeLocalRestore(drivePath, historyFile)
|
||||
case "hub":
|
||||
go s.executeHubRestore()
|
||||
default:
|
||||
http.Error(w, "Invalid restore source", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
}
|
||||
s.render(w, "setup_restore_exec", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleRestoreStatus(w http.ResponseWriter, r *http.Request) {
|
||||
s.restoreMu.Lock()
|
||||
defer s.restoreMu.Unlock()
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"running": s.restoreRunning,
|
||||
"done": s.restoreDone,
|
||||
"steps": s.restoreSteps,
|
||||
"error": s.restoreError,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func (s *Server) handleFreshHub(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
|
||||
@@ -348,82 +214,6 @@ func (s *Server) handleLogo(w http.ResponseWriter, r *http.Request) {
|
||||
// autoProcessHubRestore calls PullRecovery with pre-seeded credentials and
|
||||
// renders the confirmation page directly, skipping the manual form.
|
||||
// Falls back to the form with an error message on failure.
|
||||
func (s *Server) autoProcessHubRestore(w http.ResponseWriter, r *http.Request, customerID, password string) {
|
||||
hubURL := DefaultHubURL
|
||||
|
||||
s.logger.Printf("[INFO] Setup: auto-processing hub restore for %s (pre-seeded credentials)", customerID)
|
||||
|
||||
recovery, err := report.PullRecovery(hubURL, customerID, password)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] Setup: auto hub restore failed: %v — falling back to form", err)
|
||||
var msg string
|
||||
switch {
|
||||
case isError(err, report.ErrHubUnreachable):
|
||||
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
|
||||
case isError(err, report.ErrAuthFailed):
|
||||
msg = "Helytelen ügyfél-azonosító vagy jelszó."
|
||||
case isError(err, report.ErrNotFound):
|
||||
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
|
||||
default:
|
||||
msg = fmt.Sprintf("Hiba történt: %v", err)
|
||||
}
|
||||
// Clear pre-seeded password so form is shown on next attempt
|
||||
s.state.SetFormField("retrieval_password", "")
|
||||
s.state.Save()
|
||||
s.renderError(w, r, "setup_hub_restore", msg, customerID)
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d, versions=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML), len(recovery.BackupVersions))
|
||||
}
|
||||
|
||||
// If multiple versions available, show picker instead of auto-restoring
|
||||
if len(recovery.BackupVersions) > 1 && recovery.HasInfraBackup {
|
||||
s.logger.Printf("[INFO] Setup: %d backup versions available — showing version picker", len(recovery.BackupVersions))
|
||||
// Store config for later use after version selection
|
||||
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
||||
s.state.Save()
|
||||
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"Versions": recovery.BackupVersions,
|
||||
}
|
||||
s.render(w, "setup_hub_versions", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Single version or no versions — proceed directly
|
||||
s.storeRecoveryAndRestore(w, r, recovery, customerID)
|
||||
}
|
||||
|
||||
// storeRecoveryAndRestore stores recovery data in state and starts the restore goroutine.
|
||||
func (s *Server) storeRecoveryAndRestore(w http.ResponseWriter, r *http.Request, recovery *report.RecoveryResponse, customerID string) {
|
||||
s.state.SelectedBackup = &SelectedBackup{
|
||||
Source: "hub",
|
||||
CustomerID: customerID,
|
||||
}
|
||||
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
||||
if recovery.HasInfraBackup && recovery.InfraBackup != nil {
|
||||
ibJSON, _ := json.Marshal(recovery.InfraBackup)
|
||||
s.state.SetFormField("hub_infra_backup", string(ibJSON))
|
||||
s.state.SelectedBackup.Timestamp = recovery.InfraBackup.Timestamp
|
||||
}
|
||||
s.state.SetStep("restore-exec")
|
||||
s.state.Save()
|
||||
|
||||
s.logger.Printf("[INFO] Setup: hub recovery stored (hasInfra=%v) — starting restore", recovery.HasInfraBackup)
|
||||
|
||||
go s.executeHubRestore()
|
||||
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
}
|
||||
s.render(w, "setup_restore_exec", data)
|
||||
}
|
||||
|
||||
// autoProcessFreshHub calls PullConfig with pre-seeded credentials and
|
||||
// proceeds with fresh install, skipping the manual form.
|
||||
func (s *Server) autoProcessFreshHub(w http.ResponseWriter, r *http.Request, customerID, password string) {
|
||||
@@ -466,104 +256,6 @@ func (s *Server) autoProcessFreshHub(w http.ResponseWriter, r *http.Request, cus
|
||||
|
||||
// --- Processing Logic ---
|
||||
|
||||
func (s *Server) processHubRestore(w http.ResponseWriter, r *http.Request) {
|
||||
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
||||
password := r.FormValue("password")
|
||||
hubURL := DefaultHubURL
|
||||
|
||||
s.state.SetFormField("customer_id", customerID)
|
||||
|
||||
if customerID == "" || password == "" {
|
||||
s.renderError(w, r, "setup_hub_restore", "Kérem töltse ki mindkét mezőt.", customerID)
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] Setup: hub restore — pulling recovery from %s for customer %s", hubURL, customerID)
|
||||
}
|
||||
recovery, err := report.PullRecovery(hubURL, customerID, password)
|
||||
if err != nil {
|
||||
var msg string
|
||||
switch {
|
||||
case isError(err, report.ErrHubUnreachable):
|
||||
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
|
||||
case isError(err, report.ErrAuthFailed):
|
||||
msg = "Helytelen ügyfél-azonosító vagy jelszó."
|
||||
case isError(err, report.ErrNotFound):
|
||||
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
|
||||
default:
|
||||
msg = fmt.Sprintf("Hiba történt: %v", err)
|
||||
}
|
||||
s.renderError(w, r, "setup_hub_restore", msg, customerID)
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d, versions=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML), len(recovery.BackupVersions))
|
||||
}
|
||||
|
||||
s.state.SetFormField("retrieval_password", password)
|
||||
|
||||
// If multiple versions available, show picker
|
||||
if len(recovery.BackupVersions) > 1 && recovery.HasInfraBackup {
|
||||
s.logger.Printf("[INFO] Setup: %d backup versions available — showing version picker", len(recovery.BackupVersions))
|
||||
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
||||
s.state.Save()
|
||||
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"Versions": recovery.BackupVersions,
|
||||
}
|
||||
s.render(w, "setup_hub_versions", data)
|
||||
return
|
||||
}
|
||||
|
||||
s.storeRecoveryAndRestore(w, r, recovery, customerID)
|
||||
}
|
||||
|
||||
// handleHubVersionSelect processes the user's version selection from the Hub version picker.
|
||||
func (s *Server) handleHubVersionSelect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, "/setup/hub-restore", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if !validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
versionStr := r.FormValue("version_id")
|
||||
customerID := s.state.GetFormField("customer_id")
|
||||
password := s.state.GetFormField("retrieval_password")
|
||||
hubURL := DefaultHubURL
|
||||
|
||||
if customerID == "" || password == "" {
|
||||
http.Redirect(w, r, "/setup/hub-restore", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
var versionID int64
|
||||
fmt.Sscanf(versionStr, "%d", &versionID)
|
||||
|
||||
s.logger.Printf("[INFO] Setup: user selected backup version %d for %s", versionID, customerID)
|
||||
|
||||
// Fetch the specific version
|
||||
recovery, err := report.PullRecoveryVersion(hubURL, customerID, password, versionID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] Setup: failed to fetch version %d: %v", versionID, err)
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"Error": fmt.Sprintf("Hiba a verzió letöltésekor: %v", err),
|
||||
}
|
||||
s.render(w, "setup_hub_versions", data)
|
||||
return
|
||||
}
|
||||
|
||||
s.storeRecoveryAndRestore(w, r, recovery, customerID)
|
||||
}
|
||||
|
||||
func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) {
|
||||
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
||||
password := r.FormValue("password")
|
||||
@@ -676,232 +368,6 @@ func (s *Server) processManual(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// --- Restore Execution ---
|
||||
|
||||
func (s *Server) executeLocalRestore(drivePath, historyFile string) {
|
||||
s.restoreMu.Lock()
|
||||
s.restoreRunning = true
|
||||
s.restoreDone = false
|
||||
s.restoreError = ""
|
||||
s.restoreSteps = []RestoreStep{
|
||||
{Label: "Mentés beolvasása...", Status: "running"},
|
||||
{Label: "Konfiguráció visszaállítása...", Status: "pending"},
|
||||
{Label: "Meghajtók csatolása...", Status: "pending"},
|
||||
{Label: "Beállítás befejezése...", Status: "pending"},
|
||||
}
|
||||
s.restoreMu.Unlock()
|
||||
|
||||
// Step 1: Read backup (current or historical version)
|
||||
var backupData []byte
|
||||
var err error
|
||||
if historyFile != "" {
|
||||
backupData, _, err = backup.ReadLocalInfraBackupFromHistory(drivePath, historyFile)
|
||||
} else {
|
||||
backupData, _, err = backup.ReadLocalInfraBackup(drivePath)
|
||||
}
|
||||
if err != nil {
|
||||
s.setRestoreError(0, fmt.Sprintf("Mentés olvasási hiba: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var ib report.InfraBackup
|
||||
if err := json.Unmarshal(backupData, &ib); err != nil {
|
||||
s.setRestoreError(0, fmt.Sprintf("Mentés formátum hiba: %v", err))
|
||||
return
|
||||
}
|
||||
s.setRestoreStepDone(0)
|
||||
|
||||
// Step 2: Write config files
|
||||
s.setRestoreStepRunning(1)
|
||||
if err := s.writeRestoredConfig(&ib); err != nil {
|
||||
s.setRestoreError(1, fmt.Sprintf("Konfiguráció írási hiba: %v", err))
|
||||
return
|
||||
}
|
||||
s.setRestoreStepDone(1)
|
||||
|
||||
// Step 3: Mount drives from disk layout
|
||||
s.setRestoreStepRunning(2)
|
||||
s.mountDrivesFromBackup(&ib)
|
||||
s.setRestoreStepDone(2)
|
||||
|
||||
// Step 4: Finalize
|
||||
s.setRestoreStepRunning(3)
|
||||
|
||||
// Save retrieval password from state if available
|
||||
retrievalPw := s.state.GetFormField("retrieval_password")
|
||||
if retrievalPw != "" {
|
||||
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
||||
if err == nil {
|
||||
sett.SetRetrievalPassword(retrievalPw)
|
||||
}
|
||||
}
|
||||
|
||||
// Queue DR event
|
||||
s.queueDREvent("local", ib.Timestamp, len(ib.DeployedStacks))
|
||||
|
||||
s.setRestoreStepDone(3)
|
||||
|
||||
s.restoreMu.Lock()
|
||||
s.restoreRunning = false
|
||||
s.restoreDone = true
|
||||
s.restoreMu.Unlock()
|
||||
|
||||
s.logger.Printf("[INFO] Setup: local restore completed from %s", drivePath)
|
||||
|
||||
// Wait a moment for the UI to poll, then exit
|
||||
time.Sleep(2 * time.Second)
|
||||
s.finishSetup()
|
||||
}
|
||||
|
||||
func (s *Server) executeHubRestore() {
|
||||
s.restoreMu.Lock()
|
||||
s.restoreRunning = true
|
||||
s.restoreDone = false
|
||||
s.restoreError = ""
|
||||
s.restoreSteps = []RestoreStep{
|
||||
{Label: "Konfiguráció visszaállítása...", Status: "running"},
|
||||
{Label: "Meghajtók csatolása...", Status: "pending"},
|
||||
{Label: "Beállítás befejezése...", Status: "pending"},
|
||||
}
|
||||
s.restoreMu.Unlock()
|
||||
|
||||
// Get stored data from state
|
||||
configYAML := s.state.GetFormField("hub_config_yaml")
|
||||
ibJSON := s.state.GetFormField("hub_infra_backup")
|
||||
|
||||
// Write controller.yaml
|
||||
configPath := "/opt/docker/felhom-controller/controller.yaml"
|
||||
if err := atomicWriteFile(configPath, []byte(configYAML), 0600); err != nil {
|
||||
s.setRestoreError(0, fmt.Sprintf("Konfiguráció írási hiba: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Restore settings from infra backup if available
|
||||
var restoredIB *report.InfraBackup
|
||||
if ibJSON != "" {
|
||||
var ib report.InfraBackup
|
||||
if err := json.Unmarshal([]byte(ibJSON), &ib); err == nil {
|
||||
s.restoreFromInfraBackup(&ib)
|
||||
restoredIB = &ib
|
||||
}
|
||||
}
|
||||
s.setRestoreStepDone(0)
|
||||
|
||||
// Step 2: Mount drives from disk layout
|
||||
s.setRestoreStepRunning(1)
|
||||
if restoredIB != nil {
|
||||
s.mountDrivesFromBackup(restoredIB)
|
||||
}
|
||||
s.setRestoreStepDone(1)
|
||||
|
||||
// Step 3: Finalize
|
||||
s.setRestoreStepRunning(2)
|
||||
|
||||
// Save retrieval password
|
||||
retrievalPw := s.state.GetFormField("retrieval_password")
|
||||
if retrievalPw != "" {
|
||||
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
||||
if err == nil {
|
||||
sett.SetRetrievalPassword(retrievalPw)
|
||||
}
|
||||
}
|
||||
|
||||
// Queue DR event
|
||||
stackCount := 0
|
||||
timestamp := ""
|
||||
if restoredIB != nil {
|
||||
stackCount = len(restoredIB.DeployedStacks)
|
||||
timestamp = restoredIB.Timestamp
|
||||
}
|
||||
s.queueDREvent("hub", timestamp, stackCount)
|
||||
|
||||
s.setRestoreStepDone(2)
|
||||
|
||||
s.restoreMu.Lock()
|
||||
s.restoreRunning = false
|
||||
s.restoreDone = true
|
||||
s.restoreMu.Unlock()
|
||||
|
||||
s.logger.Printf("[INFO] Setup: Hub restore completed")
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
s.finishSetup()
|
||||
}
|
||||
|
||||
// --- Config Writing ---
|
||||
|
||||
func (s *Server) writeRestoredConfig(ib *report.InfraBackup) error {
|
||||
// Decode and write controller.yaml
|
||||
if ib.ControllerConfigB64 != "" {
|
||||
configData, err := base64.StdEncoding.DecodeString(ib.ControllerConfigB64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding controller.yaml: %w", err)
|
||||
}
|
||||
configPath := "/opt/docker/felhom-controller/controller.yaml"
|
||||
if err := atomicWriteFile(configPath, configData, 0600); err != nil {
|
||||
return fmt.Errorf("writing controller.yaml: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.restoreFromInfraBackup(ib)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) restoreFromInfraBackup(ib *report.InfraBackup) {
|
||||
// Decode and write settings.json
|
||||
if ib.SettingsJSONB64 != "" {
|
||||
if data, err := base64.StdEncoding.DecodeString(ib.SettingsJSONB64); err == nil {
|
||||
settingsPath := filepath.Join(s.dataDir, "settings.json")
|
||||
if err := atomicWriteFile(settingsPath, data, 0644); err != nil {
|
||||
s.logger.Printf("[WARN] Setup: failed to restore settings.json: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore restic password
|
||||
if ib.ResticPassword != "" {
|
||||
if data, err := base64.StdEncoding.DecodeString(ib.ResticPassword); err == nil {
|
||||
pwFile := "/opt/docker/felhom-controller/data/restic-password"
|
||||
if err := atomicWriteFile(pwFile, data, 0600); err != nil {
|
||||
s.logger.Printf("[WARN] Setup: failed to restore restic password: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore encryption key for app.yaml secrets
|
||||
if ib.EncryptionKeyB64 != "" {
|
||||
if data, err := base64.StdEncoding.DecodeString(ib.EncryptionKeyB64); err == nil {
|
||||
keyFile := filepath.Join(s.dataDir, "encryption.key")
|
||||
if err := atomicWriteFile(keyFile, data, 0600); err != nil {
|
||||
s.logger.Printf("[WARN] Setup: failed to restore encryption key: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Signal that FileBrowser's database should be reset on next startup.
|
||||
// After restore, the DB has stale source preferences from the initial install.
|
||||
flagPath := filepath.Join(s.dataDir, ".fb-reset")
|
||||
_ = os.WriteFile(flagPath, []byte("restore"), 0644)
|
||||
}
|
||||
|
||||
// mountDrivesFromBackup mounts drives from the infra backup's disk layout.
|
||||
// Best-effort: logs warnings on failure but does not block restore.
|
||||
func (s *Server) mountDrivesFromBackup(ib *report.InfraBackup) {
|
||||
if len(ib.DiskLayout.Mounts) == 0 {
|
||||
s.logger.Printf("[INFO] Setup: no drives in disk layout to mount")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
mounted, err := backup.MountDrivesFromLayout(ctx, ib.DiskLayout, s.logger)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] Setup: drive mounting error: %v", err)
|
||||
}
|
||||
if len(mounted) > 0 {
|
||||
s.logger.Printf("[INFO] Setup: mounted %d drive(s): %v", len(mounted), mounted)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeFreshConfig(configYAML, retrievalPassword string) error {
|
||||
configPath := "/opt/docker/felhom-controller/controller.yaml"
|
||||
if err := atomicWriteFile(configPath, []byte(configYAML), 0600); err != nil {
|
||||
@@ -1055,56 +521,6 @@ func (s *Server) finishSetup() {
|
||||
os.Exit(0) // Docker restart policy will restart us
|
||||
}
|
||||
|
||||
func (s *Server) queueDREvent(source, backupTimestamp string, stackCount int) {
|
||||
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] Setup: failed to load settings for DR event: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
details, _ := json.Marshal(map[string]interface{}{
|
||||
"source": source,
|
||||
"backup_timestamp": backupTimestamp,
|
||||
"stacks_count": stackCount,
|
||||
"controller_version": s.version,
|
||||
})
|
||||
|
||||
sett.AddPendingEvent(settings.PendingEvent{
|
||||
EventType: "disaster_recovery_completed",
|
||||
Severity: "warning",
|
||||
Message: "System restored from backup",
|
||||
Details: string(details),
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) setRestoreStepDone(idx int) {
|
||||
s.restoreMu.Lock()
|
||||
defer s.restoreMu.Unlock()
|
||||
if idx < len(s.restoreSteps) {
|
||||
s.restoreSteps[idx].Status = "done"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) setRestoreStepRunning(idx int) {
|
||||
s.restoreMu.Lock()
|
||||
defer s.restoreMu.Unlock()
|
||||
if idx < len(s.restoreSteps) {
|
||||
s.restoreSteps[idx].Status = "running"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) setRestoreError(idx int, msg string) {
|
||||
s.restoreMu.Lock()
|
||||
defer s.restoreMu.Unlock()
|
||||
if idx < len(s.restoreSteps) {
|
||||
s.restoreSteps[idx].Status = "failed"
|
||||
s.restoreSteps[idx].Error = msg
|
||||
}
|
||||
s.restoreRunning = false
|
||||
s.restoreError = msg
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
|
||||
// DriveBackup represents a found infra backup on a drive.
|
||||
type DriveBackup struct {
|
||||
Device string `json:"device"`
|
||||
Label string `json:"label"`
|
||||
MountPoint string `json:"mount_point"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
CtrlVersion string `json:"controller_version"`
|
||||
IntegrityOK bool `json:"integrity_ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StackCount int `json:"stack_count"`
|
||||
StackNames []string `json:"stack_names,omitempty"`
|
||||
DiskCount int `json:"disk_count"`
|
||||
IsHistory bool `json:"is_history"`
|
||||
HistoryFile string `json:"history_file,omitempty"`
|
||||
WasTempMounted bool `json:"-"`
|
||||
}
|
||||
|
||||
// lsblkOutput represents the JSON output of lsblk.
|
||||
type lsblkOutput struct {
|
||||
Blockdevices []lsblkDevice `json:"blockdevices"`
|
||||
}
|
||||
|
||||
type lsblkDevice struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
FSType *string `json:"fstype"`
|
||||
MountPoint *string `json:"mountpoint"`
|
||||
Label *string `json:"label"`
|
||||
Size interface{} `json:"size"` // string or int
|
||||
Type string `json:"type"` // "disk", "part"
|
||||
Children []lsblkDevice `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// ScanDrivesForInfraBackups scans all block devices for .felhom-infra-backup/ directories.
|
||||
func ScanDrivesForInfraBackups(logger *log.Logger, debug bool) ([]DriveBackup, error) {
|
||||
logger.Printf("[INFO] Setup: scanning drives for infra backups...")
|
||||
|
||||
// Read currently mounted filesystems
|
||||
mountedFS := readMountedFilesystems()
|
||||
|
||||
// Get root device to skip
|
||||
rootDevices := getRootDevices()
|
||||
|
||||
// Run lsblk
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx, "lsblk", "-J", "-o", "NAME,PATH,FSTYPE,MOUNTPOINT,LABEL,SIZE,TYPE").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lsblk failed: %w", err)
|
||||
}
|
||||
|
||||
var lsblk lsblkOutput
|
||||
if err := json.Unmarshal(out, &lsblk); err != nil {
|
||||
return nil, fmt.Errorf("parsing lsblk: %w", err)
|
||||
}
|
||||
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] Setup scan: lsblk returned %d block devices", len(lsblk.Blockdevices))
|
||||
}
|
||||
|
||||
var results []DriveBackup
|
||||
|
||||
// Flatten all partitions
|
||||
var partitions []lsblkDevice
|
||||
for _, disk := range lsblk.Blockdevices {
|
||||
if disk.Type == "part" {
|
||||
partitions = append(partitions, disk)
|
||||
}
|
||||
for _, child := range disk.Children {
|
||||
if child.Type == "part" {
|
||||
partitions = append(partitions, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if debug {
|
||||
logger.Printf("[DEBUG] Setup scan: found %d partitions to check, %d root devices to skip", len(partitions), len(rootDevices))
|
||||
}
|
||||
|
||||
for _, part := range partitions {
|
||||
// Skip partitions without filesystem
|
||||
if part.FSType == nil || *part.FSType == "" || *part.FSType == "swap" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip LUKS encrypted partitions
|
||||
if *part.FSType == "crypto_LUKS" {
|
||||
logger.Printf("[DEBUG] Setup: skipping LUKS partition %s", part.Path)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip LVM
|
||||
if part.Type == "lvm" {
|
||||
logger.Printf("[DEBUG] Setup: skipping LVM volume %s", part.Path)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip root partitions
|
||||
if isRootPartition(part.Path, rootDevices) {
|
||||
continue
|
||||
}
|
||||
|
||||
partResults := scanPartition(part, mountedFS, logger)
|
||||
results = append(results, partResults...)
|
||||
}
|
||||
|
||||
logger.Printf("[INFO] Setup: drive scan complete — found %d backup(s)", countValid(results))
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// CleanupTempMounts unmounts any partitions that were temporarily mounted during scanning.
|
||||
func CleanupTempMounts(results []DriveBackup, logger *log.Logger) {
|
||||
for _, r := range results {
|
||||
if r.WasTempMounted && r.MountPoint != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
exec.CommandContext(ctx, "umount", r.MountPoint).Run()
|
||||
cancel()
|
||||
os.Remove(r.MountPoint)
|
||||
logger.Printf("[DEBUG] Setup: unmounted temp mount %s", r.MountPoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Logger) []DriveBackup {
|
||||
label := ""
|
||||
if part.Label != nil {
|
||||
label = *part.Label
|
||||
}
|
||||
|
||||
// Check if already mounted
|
||||
var mountPoint string
|
||||
var tempMounted bool
|
||||
|
||||
if part.MountPoint != nil && *part.MountPoint != "" {
|
||||
mountPoint = *part.MountPoint
|
||||
} else if mp, ok := mountedFS[part.Path]; ok {
|
||||
mountPoint = mp
|
||||
} else {
|
||||
// Try to mount temporarily
|
||||
tmpDir := filepath.Join("/mnt", ".felhom-scan", part.Name)
|
||||
if err := os.MkdirAll(tmpDir, 0700); err != nil {
|
||||
logger.Printf("[DEBUG] Setup: skip %s — cannot create temp dir: %v", part.Path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Try read-only mount
|
||||
err := exec.CommandContext(ctx, "mount", "-o", "ro", part.Path, tmpDir).Run()
|
||||
if err != nil {
|
||||
// Retry with noload for journal errors
|
||||
err = exec.CommandContext(ctx, "mount", "-o", "ro,noload", part.Path, tmpDir).Run()
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(tmpDir)
|
||||
logger.Printf("[DEBUG] Setup: skip %s — mount failed: %v", part.Path, err)
|
||||
return nil
|
||||
}
|
||||
mountPoint = tmpDir
|
||||
tempMounted = true
|
||||
}
|
||||
|
||||
// Check for .felhom-infra-backup/
|
||||
infraDir := backup.InfraBackupDir(mountPoint)
|
||||
if _, err := os.Stat(infraDir); os.IsNotExist(err) {
|
||||
if tempMounted {
|
||||
exec.Command("umount", mountPoint).Run()
|
||||
os.Remove(mountPoint)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []DriveBackup
|
||||
|
||||
// Read current backup
|
||||
backupData, meta, err := backup.ReadLocalInfraBackup(mountPoint)
|
||||
|
||||
current := DriveBackup{
|
||||
Device: part.Path,
|
||||
Label: label,
|
||||
MountPoint: mountPoint,
|
||||
WasTempMounted: tempMounted,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
current.IntegrityOK = false
|
||||
current.Error = err.Error()
|
||||
if meta != nil {
|
||||
current.CustomerID = meta.CustomerID
|
||||
current.Timestamp = meta.Timestamp
|
||||
current.CtrlVersion = meta.ControllerVersion
|
||||
}
|
||||
} else {
|
||||
current.IntegrityOK = true
|
||||
current.CustomerID = meta.CustomerID
|
||||
current.Timestamp = meta.Timestamp
|
||||
current.CtrlVersion = meta.ControllerVersion
|
||||
backup.ParseBackupCounts(backupData, ¤t.StackCount, ¤t.StackNames, ¤t.DiskCount)
|
||||
}
|
||||
|
||||
results = append(results, current)
|
||||
|
||||
logger.Printf("[INFO] Setup: found infra backup on %s (%s) — customer=%s, integrity=%v",
|
||||
part.Path, label, current.CustomerID, current.IntegrityOK)
|
||||
|
||||
// Also scan history directory for older versions
|
||||
history := backup.ReadLocalInfraHistory(mountPoint)
|
||||
for _, hv := range history {
|
||||
hResult := DriveBackup{
|
||||
Device: part.Path,
|
||||
Label: label,
|
||||
MountPoint: mountPoint,
|
||||
CustomerID: hv.CustomerID,
|
||||
Timestamp: hv.Timestamp,
|
||||
CtrlVersion: hv.ControllerVersion,
|
||||
IntegrityOK: hv.IntegrityOK,
|
||||
Error: hv.Error,
|
||||
StackCount: hv.StackCount,
|
||||
StackNames: hv.StackNames,
|
||||
DiskCount: hv.DiskCount,
|
||||
IsHistory: true,
|
||||
HistoryFile: hv.HistoryFile,
|
||||
}
|
||||
results = append(results, hResult)
|
||||
}
|
||||
|
||||
if len(history) > 0 {
|
||||
logger.Printf("[INFO] Setup: found %d historical backup version(s) on %s", len(history), part.Path)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func readMountedFilesystems() map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
f, err := os.Open("/proc/mounts")
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) >= 2 {
|
||||
result[fields[0]] = fields[1]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func getRootDevices() map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
mountedFS := readMountedFilesystems()
|
||||
for dev, mp := range mountedFS {
|
||||
if mp == "/" || mp == "/boot" || mp == "/boot/efi" {
|
||||
result[dev] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isRootPartition(devPath string, rootDevices map[string]bool) bool {
|
||||
return rootDevices[devPath]
|
||||
}
|
||||
|
||||
func countValid(results []DriveBackup) int {
|
||||
n := 0
|
||||
for _, r := range results {
|
||||
if r.IntegrityOK {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// runDriveScan runs the scan asynchronously and stores results on the Server.
|
||||
func (s *Server) runDriveScan() {
|
||||
results, err := ScanDrivesForInfraBackups(s.logger, s.isDebug())
|
||||
|
||||
// Clean up any temporary mounts created during scan
|
||||
if results != nil {
|
||||
CleanupTempMounts(results, s.logger)
|
||||
}
|
||||
|
||||
s.scanMu.Lock()
|
||||
defer s.scanMu.Unlock()
|
||||
|
||||
s.scanRunning = false
|
||||
s.scanDone = true
|
||||
if err != nil {
|
||||
s.scanError = err.Error()
|
||||
} else {
|
||||
s.scanResults = results
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user