v0.57.0: stable host-storage list + per-app Tier-2 config panel
Part A of the UI-fixes/storage-spike spec.
A1: enrichHostStorageTargets sorts /api/host-metrics storage_targets
server-side and attaches friendly Hungarian labels + purpose, fixing the
#host-storage-bars reorder-on-poll bug. Display labels only — PVE storage
ids are never renamed.
A2: new GET/POST /stacks/{name}/backup Tier-2 config panel; the "2. mentés"
Beállítás button is repointed there from the dead-end deploy page. Customer
can pin a target drive or disable Tier 2; preference is preserved across the
runner's status writes. Always visible (single-SSD + non-HDD apps included).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,13 +48,40 @@ func tier2FitsHeadroom(availGB, totalGB, unitGB float64) bool {
|
||||
return (availGB - unitGB) >= reserve
|
||||
}
|
||||
|
||||
// selectTier2Target auto-picks the off-drive destination for an app's Tier 2 copy.
|
||||
// selectTier2Target picks the off-drive destination for an app's Tier 2 copy. A customer-pinned
|
||||
// target (PreferredTarget, set from the config panel) wins when it is still valid; otherwise it
|
||||
// auto-picks: another user-data drive, else the internal SSD for small units (headroom-guarded).
|
||||
func (m *Manager) selectTier2Target(stackName string, unitSizeBytes int64) (*Tier2Target, error) {
|
||||
sourceDrive := m.GetAppDrivePath(stackName)
|
||||
if sourceDrive == "" {
|
||||
return nil, fmt.Errorf("no source drive for %s", stackName)
|
||||
}
|
||||
|
||||
// 0. Honor a customer-pinned target if it is still valid (registered, schedulable, off-disk).
|
||||
// An invalid pin (gone / same physical disk) silently falls through to the auto-pick.
|
||||
if m.settings != nil {
|
||||
if cd := m.settings.GetCrossDriveConfig(stackName); cd != nil && cd.PreferredTarget != "" {
|
||||
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
||||
if sp.Path != cd.PreferredTarget {
|
||||
continue
|
||||
}
|
||||
if sp.Path == sourceDrive || system.SamePhysicalDevice(sourceDrive, sp.Path) {
|
||||
break // pinned target is on the same physical disk — not off-drive; fall through
|
||||
}
|
||||
label := sp.Label
|
||||
if label == "" {
|
||||
label = filepath.Base(sp.Path)
|
||||
}
|
||||
return &Tier2Target{
|
||||
NamespaceRoot: NamespaceRoot(sp.Path, true),
|
||||
Label: label,
|
||||
IsSystemDrive: false,
|
||||
Reason: "kézi választás",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Prefer another registered user-data drive on a DIFFERENT physical disk (can hold bulk userdata).
|
||||
if m.settings != nil {
|
||||
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
||||
@@ -103,6 +130,13 @@ func (m *Manager) tier2FitsSystemDrive(sys string, unitSizeBytes int64) bool {
|
||||
// Best-effort and idempotent (rsync mirror). Records status into settings for the UI; returns an
|
||||
// error only on an actual copy failure (no valid target is a recorded status, not an error).
|
||||
func (m *Manager) RunTier2(stackName string) error {
|
||||
// Customer turned Tier 2 off for this app (config panel) — skip without touching status.
|
||||
if m.settings != nil {
|
||||
if cd := m.settings.GetCrossDriveConfig(stackName); cd != nil && cd.UserDisabled {
|
||||
m.logger.Printf("[INFO] [backup] Tier 2 for %s skipped — disabled by customer", stackName)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
sourceDrive := m.GetAppDrivePath(stackName)
|
||||
if sourceDrive == "" {
|
||||
return fmt.Errorf("no source drive for %s", stackName)
|
||||
@@ -183,13 +217,91 @@ func (m *Manager) RunAllTier2() {
|
||||
m.logger.Printf("[INFO] [backup] Tier 2 run complete: %d HDD app(s) processed", n)
|
||||
}
|
||||
|
||||
// --- per-app config-panel view (drives the Tier-2 "Beállítás" page) ---
|
||||
|
||||
// Tier2Option is one selectable off-drive destination in the config panel.
|
||||
type Tier2Option struct {
|
||||
Path string // registered storage path (the value persisted as PreferredTarget)
|
||||
Label string // human label for the dropdown
|
||||
}
|
||||
|
||||
// Tier2Info is the per-app Tier-2 view the config panel renders. It exposes the effective target
|
||||
// (pinned or auto), whether that is the size-limited internal SSD, the honest no-target reason, and
|
||||
// the off-disk drives the customer may pin — so the control is meaningful even with a single target.
|
||||
type Tier2Info struct {
|
||||
IsHDDApp bool // false = the app lives on the rootfs (already inside the PBS whole-guest snapshot)
|
||||
SourceDrive string // where the app's data currently lives
|
||||
Disabled bool // customer turned Tier 2 off
|
||||
Preferred string // customer-pinned target path ("" = automatic)
|
||||
EffectiveLabel string // label of the target that WOULD be used right now
|
||||
EffectiveIsSSD bool // the effective target is the internal SSD (DB/config only)
|
||||
EffectiveDesc string // why this target (Hungarian)
|
||||
NoTarget bool // no off-drive target fits at all
|
||||
NoTargetReason string // honest reason when NoTarget
|
||||
Alternatives []Tier2Option
|
||||
}
|
||||
|
||||
// Tier2Info builds the config-panel view for one app. Read-only (no status writes).
|
||||
func (m *Manager) Tier2Info(stackName string) Tier2Info {
|
||||
var info Tier2Info
|
||||
if m.stackProvider != nil {
|
||||
info.IsHDDApp = m.stackProvider.GetStackHDDPath(stackName) != ""
|
||||
}
|
||||
source := m.GetAppDrivePath(stackName)
|
||||
info.SourceDrive = source
|
||||
|
||||
if m.settings != nil {
|
||||
if cd := m.settings.GetCrossDriveConfig(stackName); cd != nil {
|
||||
info.Disabled = cd.UserDisabled
|
||||
info.Preferred = cd.PreferredTarget
|
||||
}
|
||||
// Eligible alternative drives: registered, schedulable, on a DIFFERENT physical disk.
|
||||
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
||||
if sp.Path == source || system.SamePhysicalDevice(source, sp.Path) {
|
||||
continue
|
||||
}
|
||||
label := sp.Label
|
||||
if label == "" {
|
||||
label = filepath.Base(sp.Path)
|
||||
}
|
||||
info.Alternatives = append(info.Alternatives, Tier2Option{Path: sp.Path, Label: label})
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve what the runner WOULD pick right now (real unit size feeds the SSD headroom guard).
|
||||
sourceNsRoot := m.namespaceRoot(source)
|
||||
unitSize := dirSizeBytes(RecoveryUnitPath(sourceNsRoot, stackName)) + dirSizeBytes(AppDataDir(sourceNsRoot, stackName))
|
||||
target, err := m.selectTier2Target(stackName, unitSize)
|
||||
if err != nil {
|
||||
info.NoTarget = true
|
||||
info.NoTargetReason = tier2NoTargetReason(err)
|
||||
return info
|
||||
}
|
||||
info.EffectiveLabel = target.Label
|
||||
info.EffectiveIsSSD = target.IsSystemDrive
|
||||
info.EffectiveDesc = target.Reason
|
||||
return info
|
||||
}
|
||||
|
||||
// --- status persistence (drives the "2. mentés" UI card) ---
|
||||
|
||||
// withTier2Prefs carries the customer-preference fields (UserDisabled/PreferredTarget) from any
|
||||
// existing config into a freshly-built status struct, so a runner status write never clobbers them.
|
||||
func (m *Manager) withTier2Prefs(stackName string, cfg *settings.CrossDriveBackup) *settings.CrossDriveBackup {
|
||||
if m.settings != nil {
|
||||
if existing := m.settings.GetCrossDriveConfig(stackName); existing != nil {
|
||||
cfg.UserDisabled = existing.UserDisabled
|
||||
cfg.PreferredTarget = existing.PreferredTarget
|
||||
}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (m *Manager) recordTier2Success(stackName string, target *Tier2Target, sizeBytes int64, dur time.Duration) {
|
||||
if m.settings == nil {
|
||||
return
|
||||
}
|
||||
_ = m.settings.SetCrossDriveConfig(stackName, &settings.CrossDriveBackup{
|
||||
_ = m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{
|
||||
Enabled: true,
|
||||
Method: "rsync",
|
||||
DestinationPath: target.NamespaceRoot,
|
||||
@@ -198,14 +310,14 @@ func (m *Manager) recordTier2Success(stackName string, target *Tier2Target, size
|
||||
LastStatus: "ok",
|
||||
LastDuration: dur.Round(time.Second).String(),
|
||||
LastSizeHuman: humanizeBytes(sizeBytes),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func (m *Manager) recordTier2Failure(stackName string, target *Tier2Target, cause error) {
|
||||
if m.settings == nil {
|
||||
return
|
||||
}
|
||||
_ = m.settings.SetCrossDriveConfig(stackName, &settings.CrossDriveBackup{
|
||||
_ = m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{
|
||||
Enabled: true,
|
||||
Method: "rsync",
|
||||
DestinationPath: target.NamespaceRoot,
|
||||
@@ -213,20 +325,20 @@ func (m *Manager) recordTier2Failure(stackName string, target *Tier2Target, caus
|
||||
LastRun: time.Now().Format(time.RFC3339),
|
||||
LastStatus: "error",
|
||||
LastError: cause.Error(),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func (m *Manager) recordTier2NoTarget(stackName, reason string) {
|
||||
if m.settings == nil {
|
||||
return
|
||||
}
|
||||
_ = m.settings.SetCrossDriveConfig(stackName, &settings.CrossDriveBackup{
|
||||
_ = m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{
|
||||
Enabled: false,
|
||||
Method: "rsync",
|
||||
Schedule: "daily",
|
||||
LastStatus: "no_target",
|
||||
LastError: reason,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func tier2NoTargetReason(err error) string {
|
||||
|
||||
@@ -1,6 +1,91 @@
|
||||
package backup
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// newTestManager builds a Manager backed by a real (temp-file) settings store and the given
|
||||
// system-data path as the SSD fallback source (no stackProvider → source = systemDataPath).
|
||||
func newTestManager(t *testing.T, systemDataPath string) (*Manager, *settings.Settings) {
|
||||
t.Helper()
|
||||
logger := log.New(os.Stderr, "", 0)
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), logger)
|
||||
if err != nil {
|
||||
t.Fatalf("settings.Load: %v", err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.SystemDataPath = systemDataPath
|
||||
return NewManager(cfg, sett, logger), sett
|
||||
}
|
||||
|
||||
// A customer-pinned PreferredTarget must win over the auto-pick (which would take the first
|
||||
// off-disk drive), and be reported with the "kézi választás" reason.
|
||||
func TestSelectTier2Target_HonorsPreferred(t *testing.T) {
|
||||
m, sett := newTestManager(t, "/srv/sys")
|
||||
// Two eligible drives; auto-pick would take the alphabetically-first ("/mnt/a").
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/a", Label: "A", Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/b", Label: "B", Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.SetTier2Preference("app", false, "/mnt/b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err := m.selectTier2Target("app", 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("selectTier2Target: %v", err)
|
||||
}
|
||||
if target.NamespaceRoot != filepath.FromSlash("/mnt/b") {
|
||||
t.Errorf("NamespaceRoot = %q, want /mnt/b (pinned)", target.NamespaceRoot)
|
||||
}
|
||||
if target.Reason != "kézi választás" {
|
||||
t.Errorf("Reason = %q, want 'kézi választás'", target.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// An invalid pin (path not registered) silently falls through to the auto-pick.
|
||||
func TestSelectTier2Target_InvalidPreferredFallsBack(t *testing.T) {
|
||||
m, sett := newTestManager(t, "/srv/sys")
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/a", Label: "A", Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.SetTier2Preference("app", false, "/mnt/gone"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err := m.selectTier2Target("app", 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("selectTier2Target: %v", err)
|
||||
}
|
||||
if target.NamespaceRoot != filepath.FromSlash("/mnt/a") || target.Reason != "másik adatmeghajtó" {
|
||||
t.Errorf("got %q/%q, want /mnt/a auto-pick", target.NamespaceRoot, target.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// A runner status write must NOT clobber the customer's preference fields.
|
||||
func TestRecordTier2_PreservesPreference(t *testing.T) {
|
||||
m, sett := newTestManager(t, "/srv/sys")
|
||||
if err := sett.SetTier2Preference("app", true, "/mnt/b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.recordTier2NoTarget("app", "teszt")
|
||||
cd := sett.GetCrossDriveConfig("app")
|
||||
if cd == nil {
|
||||
t.Fatal("config missing after status write")
|
||||
}
|
||||
if !cd.UserDisabled || cd.PreferredTarget != "/mnt/b" {
|
||||
t.Errorf("preference clobbered: UserDisabled=%v PreferredTarget=%q", cd.UserDisabled, cd.PreferredTarget)
|
||||
}
|
||||
if cd.LastStatus != "no_target" {
|
||||
t.Errorf("LastStatus = %q, want no_target", cd.LastStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTier2FitsHeadroom covers the size-aware rootfs-headroom guard that protects the ~8 GB guest
|
||||
// rootfs from being filled by a Tier 2 SSD copy (reserve = max(2 GB, 20% of total)).
|
||||
|
||||
Reference in New Issue
Block a user