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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user