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
+26 -273
View File
@@ -14,10 +14,10 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
@@ -29,25 +29,21 @@ import (
// Router handles all /api/* requests.
type Router struct {
cfg *config.Config
configPath string
sett *settings.Settings
stackMgr *stacks.Manager
syncer *catalogsync.Syncer
cpuCollector *system.CPUCollector
backupMgr *backup.Manager
crossDriveRunner *backup.CrossDriveRunner
metricsStore *metrics.MetricsStore
updater *selfupdate.Updater
notifier *notify.Notifier
logger *log.Logger
cfg *config.Config
configPath string
sett *settings.Settings
stackMgr *stacks.Manager
syncer *catalogsync.Syncer
cpuCollector *system.CPUCollector
backupMgr *backup.Manager
metricsStore *metrics.MetricsStore
updater *selfupdate.Updater
notifier *notify.Notifier
logger *log.Logger
// OnConfigApplied is called after a successful config apply (e.g., to push infra backup).
OnConfigApplied func()
// OnCrossDriveComplete is called after a manual cross-drive backup completes (to push infra backup to Hub).
OnCrossDriveComplete func()
// OnGeoRelevantChange is called after deploy/remove to re-sync geo rules.
OnGeoRelevantChange func()
@@ -89,8 +85,8 @@ func (r *Router) SetIntegrationManager(im *integrations.Manager) {
r.integrationMgr = im
}
func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, notif *notify.Notifier, logger *log.Logger) *Router {
return &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, updater: updater, notifier: notif, logger: logger}
func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, notif *notify.Notifier, logger *log.Logger) *Router {
return &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, metricsStore: metricsStore, updater: updater, notifier: notif, logger: logger}
}
type apiResponse struct {
@@ -209,22 +205,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"):
r.deleteStack(w, req, trimSegment(path, "/stacks/"))
// POST /api/stacks/{name}/cross-backup — save cross-drive config
case hasSuffix(path, "/cross-backup") && req.Method == http.MethodPost && !hasSuffix(path, "/cross-backup/run") && !hasSuffix(path, "/cross-backup/status"):
r.saveCrossBackupConfig(w, req, extractName(path, "/cross-backup"))
// POST /api/stacks/{name}/cross-backup/run — trigger manual run
case hasSuffix(path, "/cross-backup/run") && req.Method == http.MethodPost:
r.triggerCrossBackup(w, req, extractName(path, "/cross-backup/run"))
// GET /api/stacks/{name}/cross-backup/status — poll status
case hasSuffix(path, "/cross-backup/status") && req.Method == http.MethodGet:
r.getCrossBackupStatus(w, req, extractName(path, "/cross-backup/status"))
// POST /api/backup/cross-drive/run-all — trigger all scheduled cross-drive backups
case path == "/backup/cross-drive/run-all" && req.Method == http.MethodPost:
r.triggerAllCrossBackups(w, req)
// POST /api/sync — trigger immediate catalog sync
case path == "/sync" && req.Method == http.MethodPost:
r.triggerSync(w, req)
@@ -241,10 +221,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case path == "/backup/run" && req.Method == http.MethodPost:
r.triggerBackup(w, req)
// GET /api/backup/snapshots
case path == "/backup/snapshots" && req.Method == http.MethodGet:
r.backupSnapshots(w, req)
// GET /api/metrics/system
case path == "/metrics/system" && req.Method == http.MethodGet:
r.metricsSystem(w, req)
@@ -587,8 +563,8 @@ func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name
// Compute the drive path for this stack (HDD or system data path)
var drivePath string
if r.crossDriveRunner != nil {
drivePath = r.crossDriveRunner.GetAppDrivePath(name)
if r.backupMgr != nil {
drivePath = r.backupMgr.GetAppDrivePath(name)
}
resp, err := r.stackMgr.GetStackBackupData(name, drivePath)
@@ -618,15 +594,13 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
}
r.dbg("removeStack: name=%s removeHDDData=%v removeBackups=%v", name, body.RemoveHDDData, body.RemoveBackups)
// Compute backup paths to remove if requested
// Compute backup paths to remove if requested. Disk-tier (cross-drive rsync)
// backup has moved to the host agent; only the app-data DB-dump path is removed here.
var backupPaths []string
if body.RemoveBackups && r.crossDriveRunner != nil {
drivePath := r.crossDriveRunner.GetAppDrivePath(name)
if body.RemoveBackups && r.backupMgr != nil {
drivePath := r.backupMgr.GetAppDrivePath(name)
if drivePath != "" {
backupPaths = append(backupPaths,
backup.AppDBDumpPath(drivePath, name),
backup.AppSecondaryRsyncPath(drivePath, name),
)
backupPaths = append(backupPaths, backup.AppDBDumpPath(drivePath, name))
}
}
@@ -734,7 +708,7 @@ func (r *Router) backupStatus(w http.ResponseWriter, _ *http.Request) {
return
}
dbDump, backupSt := r.backupMgr.GetStatus()
dbDump := r.backupMgr.GetStatus()
data := map[string]interface{}{
"enabled": true,
"running": r.backupMgr.IsRunning(),
@@ -749,27 +723,11 @@ func (r *Router) backupStatus(w http.ResponseWriter, _ *http.Request) {
}
}
if backupSt != nil {
backupData := map[string]interface{}{
"last_run": backupSt.LastRun,
"success": backupSt.Success,
"duration": backupSt.Duration.String(),
}
if backupSt.Snapshot != nil {
backupData["snapshot_id"] = backupSt.Snapshot.SnapshotID
backupData["files_new"] = backupSt.Snapshot.FilesNew
backupData["data_added"] = backupSt.Snapshot.DataAdded
}
if backupSt.RepoStats != nil {
backupData["repo_size"] = backupSt.RepoStats.TotalSize
backupData["snapshot_count"] = backupSt.RepoStats.SnapshotCount
}
data["backup"] = backupData
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
}
// triggerBackup runs the app-data database dumps. Disk-tier (restic) backup has
// moved to the host agent (slice 8C).
func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
r.dbg("triggerBackup: backupMgr=%v", r.backupMgr != nil)
if r.backupMgr == nil {
@@ -783,82 +741,12 @@ func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
return
}
r.logger.Println("[INFO] [api] Manual backup triggered")
go r.backupMgr.RunFullBackup(context.Background())
r.logger.Println("[INFO] [api] Manual app-data backup (DB dump) triggered")
go r.backupMgr.RunDBDumps(context.Background())
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
}
func (r *Router) backupSnapshots(w http.ResponseWriter, req *http.Request) {
if r.backupMgr == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []interface{}{}})
return
}
stackName := req.URL.Query().Get("stack")
var snapshots []backup.SnapshotInfo
var err error
if stackName != "" {
// Per-app: only snapshots from the app's home drive
snapshots, err = r.backupMgr.ListSnapshotsForApp(stackName, 20)
} else {
// Fallback: all snapshots (general use)
snapshots, err = r.backupMgr.ListAllSnapshots(50)
}
if err != nil {
r.logger.Printf("[ERROR] [api] Failed to list backup snapshots: %v", err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
// Enrich snapshots with drive labels from storage paths
if r.sett != nil {
storagePaths := r.sett.GetStoragePaths()
for i := range snapshots {
repoPath := snapshots[i].RepoPath
for _, sp := range storagePaths {
if strings.HasPrefix(repoPath, sp.Path) {
snapshots[i].DriveLabel = sp.Label
break
}
}
}
// Append Tier 2 (cross-drive rsync) entry if available for this app
if stackName != "" {
cdCfg := r.sett.GetCrossDriveConfig(stackName)
if cdCfg != nil && cdCfg.Enabled && cdCfg.LastStatus == "ok" && cdCfg.LastRun != "" {
lastRun, _ := time.Parse(time.RFC3339, cdCfg.LastRun)
if !lastRun.IsZero() {
// Resolve drive label for destination
var destLabel string
for _, sp := range storagePaths {
if sp.Path == cdCfg.DestinationPath {
destLabel = sp.Label
break
}
}
tier2 := backup.SnapshotInfo{
ID: "tier2-rsync",
Time: lastRun,
Tier: 2,
Source: "rsync",
DriveLabel: destLabel,
}
snapshots = append(snapshots, tier2)
}
}
}
}
if snapshots == nil {
snapshots = []backup.SnapshotInfo{}
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: snapshots})
}
// --- Metrics handlers ---
func (r *Router) metricsSystem(w http.ResponseWriter, req *http.Request) {
@@ -955,141 +843,6 @@ func (r *Router) metricsSysInfo(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: info})
}
// --- Cross-drive backup handlers ---
func (r *Router) saveCrossBackupConfig(w http.ResponseWriter, req *http.Request, name string) {
if r.crossDriveRunner == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
return
}
limitBody(w, req)
var body struct {
Enabled bool `json:"enabled"`
DestinationPath string `json:"destination_path"`
Schedule string `json:"schedule"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
return
}
// Validate schedule
if body.Schedule != "daily" && body.Schedule != "weekly" && body.Schedule != "manual" {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "schedule must be 'daily', 'weekly', or 'manual'"})
return
}
// C9: Validate DestinationPath against registered storage paths to prevent path traversal.
if body.Enabled && body.DestinationPath != "" {
registeredPaths := r.sett.GetStoragePaths()
validDest := false
for _, sp := range registeredPaths {
if body.DestinationPath == sp.Path {
validDest = true
break
}
}
if !validDest {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "destination_path must be a registered storage path"})
return
}
}
// Preserve existing runtime status
existing := r.sett.GetCrossDriveConfig(name)
var lastRun, lastStatus, lastError, lastDuration, lastSize string
if existing != nil {
lastRun, lastStatus, lastError, lastDuration, lastSize =
existing.LastRun, existing.LastStatus, existing.LastError, existing.LastDuration, existing.LastSizeHuman
}
cfg := &settings.CrossDriveBackup{
Enabled: body.Enabled,
Method: "rsync",
DestinationPath: body.DestinationPath,
Schedule: body.Schedule,
LastRun: lastRun,
LastStatus: lastStatus,
LastError: lastError,
LastDuration: lastDuration,
LastSizeHuman: lastSize,
}
if err := r.sett.SetCrossDriveConfig(name, cfg); err != nil {
r.logger.Printf("[ERROR] [api] Failed to save cross-drive config for %s: %v", name, err)
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
r.logger.Printf("[INFO] [api] Cross-drive backup config saved for %s: dest=%s schedule=%s",
name, body.DestinationPath, body.Schedule)
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Cross-drive backup configuration saved"})
}
func (r *Router) triggerCrossBackup(w http.ResponseWriter, req *http.Request, name string) {
if r.crossDriveRunner == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
return
}
if r.crossDriveRunner.IsRunning(name) {
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
return
}
r.logger.Printf("[INFO] [api] Cross-drive backup triggered for: %s", name)
go func() {
if err := r.crossDriveRunner.RunAppBackup(context.Background(), name); err != nil {
r.logger.Printf("[ERROR] [api] Cross-drive backup failed for %s: %v", name, err)
}
if r.OnCrossDriveComplete != nil {
r.OnCrossDriveComplete()
}
}()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Mentés elindítva"})
}
func (r *Router) getCrossBackupStatus(w http.ResponseWriter, _ *http.Request, name string) {
cfg := r.sett.GetCrossDriveConfig(name)
if cfg == nil {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{"configured": false}})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
"configured": true,
"enabled": cfg.Enabled,
"method": "rsync",
"schedule": cfg.Schedule,
"running": r.crossDriveRunner != nil && r.crossDriveRunner.IsRunning(name),
"last_run": cfg.LastRun,
"last_status": cfg.LastStatus,
"last_error": cfg.LastError,
"last_duration": cfg.LastDuration,
"last_size": cfg.LastSizeHuman,
}})
}
func (r *Router) triggerAllCrossBackups(w http.ResponseWriter, _ *http.Request) {
if r.crossDriveRunner == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "cross-drive runner not available"})
return
}
r.logger.Println("[INFO] [api] All cross-drive backups triggered")
go func() {
ctx := context.Background()
if err := r.crossDriveRunner.RunAllScheduled(ctx, "daily"); err != nil {
r.logger.Printf("[ERROR] [api] Cross-drive run-all error: %v", err)
}
if err := r.crossDriveRunner.RunAllScheduled(ctx, "weekly"); err != nil {
r.logger.Printf("[ERROR] [api] Cross-drive run-all weekly error: %v", err)
}
if r.OnCrossDriveComplete != nil {
r.OnCrossDriveComplete()
}
}()
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Összes mentés elindítva"})
}
// parseTimeRange reads range or from/to query params.
func parseTimeRange(req *http.Request) (from, to time.Time) {
to = time.Now()