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
+24 -415
View File
@@ -20,7 +20,6 @@ import (
"golang.org/x/crypto/bcrypt"
)
// protectedStackSubdomains maps programmatically managed protected stacks
// to their well-known subdomains (these stacks have no .felhom.yml or app.yaml).
var protectedStackSubdomains = map[string]string{
@@ -29,8 +28,8 @@ var protectedStackSubdomains = map[string]string{
// StorageBarInfo holds data for rendering a storage usage bar on dashboard/monitoring.
type StorageBarInfo struct {
Label string // e.g., "USB HDD 1TB", "SYS Storage 350G"
Path string // e.g., "/mnt/hdd_1"
Label string // e.g., "USB HDD 1TB", "SYS Storage 350G"
Path string // e.g., "/mnt/hdd_1"
TotalGB float64
UsedGB float64
Percent float64
@@ -148,34 +147,10 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
data["BackupEnabled"] = s.cfg.Backup.Enabled
if s.backupMgr != nil {
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
data["DBDumpStatus"] = fullStatus.LastDBDump
data["BackupStatus"] = fullStatus.LastBackup
data["BackupRunning"] = fullStatus.Running
data["BackupMaxAgeHours"] = s.cfg.Monitoring.Thresholds.BackupMaxAgeHours
// Cross-drive summary for dashboard Tier 2 status line
crossConfigs := s.settings.GetAllCrossDriveConfigs()
crossDriveTotal := 0
crossDriveConfigured := 0
crossDriveFailed := 0
for _, st := range deployedStacks {
if st.Protected {
continue
}
crossDriveTotal++
cfg, hasCfg := crossConfigs[st.Name]
if hasCfg && cfg != nil && cfg.Enabled {
crossDriveConfigured++
if cfg.LastStatus == "error" {
crossDriveFailed++
}
}
}
data["CrossDriveTotal"] = crossDriveTotal
data["CrossDriveConfigured"] = crossDriveConfigured
data["CrossDriveFailed"] = crossDriveFailed
}
// Build subdomain map for "Megnyitás" buttons
@@ -350,53 +325,10 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
}
}
// Storage info for already-deployed apps with HDD data
// Disk-tier storage management (drive info, stale-data cleanup, cross-drive
// backup) has moved to the host agent (slice 8C); the deploy page no longer
// renders those sections.
if alreadyDeployed {
storageInfo := s.storageInfoForStack(name)
if storageInfo != nil {
data["StorageInfo"] = storageInfo
data["OtherStoragePaths"] = s.otherStoragePathsForStack(name)
}
// Stale data from previous migrations (only for deployed apps with HDD data)
staleData := s.findStaleStorageData(name)
if len(staleData) > 0 {
data["StaleData"] = staleData
}
// Cross-drive backup config for this app
crossCfg := s.settings.GetCrossDriveConfig(name)
data["CrossDriveConfig"] = crossCfg
// Other storage paths for destination dropdown (exclude the app's current storage path)
currentPath := ""
if storageInfo != nil {
currentPath = storageInfo.Path
}
var destPaths []DeployStoragePath
for _, sp := range s.settings.GetStoragePaths() {
if sp.Path == currentPath {
continue // skip the app's current storage — must be a DIFFERENT physical device
}
dp := DeployStoragePath{StoragePath: sp}
if di := system.GetDiskUsage(sp.Path); di != nil {
dp.FreeHuman = formatFreeSpace(di.AvailGB)
if di.TotalGB > 0 {
dp.FreePercent = di.AvailGB / di.TotalGB * 100
}
}
destPaths = append(destPaths, dp)
}
data["BackupDestPaths"] = destPaths
// Destination health warning (tiered validation)
if crossCfg != nil && crossCfg.Enabled && crossCfg.DestinationPath != "" {
health := system.CheckBackupDestination(crossCfg.DestinationPath)
if health.Warning != "" {
data["BackupDestWarning"] = health.Warning
data["BackupDestWarningSeverity"] = health.Severity
}
}
// App-to-app integrations
if meta.HasIntegrations() && s.integrationMgr != nil {
data["HasIntegrations"] = true
@@ -581,8 +513,7 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr != nil {
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
// Pass flash messages from query params (set by redirect handlers)
if flash := r.URL.Query().Get("flash"); flash != "" {
@@ -608,143 +539,18 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
}
}
// Build cross-drive summary
crossConfigs := s.settings.GetAllCrossDriveConfigs()
// Build label lookup for dest paths
destLabels := make(map[string]string)
for _, sp := range storagePaths {
destLabels[sp.Path] = sp.Label
}
for _, app := range fullStatus.AppDataInfo {
cfg, hasCfg := crossConfigs[app.StackName]
if !hasCfg || cfg == nil {
fullStatus.UnconfiguredApps = append(fullStatus.UnconfiguredApps, backup.CrossDriveSummaryItem{
StackName: app.StackName,
DisplayName: app.DisplayName,
})
continue
}
item := backup.CrossDriveSummaryItem{
StackName: app.StackName,
DisplayName: app.DisplayName,
Method: cfg.Method,
DestPath: cfg.DestinationPath,
DestLabel: destLabels[cfg.DestinationPath],
Schedule: cfg.Schedule,
LastStatus: cfg.LastStatus,
SizeHuman: cfg.LastSizeHuman,
}
switch cfg.Method {
case "rsync":
item.MethodLabel = "rsync"
case "restic":
item.MethodLabel = "restic"
default:
item.MethodLabel = cfg.Method
}
switch cfg.Schedule {
case "daily":
item.ScheduleLabel = "Naponta"
case "weekly":
item.ScheduleLabel = "Hetente"
default:
item.ScheduleLabel = "Kézi"
}
if cfg.LastRun != "" {
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
item.LastRunShort = t.In(getTimezone()).Format("01-02 15:04")
}
}
fullStatus.CrossDriveSummary = append(fullStatus.CrossDriveSummary, item)
// Destination health warning (tiered validation)
if cfg.Enabled && cfg.DestinationPath != "" {
health := system.CheckBackupDestination(cfg.DestinationPath)
if health.Warning != "" {
prefix := "⚠️"
if health.Severity == "critical" {
prefix = "🔴"
}
fullStatus.CrossDriveWarnings = append(fullStatus.CrossDriveWarnings,
fmt.Sprintf("%s %s: %s", prefix, app.DisplayName, health.Warning))
}
}
}
// Build unified per-app backup rows for the new UI
data["AppBackupRows"] = s.buildAppBackupRows(fullStatus, crossConfigs, destLabels)
// Top-level warning: no user data backed up at all
hasAnyCrossDrive := false
hasAnyHDDApp := false
for _, app := range fullStatus.AppDataInfo {
if app.HasHDDData {
hasAnyHDDApp = true
if cfg, ok := crossConfigs[app.StackName]; ok && cfg != nil && cfg.Enabled {
hasAnyCrossDrive = true
}
}
}
if hasAnyHDDApp && !hasAnyCrossDrive {
data["NoUserDataBackupWarning"] = true
}
// Build unified per-app backup rows for the app-data backup UI.
// Disk-tier (cross-drive / restic) backup has moved to the host agent.
data["AppBackupRows"] = s.buildAppBackupRows(fullStatus)
data["Backup"] = fullStatus
// Restic password for display
if pw, err := s.backupMgr.GetResticPassword(); err == nil {
data["ResticPassword"] = pw
}
// Részletek section: DB dump total size
// DB dump total size
var dbDumpTotalBytes int64
for _, f := range fullStatus.DumpFiles {
dbDumpTotalBytes += f.Size
}
data["DBDumpTotalBytes"] = dbDumpTotalBytes
// Részletek section: enrich per-drive repo stats with storage labels
for i := range fullStatus.PerDriveRepoStats {
for _, sp := range storagePaths {
if strings.HasPrefix(fullStatus.PerDriveRepoStats[i].DrivePath, sp.Path) ||
fullStatus.PerDriveRepoStats[i].DrivePath == sp.Path {
fullStatus.PerDriveRepoStats[i].DriveLabel = sp.Label
break
}
}
if fullStatus.PerDriveRepoStats[i].DriveLabel == "" {
fullStatus.PerDriveRepoStats[i].DriveLabel = filepath.Base(fullStatus.PerDriveRepoStats[i].DrivePath)
}
}
data["PerDriveRepoStats"] = fullStatus.PerDriveRepoStats
// Részletek section: group Tier 2 items by destination drive
tier2GroupMap := make(map[string]*Tier2DriveGroup)
for _, item := range fullStatus.CrossDriveSummary {
if item.DestPath == "" {
continue
}
grp, exists := tier2GroupMap[item.DestPath]
if !exists {
grp = &Tier2DriveGroup{
DestPath: item.DestPath,
DestLabel: item.DestLabel,
}
if grp.DestLabel == "" {
grp.DestLabel = filepath.Base(item.DestPath)
}
tier2GroupMap[item.DestPath] = grp
}
grp.Items = append(grp.Items, item)
}
var tier2Groups []Tier2DriveGroup
for _, grp := range tier2GroupMap {
tier2Groups = append(tier2Groups, *grp)
}
data["Tier2DriveGroups"] = tier2Groups
} else {
data["Backup"] = nil
}
@@ -752,13 +558,6 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
s.executeTemplate(w, r, "backups", data)
}
// Tier2DriveGroup holds grouped Tier 2 cross-drive backup items for one destination drive.
type Tier2DriveGroup struct {
DestPath string
DestLabel string
Items []backup.CrossDriveSummaryItem
}
// AppBackupRow holds per-tier backup information for one app on the backup page.
type AppBackupRow struct {
StackName string
@@ -804,13 +603,9 @@ type AppBackupRow struct {
}
// buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page.
func (s *Server) buildAppBackupRows(
status *backup.FullBackupStatus,
crossConfigs map[string]*settings.CrossDriveBackup,
destLabels map[string]string,
) []AppBackupRow {
loc := getTimezone()
// Disk-tier (cross-drive / restic) backup has moved to the host agent; this now
// reflects only the app-data backup (DB dumps + Docker-volume tars).
func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackupRow {
// Build DB stack lookup
dbStacks := make(map[string]bool)
for _, db := range status.DiscoveredDBs {
@@ -820,17 +615,6 @@ func (s *Server) buildAppBackupRows(
dbStacks[f.StackName] = true
}
// Tier 1 timestamps (shared across all apps — single nightly job)
tier1LastRun := ""
tier1LastStatus := ""
if status.LastBackup != nil {
tier1LastRun = status.LastBackup.LastRun.In(loc).Format("01-02 15:04")
if status.LastBackup.Success {
tier1LastStatus = "ok"
} else {
tier1LastStatus = "error"
}
}
tier1DBStatus := ""
if status.LastDBDump != nil {
if status.LastDBDump.Success {
@@ -884,115 +668,18 @@ func (s *Server) buildAppBackupRows(
HasDB: hasDB,
HasVolumeData: app.HasVolumeData,
DriveDisconnected: driveDisconnected,
StorageLabel: app.StorageLabel,
HDDSizeHuman: app.HDDSizeHuman,
BackupContents: contents,
Tier1LastRun: tier1LastRun,
Tier1LastStatus: tier1LastStatus,
Tier1DBStatus: tier1DBStatus,
StorageLabel: app.StorageLabel,
HDDSizeHuman: app.HDDSizeHuman,
BackupContents: contents,
Tier1DBStatus: tier1DBStatus,
}
// Status dot — start as yellow (1 tier only)
row.Status = "yellow"
row.StatusText = "Csak helyi mentés (1 szint)"
cfg, hasCfg := crossConfigs[app.StackName]
if !hasCfg || cfg == nil || !cfg.Enabled {
// Only Tier 1 — no second copy
row.Tier2Configured = false
} else {
row.Tier2Configured = true
row.Tier2Dest = destLabels[cfg.DestinationPath]
if row.Tier2Dest == "" {
row.Tier2Dest = cfg.DestinationPath
}
switch cfg.Schedule {
case "daily":
row.Tier2Schedule = "Naponta"
case "weekly":
row.Tier2Schedule = "Hetente"
default:
row.Tier2Schedule = cfg.Schedule
}
if cfg.LastRun != "" {
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
row.Tier2LastRun = t.In(loc).Format("01-02 15:04")
}
}
row.Tier2LastStatus = cfg.LastStatus
row.Tier2LastError = cfg.LastError
row.Tier2SizeHuman = cfg.LastSizeHuman
switch cfg.LastStatus {
case "ok":
row.Tier2StatusBadge = "Sikeres"
row.Status = "green"
row.StatusText = "Mentés rendben"
case "error":
row.Tier2StatusBadge = "Hiba"
// Status stays yellow
row.StatusText = "Utolsó mentés sikertelen"
case "running":
row.Tier2StatusBadge = "Fut..."
default:
row.Tier2StatusBadge = "—"
// Tier2 configured but never run — stay yellow
}
// Check if Tier2 destination drive is disconnected
if cfg.DestinationPath != "" {
for dp := range disconnectedPaths {
if cfg.DestinationPath == dp || strings.HasPrefix(cfg.DestinationPath, dp+"/") {
row.Tier2DestDisconnected = true
break
}
}
}
// Also treat as disconnected if dest was removed from storage entirely
if cfg.DestinationPath != "" && !row.Tier2DestDisconnected {
if !s.settings.IsStoragePathKnown(cfg.DestinationPath) {
row.Tier2DestDisconnected = true
}
}
// Check if Tier2 destination drive is inactive (not schedulable)
if cfg.DestinationPath != "" && !row.Tier2DestDisconnected {
if !s.settings.IsStoragePathSchedulable(cfg.DestinationPath) {
row.Tier2DestInactive = true
}
}
if row.Tier2DestDisconnected {
// Disconnected destination — treat as paused, not failed
row.Status = "yellow"
row.StatusText = "2. mentés szünetel — cél meghajtó leválasztva"
} else if row.Tier2DestInactive {
// Inactive destination — treat as paused
row.Status = "yellow"
row.StatusText = "2. mentés szünetel — cél meghajtó inaktív"
} else if cfg.DestinationPath != "" && s.crossDriveRunner != nil {
// Destination health check — can downgrade green to yellow/red
if err := s.crossDriveRunner.ValidateDestination(cfg.DestinationPath); err != nil {
if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "not writable") {
row.Status = "red"
row.StatusText = "Mentési cél nem elérhető"
} else if row.Status != "red" {
row.Status = "yellow"
row.StatusText = "Figyelmeztetés"
}
row.Warnings = append(row.Warnings, err.Error())
}
}
}
// DB dump failure warning (affects Tier 1 quality)
// Status dot — app-data backup status
row.Status = "green"
row.StatusText = "Alkalmazás-adat mentés rendben"
if hasDB && tier1DBStatus == "error" {
if row.Status != "red" {
row.Status = "yellow"
row.StatusText = "Adatbázis mentés sikertelen"
}
row.Status = "yellow"
row.StatusText = "Adatbázis mentés sikertelen"
}
rows = append(rows, row)
@@ -1000,79 +687,6 @@ func (s *Server) buildAppBackupRows(
return rows
}
// settingsCrossBackupHandler handles POST /settings/cross-backup/{name}
// Saves or updates the cross-drive backup configuration for an app.
func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Request, name string) {
_ = r.ParseForm()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsCrossBackupHandler: stack=%s from %s", name, r.RemoteAddr)
}
enabled := r.FormValue("cross_drive_enabled") == "on"
// Preserve existing runtime status fields and config when disabling
existing := s.settings.GetCrossDriveConfig(name)
var destPath, schedule string
if enabled {
destPath = r.FormValue("cross_drive_dest")
schedule = r.FormValue("cross_drive_schedule")
if schedule != "daily" && schedule != "weekly" {
schedule = "daily"
}
} else if existing != nil {
// Preserve existing settings when disabling
destPath = existing.DestinationPath
schedule = existing.Schedule
}
// Validate destination path against registered storage paths (H11 fix — matches API handler).
if enabled && destPath != "" {
registeredPaths := s.settings.GetStoragePaths()
validDest := false
for _, sp := range registeredPaths {
if destPath == sp.Path {
validDest = true
break
}
}
if !validDest {
s.logger.Printf("[WARN] [web] Cross-drive backup: rejected invalid dest path %q for %s", destPath, name)
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash_error="+url.QueryEscape("Érvénytelen célútvonal: "+destPath), http.StatusFound)
return
}
}
var cfg *settings.CrossDriveBackup
if destPath != "" || existing != nil {
cfg = &settings.CrossDriveBackup{
Enabled: enabled,
Method: "rsync",
DestinationPath: destPath,
Schedule: schedule,
}
if existing != nil {
cfg.LastRun = existing.LastRun
cfg.LastStatus = existing.LastStatus
cfg.LastError = existing.LastError
cfg.LastDuration = existing.LastDuration
cfg.LastSizeHuman = existing.LastSizeHuman
}
}
if err := s.settings.SetCrossDriveConfig(name, cfg); err != nil {
s.logger.Printf("[ERROR] [web] Failed to save cross-drive config for %s: %v", name, err)
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash_error=Hiba+a+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+ment%C3%A9sakor", http.StatusFound)
return
}
s.logger.Printf("[INFO] [web] Cross-drive backup config saved for %s: dest=%s schedule=%s enabled=%v",
name, destPath, schedule, enabled)
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash=Ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+mentve.", http.StatusFound)
}
func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
@@ -1096,12 +710,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[WARN] [web] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
start := time.Now()
var err error
if snapshotID == "tier2-rsync" {
err = s.backupMgr.RestoreAppFromTier2(stackName)
} else {
err = s.backupMgr.RestoreApp(stackName, snapshotID)
}
err := s.backupMgr.RestoreApp(stackName, snapshotID)
if err != nil {
s.logger.Printf("[ERROR] [web] Restore failed: %v", err)
if s.isDebug() {