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:
2026-06-10 13:57:27 +02:00
parent 0294513906
commit abe4e8e619
47 changed files with 404 additions and 12317 deletions
+2 -586
View File
@@ -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 {