feat(shares): R-7b Parts 1-2 — shares payload builder + tier-2 shares job (Model B')
Sibling shares source for the local cross-drive tier. Reuses the tier2Mirror seam, selectTier2TargetFrom (narrow source-drive seam extracted from selectTier2Target), tier2ReconcileRoots (pure extraction), tier2SafeRemove, the marker-LAST discipline and the recordTier2* helpers. Per-app paths are untouched. - shares_payload.go: deterministic _shares-manifest.json + best-effort passdb capture - tier2_shares.go: per-source-drive legs -> cross-drive target, payload, marker LAST - infra.SambaContainerName/SambaPassdbVolume/Mount: single source of truth for the container identity (renderer, stacks execs, backup execs, monitor all read it) - RESERVED-NAME finding: ValidateSMBShareName did NOT exclude a leading underscore, so "_shares" was an accepted share name. Now refused; RunAllTier2 additionally skips a "_shares" stack loudly as defense in depth. - fix: shareSourceDrive returned a slash-normalised path, which made the target selector's source-drive equality check miss (a group could target its own drive)
This commit is contained in:
@@ -92,6 +92,15 @@ type Manager struct {
|
||||
// (`-a --delete`, contents-of-src semantics).
|
||||
tier2Mirror func(src, dst string) error
|
||||
|
||||
// sharesPassdbCapture (R-7b) — the samba passdb capture seam (a `docker exec … tar cf -`),
|
||||
// overridable so the shares payload builder is unit-testable without docker. Nil → the real
|
||||
// defaultSharesPassdbCapture. Best-effort by contract: an error yields a manifest-only payload.
|
||||
sharesPassdbCapture func() ([]byte, error)
|
||||
|
||||
// sharesPassdbRestore (R-7b) — the mirror seam for putting a captured passdb archive BACK into the
|
||||
// samba named volume (`docker exec -i … tar xf -`). Nil → the real defaultSharesPassdbRestore.
|
||||
sharesPassdbRestore func(tar []byte) error
|
||||
|
||||
// tier2SSDFits (3b) — the SSD-headroom predicate seam, overridable in tests (system.GetDiskUsage is
|
||||
// Linux-only → nil on the Windows test host, which would always refuse the SSD branch). Nil → the
|
||||
// real tier2FitsSystemDrive.
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// SMB share backup — R-7b, Model B′ (Viktor's ruling 2026-07-18). Share data enters the live backup
|
||||
// runs through a SIBLING shares source: additive job/leg code that reuses the proven primitives
|
||||
// (tier-2 mirror seam, restic wrappers, soft-quota gate, status recording) while leaving every
|
||||
// per-app engine path BYTE-IDENTICAL. This file holds the piece both tiers share: the PAYLOAD — the
|
||||
// share DEFINITIONS plus the SMB password hash — so a DR rebuild restores the files, the share
|
||||
// configuration, and the household credential, not just the bytes on disk.
|
||||
//
|
||||
// Why a payload at all: the share folders are plain customer directories; copying them protects the
|
||||
// data but loses everything that made them shares. Without the manifest a restore leaves the customer
|
||||
// with their files back and an empty „Megosztás" page.
|
||||
|
||||
const (
|
||||
// SharesPseudoStack is the RESERVED key the shares source occupies wherever the per-app engines
|
||||
// key by stack name: the restic tag, the tier-2 dest root (backups/secondary/_shares/…), and the
|
||||
// CrossDriveBackup status record. It is NEVER shown to the customer — every UI/notification
|
||||
// boundary maps it through SharesDisplayName (see DisplayStackName).
|
||||
//
|
||||
// RESERVED-NAME VERIFICATION (R-7b): the claim that slice-1 validation already excludes a leading
|
||||
// underscore is FALSE for shares — settings.nbNameRe starts with [A-Za-z0-9_], so „_shares" was an
|
||||
// accepted share name. settings.ValidateSMBShareName now rejects the underscore-prefixed namespace
|
||||
// outright. Stack names come from the git-synced catalog (not customer input) and so cannot
|
||||
// realistically produce „_shares", but RunAllTier2 and RunOffboxBackup skip such a stack with a
|
||||
// loud WARN as defense in depth rather than let it clobber the shares tree.
|
||||
SharesPseudoStack = "_shares"
|
||||
// SharesDisplayName is the Hungarian customer-facing name for the shares source.
|
||||
SharesDisplayName = "Megosztások"
|
||||
|
||||
// sharesManifestName is the share-definition document inside the payload.
|
||||
sharesManifestName = "_shares-manifest.json"
|
||||
// sharesPassdbName is the SECRET-BEARING samba passdb archive inside the payload. It rides the
|
||||
// restic repo (encrypted at rest) and the tier-2 staging on the customer's own drives; its bytes
|
||||
// and its name never reach a log line at INFO, a report, or a committed file.
|
||||
sharesPassdbName = "passdb.tar"
|
||||
// sharesPassdbMember is the subtree inside infra.SambaPassdbMount that holds the credential.
|
||||
sharesPassdbMember = "private"
|
||||
|
||||
// sharesManifestVersion pins the payload document shape.
|
||||
sharesManifestVersion = 1
|
||||
)
|
||||
|
||||
// DisplayStackName maps an engine-internal stack key to the name a customer may see. Today the only
|
||||
// mapping is the reserved shares pseudo-stack; every other key is its own display name. THIS is the
|
||||
// single boundary that keeps „_shares" out of the Hungarian UI, alerts and e-mails.
|
||||
func DisplayStackName(key string) string {
|
||||
if key == SharesPseudoStack {
|
||||
return SharesDisplayName
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// SharesManifest is the restorable share-definition document. It is deliberately timestamp-free at
|
||||
// the document level so the rendered JSON is BYTE-DETERMINISTIC for an unchanged registry — the
|
||||
// tier-2 mirror then has nothing to rewrite on a no-op run. Per-share CreatedAt is preserved.
|
||||
type SharesManifest struct {
|
||||
Version int `json:"version"`
|
||||
ServerName string `json:"server_name"`
|
||||
Shares []settings.SMBShare `json:"shares"`
|
||||
}
|
||||
|
||||
// SetSharesPassdbCapturer overrides the samba passdb capture (tests inject a fake so no docker runs).
|
||||
func (m *Manager) SetSharesPassdbCapturer(fn func() ([]byte, error)) { m.sharesPassdbCapture = fn }
|
||||
|
||||
// sharesPassdbCapturer returns the passdb capture seam (nil → the real docker exec).
|
||||
func (m *Manager) sharesPassdbCapturer() func() ([]byte, error) {
|
||||
if m.sharesPassdbCapture != nil {
|
||||
return m.sharesPassdbCapture
|
||||
}
|
||||
return defaultSharesPassdbCapture
|
||||
}
|
||||
|
||||
// defaultSharesPassdbCapture tars the samba private/ subtree out of the running container on stdout.
|
||||
// Best-effort by contract: a stopped container simply yields an error and the payload ships
|
||||
// manifest-only (re-setting the SMB password is a cheap UX step; the definitions are the load-bearing
|
||||
// part). Uses the same `docker exec` shape as stacks.extractInitialCreds.
|
||||
func defaultSharesPassdbCapture() ([]byte, error) {
|
||||
cmd := exec.Command("docker", "exec", infra.SambaContainerName,
|
||||
"tar", "cf", "-", "-C", infra.SambaPassdbMount, sharesPassdbMember)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("passdb capture: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sharesPayloadDir is the staging directory both tiers read. It lives in the controller DATA DIR
|
||||
// (not on a customer drive) for two reasons: the payload is kilobytes — manifest JSON plus a small
|
||||
// tdb archive — so the F-A1 rootfs-filler concern does not apply, and a DETERMINISTIC absolute path
|
||||
// makes the restic snapshot path stable, which is what the restore mapper anchors on. 0700 because
|
||||
// the passdb archive is secret-bearing.
|
||||
func (m *Manager) sharesPayloadDir() string {
|
||||
return filepath.Join(m.cfg.Paths.DataDir, "shares-payload")
|
||||
}
|
||||
|
||||
// SharesPayloadDir exposes the staging path for the restore mapper and tests.
|
||||
func (m *Manager) SharesPayloadDir() string { return m.sharesPayloadDir() }
|
||||
|
||||
// buildSharesManifest renders the deterministic manifest document from the live registry. Shares are
|
||||
// sorted case-insensitively by name so an unchanged registry always marshals to identical bytes
|
||||
// regardless of the order the customer happened to add them in.
|
||||
func (m *Manager) buildSharesManifest() SharesManifest {
|
||||
mf := SharesManifest{Version: sharesManifestVersion, Shares: []settings.SMBShare{}}
|
||||
if m.settings == nil {
|
||||
return mf
|
||||
}
|
||||
smb := m.settings.GetSMBSettings()
|
||||
mf.ServerName = smb.EffectiveServerName()
|
||||
mf.Shares = append(mf.Shares, m.settings.GetSMBShares()...)
|
||||
sort.Slice(mf.Shares, func(i, j int) bool {
|
||||
a, b := strings.ToLower(mf.Shares[i].Name), strings.ToLower(mf.Shares[j].Name)
|
||||
if a != b {
|
||||
return a < b
|
||||
}
|
||||
return mf.Shares[i].Name < mf.Shares[j].Name
|
||||
})
|
||||
return mf
|
||||
}
|
||||
|
||||
// buildSharesPayload stages the payload and returns its directory. The manifest is ALWAYS written
|
||||
// (it is the definitions-protection floor that must never regress); the passdb archive is
|
||||
// best-effort and its absence is a WARN, not an error. Returns the dir plus whether the passdb made
|
||||
// it in, so callers can report honestly.
|
||||
func (m *Manager) buildSharesPayload() (dir string, passdbOK bool, err error) {
|
||||
dir = m.sharesPayloadDir()
|
||||
if mkErr := os.MkdirAll(dir, 0o700); mkErr != nil {
|
||||
return "", false, fmt.Errorf("shares payload dir: %w", mkErr)
|
||||
}
|
||||
// Tighten an inherited-loose mode from an older layout (the passdb archive lives here).
|
||||
if chErr := os.Chmod(dir, 0o700); chErr != nil {
|
||||
m.logger.Printf("[WARN] [shares] payload dir chmod failed: %v", chErr)
|
||||
}
|
||||
|
||||
blob, mErr := json.MarshalIndent(m.buildSharesManifest(), "", " ")
|
||||
if mErr != nil {
|
||||
return "", false, fmt.Errorf("shares manifest marshal: %w", mErr)
|
||||
}
|
||||
blob = append(blob, '\n')
|
||||
if wErr := writeFileAtomic(filepath.Join(dir, sharesManifestName), blob, 0o600); wErr != nil {
|
||||
return "", false, fmt.Errorf("shares manifest write: %w", wErr)
|
||||
}
|
||||
|
||||
passdbPath := filepath.Join(dir, sharesPassdbName)
|
||||
tar, pErr := m.sharesPassdbCapturer()()
|
||||
switch {
|
||||
case pErr != nil || len(tar) == 0:
|
||||
// Container down / never deployed. Keep any PREVIOUSLY captured archive rather than deleting
|
||||
// it — a stale credential copy is strictly better for DR than none, and the payload stays
|
||||
// self-consistent because the manifest carries no password of its own.
|
||||
if _, sErr := os.Stat(passdbPath); sErr == nil {
|
||||
m.logger.Printf("[WARN] [shares] passdb capture unavailable (sharing service down?) — keeping the previously captured copy: %v", pErr)
|
||||
passdbOK = true
|
||||
} else {
|
||||
m.logger.Printf("[WARN] [shares] passdb capture unavailable (sharing service down?) — payload is manifest-only; the SMB password must be re-set after a restore: %v", pErr)
|
||||
}
|
||||
default:
|
||||
if wErr := writeFileAtomic(passdbPath, tar, 0o600); wErr != nil {
|
||||
// Never fatal: definitions protection must not hinge on the credential copy.
|
||||
m.logger.Printf("[WARN] [shares] passdb stage failed — payload is manifest-only: %v", wErr)
|
||||
} else {
|
||||
passdbOK = true
|
||||
m.logger.Printf("[DEBUG] [shares] passdb archive staged (%d bytes)", len(tar))
|
||||
}
|
||||
}
|
||||
m.logger.Printf("[INFO] [shares] payload staged: %d share definition(s), credential copy=%v", len(m.buildSharesManifest().Shares), passdbOK)
|
||||
return dir, passdbOK, nil
|
||||
}
|
||||
|
||||
// writeFileAtomic writes via tmp + rename at the requested mode (mirrors stacks.sambaWriteAtomic's
|
||||
// discipline: a crash mid-write can never leave a half-manifest a restore would read).
|
||||
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp, mode); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifiedShare is one share the backup sources act on, with its class resolved from the registry
|
||||
// exactly as stacks.sambaClassifiedBinds does it: Offsite=true ⇒ MANDATORY (offsite + tier-2),
|
||||
// Offsite=false ⇒ OPTIONAL (tier-2 only). Availability is resolved here too, so BOTH jobs skip a
|
||||
// dead mount identically (Scenario F).
|
||||
type classifiedShare struct {
|
||||
settings.SMBShare
|
||||
mandatory bool
|
||||
}
|
||||
|
||||
// classifiedShares returns the shares both jobs act on, dropping unavailable ones with a loud WARN.
|
||||
// A share whose owning drive is disconnected/decommissioned, or whose folder simply is not there, is
|
||||
// NEVER handed to a mirror or a restic argv: mirroring a missing mountpoint would copy an empty dir
|
||||
// over a good backup, and that is the silently-wrong-restore class this codebase refuses by rule.
|
||||
func (m *Manager) classifiedShares() []classifiedShare {
|
||||
if m.settings == nil {
|
||||
return nil
|
||||
}
|
||||
var out []classifiedShare
|
||||
for _, sh := range m.settings.GetSMBShares() {
|
||||
if !m.shareBackupAvailable(sh.Path) {
|
||||
m.logger.Printf("[WARN] [shares] share skipped — folder unavailable (drive away or path missing): name=%s", sh.Name)
|
||||
continue
|
||||
}
|
||||
out = append(out, classifiedShare{SMBShare: sh, mandatory: sh.Offsite})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
|
||||
return out
|
||||
}
|
||||
|
||||
// shareBackupAvailable mirrors stacks.shareAvailable (the export-time gate) so the backup sources and
|
||||
// the smb.conf renderer agree on what "available" means. Kept local rather than imported: the backup
|
||||
// package must not depend on the stacks package.
|
||||
func (m *Manager) shareBackupAvailable(path string) bool {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return false
|
||||
}
|
||||
if m.settings != nil {
|
||||
p := filepath.ToSlash(path)
|
||||
for _, sp := range m.settings.GetStoragePaths() {
|
||||
root := filepath.ToSlash(sp.Path)
|
||||
if p == root || strings.HasPrefix(p, root+"/") {
|
||||
if sp.Disconnected || sp.Decommissioned {
|
||||
return false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
return err == nil && st.IsDir()
|
||||
}
|
||||
|
||||
// sharesEnabled reports whether the shares source has anything to do at all: the feature is on AND at
|
||||
// least one share is registered. A disabled feature or an empty registry is a clean no-op in both
|
||||
// jobs — no `_shares` restic group, no empty dest dirs (the zero-shares edge case).
|
||||
func (m *Manager) sharesEnabled() bool {
|
||||
if m.settings == nil {
|
||||
return false
|
||||
}
|
||||
return m.settings.GetSMBSettings().Enabled && len(m.settings.GetSMBShares()) > 0
|
||||
}
|
||||
|
||||
// sharesNow is the timestamp helper for the shares status records (kept in one place so tests that
|
||||
// assert on recorded status have a single seam to reason about).
|
||||
func sharesNow() string { return time.Now().Format(time.RFC3339) }
|
||||
@@ -0,0 +1,415 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// --- shared harness -------------------------------------------------------------------------------
|
||||
|
||||
// sharesEnv is a Manager wired for the R-7b shares source: a real settings store, real temp drives,
|
||||
// and FAKE copy/exec seams so nothing shells out to rsync, du, docker or restic.
|
||||
type sharesEnv struct {
|
||||
m *Manager
|
||||
// mirrored records every tier2Mirror call as "src=>dst" in call order.
|
||||
mirrored []string
|
||||
// drives maps a label to the temp dir standing in for that registered drive.
|
||||
drives map[string]string
|
||||
sett *settings.Settings
|
||||
tmp string
|
||||
}
|
||||
|
||||
// newSharesEnv registers the named drives, turns sharing on, and installs the fake seams. Shares are
|
||||
// added afterwards with addShare so each test states exactly the registry it needs.
|
||||
func newSharesEnv(t *testing.T, driveNames ...string) *sharesEnv {
|
||||
t.Helper()
|
||||
tmp := t.TempDir()
|
||||
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := &sharesEnv{drives: map[string]string{}, sett: sett, tmp: tmp}
|
||||
for _, name := range driveNames {
|
||||
p := filepath.Join(tmp, name)
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sett.AddStoragePath(settings.StoragePath{Path: p, Label: name, Schedulable: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.drives[name] = p
|
||||
}
|
||||
if err := sett.SetSMBEnabled(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.DataDir = filepath.Join(tmp, "data")
|
||||
sysDrive := filepath.Join(tmp, "sysdrive")
|
||||
if err := os.MkdirAll(sysDrive, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Paths.SystemDataPath = sysDrive
|
||||
|
||||
env.m = &Manager{
|
||||
cfg: cfg, settings: sett, logger: log.New(io.Discard, "", 0), systemDataPath: sysDrive,
|
||||
// Fake mirror: records the call AND actually copies, so restore tests have real bytes.
|
||||
tier2Mirror: func(src, dst string) error {
|
||||
env.mirrored = append(env.mirrored, src+"=>"+dst)
|
||||
return copyTreeForTest(src, dst)
|
||||
},
|
||||
sharesPassdbCapture: func() ([]byte, error) { return []byte("FAKE-PASSDB-TAR"), nil },
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// addShare registers a share, creating its folder with one file so it is "available".
|
||||
func (e *sharesEnv) addShare(t *testing.T, drive, name string, offsite bool) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(e.drives[drive], name)
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(p, name+".txt"), []byte("content-of-"+name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.sett.AddSMBShare(settings.SMBShare{Name: name, Path: p, Offsite: offsite, CreatedAt: "2026-07-18T00:00:00Z"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// copyTreeForTest is a minimal recursive copy standing in for rsync in the mirror seam.
|
||||
func copyTreeForTest(src, dst string) error {
|
||||
fi, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
b, rErr := os.ReadFile(src)
|
||||
if rErr != nil {
|
||||
return rErr
|
||||
}
|
||||
if mErr := os.MkdirAll(filepath.Dir(dst), 0o755); mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
return os.WriteFile(dst, b, 0o644)
|
||||
}
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if err := copyTreeForTest(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mirroredDsts returns just the destination side of every recorded mirror call.
|
||||
func (e *sharesEnv) mirroredDsts() []string {
|
||||
var out []string
|
||||
for _, s := range e.mirrored {
|
||||
out = append(out, s[strings.Index(s, "=>")+2:])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Part 1: payload ------------------------------------------------------------------------------
|
||||
|
||||
// The manifest must carry the share DEFINITIONS verbatim — that is the whole point of the payload:
|
||||
// after a DR rebuild the customer gets their files AND their „Megosztás" page back, not an empty one.
|
||||
func TestSharesPayloadManifestMatchesRegistry(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1")
|
||||
env.addShare(t, "hdd_1", "dokumentumok", true)
|
||||
env.addShare(t, "hdd_1", "filmek", false)
|
||||
|
||||
dir, passdbOK, err := env.m.buildSharesPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("buildSharesPayload: %v", err)
|
||||
}
|
||||
if !passdbOK {
|
||||
t.Fatal("expected the (faked) passdb capture to succeed")
|
||||
}
|
||||
blob, err := os.ReadFile(filepath.Join(dir, sharesManifestName))
|
||||
if err != nil {
|
||||
t.Fatalf("read manifest: %v", err)
|
||||
}
|
||||
var mf SharesManifest
|
||||
if err := json.Unmarshal(blob, &mf); err != nil {
|
||||
t.Fatalf("manifest is not valid JSON: %v", err)
|
||||
}
|
||||
if mf.Version != sharesManifestVersion {
|
||||
t.Errorf("manifest version = %d, want %d", mf.Version, sharesManifestVersion)
|
||||
}
|
||||
if mf.ServerName != settings.DefaultSMBServerName {
|
||||
t.Errorf("server name = %q, want %q", mf.ServerName, settings.DefaultSMBServerName)
|
||||
}
|
||||
want := env.sett.GetSMBShares()
|
||||
sort.Slice(want, func(i, j int) bool { return want[i].Name < want[j].Name })
|
||||
if len(mf.Shares) != len(want) {
|
||||
t.Fatalf("manifest has %d shares, registry has %d", len(mf.Shares), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if mf.Shares[i] != want[i] {
|
||||
t.Errorf("share %d: manifest %+v != registry %+v", i, mf.Shares[i], want[i])
|
||||
}
|
||||
}
|
||||
// The credential copy must be present and 0600 — it is secret-bearing.
|
||||
st, err := os.Stat(filepath.Join(dir, sharesPassdbName))
|
||||
if err != nil {
|
||||
t.Fatalf("passdb archive missing: %v", err)
|
||||
}
|
||||
if runtimeIsPOSIX() && st.Mode().Perm() != 0o600 {
|
||||
t.Errorf("passdb archive mode = %o, want 600 (secret-bearing)", st.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
// Determinism: an unchanged registry must marshal to IDENTICAL bytes regardless of the order shares
|
||||
// were added in, so a no-op nightly run gives the tier-2 mirror nothing to rewrite.
|
||||
func TestSharesPayloadManifestIsDeterministic(t *testing.T) {
|
||||
// Built from the registry only (no filesystem), so the two runs differ ONLY in insertion order —
|
||||
// otherwise the temp-dir paths baked into each share would make any comparison meaningless.
|
||||
read := func(order []string) []byte {
|
||||
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range order {
|
||||
if err := sett.AddSMBShare(settings.SMBShare{
|
||||
Name: n, Path: "/mnt/hdd_1/" + n, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m := &Manager{settings: sett, logger: log.New(io.Discard, "", 0)}
|
||||
b, err := json.MarshalIndent(m.buildSharesManifest(), "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
a := read([]string{"alpha", "zulu", "mike"})
|
||||
b := read([]string{"zulu", "mike", "alpha"})
|
||||
if string(a) != string(b) {
|
||||
t.Errorf("manifest is order-dependent:\n%s\n---\n%s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: samba container down. The payload must still be produced (manifest-only) and must NOT
|
||||
// error — re-setting the SMB password is cheap; losing the definitions is not.
|
||||
func TestSharesPayloadPassdbAbsentIsManifestOnly(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1")
|
||||
env.addShare(t, "hdd_1", "dokumentumok", true)
|
||||
env.m.sharesPassdbCapture = func() ([]byte, error) { return nil, os.ErrNotExist }
|
||||
|
||||
dir, passdbOK, err := env.m.buildSharesPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("a down container must not fail the payload: %v", err)
|
||||
}
|
||||
if passdbOK {
|
||||
t.Error("passdbOK must be false when the capture failed and no prior copy exists")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, sharesManifestName)); err != nil {
|
||||
t.Errorf("manifest must still be written: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, sharesPassdbName)); !os.IsNotExist(err) {
|
||||
t.Error("no passdb archive should exist when the capture failed and none was staged before")
|
||||
}
|
||||
}
|
||||
|
||||
// A previously captured credential copy must SURVIVE a later failed capture — a stale credential is
|
||||
// strictly better for DR than none, and the manifest carries no password of its own to contradict it.
|
||||
func TestSharesPayloadKeepsPriorPassdbOnCaptureFailure(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1")
|
||||
env.addShare(t, "hdd_1", "dokumentumok", true)
|
||||
dir, _, err := env.m.buildSharesPayload()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env.m.sharesPassdbCapture = func() ([]byte, error) { return nil, os.ErrNotExist }
|
||||
_, passdbOK, err := env.m.buildSharesPayload()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !passdbOK {
|
||||
t.Error("a previously staged passdb copy must keep passdbOK true")
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, sharesPassdbName))
|
||||
if err != nil || string(b) != "FAKE-PASSDB-TAR" {
|
||||
t.Errorf("prior passdb copy was lost: %q, %v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The reserved key must never reach a Hungarian surface raw.
|
||||
func TestDisplayStackNameMapsReservedKey(t *testing.T) {
|
||||
if got := DisplayStackName(SharesPseudoStack); got != SharesDisplayName {
|
||||
t.Errorf("DisplayStackName(%q) = %q, want %q", SharesPseudoStack, got, SharesDisplayName)
|
||||
}
|
||||
if got := DisplayStackName("immich"); got != "immich" {
|
||||
t.Errorf("DisplayStackName must be identity for ordinary stacks, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Part 2: tier-2 shares job --------------------------------------------------------------------
|
||||
|
||||
// Scenario B: an OPTIONAL (Felhőmentés-off) share is tier-2'd like any other — the tier-2 tier is the
|
||||
// local copy every share gets; only the offsite tier discriminates.
|
||||
func TestSharesTier2MirrorsBothClasses(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
env.addShare(t, "hdd_1", "dokumentumok", true)
|
||||
env.addShare(t, "hdd_1", "filmek", false)
|
||||
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatalf("RunSharesTier2: %v", err)
|
||||
}
|
||||
key := sharesDriveKey(env.drives["hdd_1"])
|
||||
destBase := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack)
|
||||
|
||||
for _, share := range []string{"dokumentumok", "filmek"} {
|
||||
want := filepath.Join(destBase, key, share)
|
||||
if _, err := os.Stat(filepath.Join(want, share+".txt")); err != nil {
|
||||
t.Errorf("share %s was not mirrored to %s: %v", share, want, err)
|
||||
}
|
||||
}
|
||||
// The payload rides the target so it is independently restorable.
|
||||
if _, err := os.Stat(filepath.Join(destBase, sharesPayloadDestRel, sharesManifestName)); err != nil {
|
||||
t.Errorf("payload manifest missing from the tier-2 target: %v", err)
|
||||
}
|
||||
// Marker LAST: its presence means every leg + reconcile completed.
|
||||
b, err := os.ReadFile(filepath.Join(destBase, tier2LayoutMarker))
|
||||
if err != nil || string(b) != tier2LayoutVersion {
|
||||
t.Errorf("layout marker missing/wrong: %q, %v", b, err)
|
||||
}
|
||||
// Status is recorded under the reserved key, and renders as „Megosztások".
|
||||
cd := env.m.SharesTier2Status()
|
||||
if cd == nil || cd.LastStatus != "ok" {
|
||||
t.Fatalf("shares tier-2 status = %+v, want LastStatus=ok", cd)
|
||||
}
|
||||
}
|
||||
|
||||
// The target may NEVER be the leg's own source drive — that would be a same-disk copy pretending to
|
||||
// be a second backup. With shares on two drives each group must land on the other one.
|
||||
func TestSharesTier2NeverTargetsItsOwnSourceDrive(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
env.addShare(t, "hdd_1", "egy", true)
|
||||
env.addShare(t, "hdd_2", "ketto", true)
|
||||
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatalf("RunSharesTier2: %v", err)
|
||||
}
|
||||
for _, call := range env.mirrored {
|
||||
src, dst, _ := strings.Cut(call, "=>")
|
||||
if strings.HasPrefix(src, env.drives["hdd_1"]) && strings.HasPrefix(dst, env.drives["hdd_1"]) {
|
||||
t.Errorf("leg mirrored onto its own source drive: %s", call)
|
||||
}
|
||||
if strings.HasPrefix(src, env.drives["hdd_2"]) && strings.HasPrefix(dst, env.drives["hdd_2"]) {
|
||||
t.Errorf("leg mirrored onto its own source drive: %s", call)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario F: a share whose drive is marked away is skipped LOUDLY and its path reaches NO mirror
|
||||
// argument — mirroring a dead mountpoint would copy an empty dir over a good backup.
|
||||
func TestSharesTier2SkipsDeadMountAndContinues(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2", "hdd_3")
|
||||
env.addShare(t, "hdd_1", "elo", true)
|
||||
deadPath := env.addShare(t, "hdd_3", "halott", true)
|
||||
if err := env.sett.SetDisconnected(env.drives["hdd_3"], true, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatalf("the run must continue for healthy shares: %v", err)
|
||||
}
|
||||
for _, call := range env.mirrored {
|
||||
if strings.Contains(call, deadPath) {
|
||||
t.Errorf("dead-mount share reached a mirror call: %s", call)
|
||||
}
|
||||
}
|
||||
var sawLive bool
|
||||
for _, dst := range env.mirroredDsts() {
|
||||
if strings.HasSuffix(dst, string(filepath.Separator)+"elo") {
|
||||
sawLive = true
|
||||
}
|
||||
}
|
||||
if !sawLive {
|
||||
t.Error("the healthy share must still be mirrored")
|
||||
}
|
||||
}
|
||||
|
||||
// Zero shares / sharing disabled: a clean no-op — no dest tree, no status record, no empty dirs.
|
||||
func TestSharesTier2ZeroSharesIsCleanNoOp(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatalf("RunSharesTier2: %v", err)
|
||||
}
|
||||
if len(env.mirrored) != 0 {
|
||||
t.Errorf("expected no mirror calls, got %v", env.mirrored)
|
||||
}
|
||||
if cd := env.m.SharesTier2Status(); cd != nil {
|
||||
t.Errorf("expected no status record for a feature nobody uses, got %+v", cd)
|
||||
}
|
||||
destBase := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack)
|
||||
if _, err := os.Stat(destBase); !os.IsNotExist(err) {
|
||||
t.Errorf("expected no _shares dest tree, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A share removed from the registry must stop occupying the secondary drive within ONE run — the
|
||||
// reconcile pass prunes it, reusing the same destBase-bounded removal guard the app path uses.
|
||||
func TestSharesTier2ReconcilePrunesRemovedShare(t *testing.T) {
|
||||
env := newSharesEnv(t, "hdd_1", "hdd_2")
|
||||
env.addShare(t, "hdd_1", "marad", true)
|
||||
env.addShare(t, "hdd_1", "torolt", true)
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := sharesDriveKey(env.drives["hdd_1"])
|
||||
destBase := filepath.Join(NamespaceRoot(env.drives["hdd_2"], true), "backups", "secondary", SharesPseudoStack)
|
||||
stale := filepath.Join(destBase, key, "torolt")
|
||||
if _, err := os.Stat(stale); err != nil {
|
||||
t.Fatalf("precondition: %s should exist after the first run: %v", stale, err)
|
||||
}
|
||||
|
||||
if err := env.sett.RemoveSMBShare("torolt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := env.m.RunSharesTier2(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(stale); !os.IsNotExist(err) {
|
||||
t.Errorf("removed share's dest dir was not pruned: stat err = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destBase, key, "marad")); err != nil {
|
||||
t.Errorf("surviving share was pruned by mistake: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Drive keys must be collision-free across drives that share a basename.
|
||||
func TestSharesDriveKeyIsCollisionFree(t *testing.T) {
|
||||
if sharesDriveKey("/mnt/a/data") == sharesDriveKey("/mnt/b/data") {
|
||||
t.Error("drives with the same basename must not map to the same dest key")
|
||||
}
|
||||
for _, bad := range []string{"/", "\x00", " "} {
|
||||
if k := sharesDriveKey(bad); strings.ContainsAny(k, `/\`) {
|
||||
t.Errorf("sharesDriveKey(%q) = %q — must be a single safe path segment", bad, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeIsPOSIX reports whether file-mode assertions are meaningful on this host (Windows reports
|
||||
// synthesised permissions, so mode checks there are noise rather than signal).
|
||||
func runtimeIsPOSIX() bool { return os.PathSeparator == '/' }
|
||||
@@ -86,6 +86,17 @@ func tier2FitsHeadroom(availGB, totalGB, unitGB float64) bool {
|
||||
// silently-wrong-owner restore). fullSize sizes the real-drive path; stateOnlySize sizes the SSD path.
|
||||
func (m *Manager) selectTier2Target(stackName string, fullSize, stateOnlySize int64) (*Tier2Target, error) {
|
||||
sourceDrive := m.GetAppDrivePath(stackName)
|
||||
if sourceDrive == "" {
|
||||
return nil, fmt.Errorf("no source drive for %s", stackName)
|
||||
}
|
||||
return m.selectTier2TargetFrom(stackName, sourceDrive, fullSize, stateOnlySize)
|
||||
}
|
||||
|
||||
// selectTier2TargetFrom is selectTier2Target with the source drive supplied EXPLICITLY. It exists so
|
||||
// the R-7b shares source — whose "source drive" is the drive a group of shares lives on, not an app's
|
||||
// GetAppDrivePath — can reuse this selection and its headroom math verbatim instead of forking it.
|
||||
// The app path above is a thin wrapper; nothing about its behaviour changed.
|
||||
func (m *Manager) selectTier2TargetFrom(stackName, sourceDrive string, fullSize, stateOnlySize int64) (*Tier2Target, error) {
|
||||
if sourceDrive == "" {
|
||||
return nil, fmt.Errorf("no source drive for %s", stackName)
|
||||
}
|
||||
@@ -232,6 +243,13 @@ func classifyTier2Rel(dirRel string, legRels []string) tier2RelClass {
|
||||
// descendant of any current leg relpath (§7-D — the deferred-pruning answer: a bind removed/re-classed
|
||||
// stops occupying the secondary drive within one run). Runs strictly inside destBase.
|
||||
func (m *Manager) tier2Reconcile(destBase string, legRels []string) {
|
||||
m.tier2ReconcileRoots(destBase, []string{"hdd", "userdata"}, legRels)
|
||||
}
|
||||
|
||||
// tier2ReconcileRoots is tier2Reconcile with the top-level dest roots supplied explicitly — a pure
|
||||
// extraction so the R-7b shares dest (whose roots are per-source-drive keys, not hdd/userdata) can
|
||||
// reuse the SAME staleness classification and the SAME destBase-bounded removal guard.
|
||||
func (m *Manager) tier2ReconcileRoots(destBase string, roots, legRels []string) {
|
||||
var walk func(dirAbs, dirRel string)
|
||||
walk = func(dirAbs, dirRel string) {
|
||||
entries, err := os.ReadDir(dirAbs)
|
||||
@@ -258,7 +276,7 @@ func (m *Manager) tier2Reconcile(destBase string, legRels []string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, root := range []string{"hdd", "userdata"} {
|
||||
for _, root := range roots {
|
||||
walk(filepath.Join(destBase, root), root)
|
||||
}
|
||||
}
|
||||
@@ -397,6 +415,14 @@ func (m *Manager) RunAllTier2() {
|
||||
}
|
||||
var n int
|
||||
for _, stack := range m.stackProvider.ListDeployedStacks() {
|
||||
// Reserved-name defense in depth (R-7b): the shares source owns backups/secondary/_shares and
|
||||
// the _shares status record. Stack names come from the git-synced catalog, not customer input,
|
||||
// so this cannot realistically fire — but if it ever did, the app would silently overwrite the
|
||||
// shares tree, so it is refused loudly instead.
|
||||
if stack.Name == SharesPseudoStack {
|
||||
m.logger.Printf("[ERROR] [backup] Tier 2: stack %q uses the RESERVED shares key — skipped to protect the shares backup tree", stack.Name)
|
||||
continue
|
||||
}
|
||||
// F6 (CAMPAIGN-3): volume-only apps (no HDD_PATH, backups on sys_drive) previously got NO
|
||||
// tier-2 copy — a single controller-level copy on one device. They now flow through too: their
|
||||
// recovery unit (which holds the db/volume dumps) gets a cross-drive second copy like any HDD
|
||||
@@ -416,6 +442,12 @@ func (m *Manager) RunAllTier2() {
|
||||
n++
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] Tier 2 run complete: %d app(s) processed (incl. volume-only — F6)", n)
|
||||
|
||||
// R-7b: the SHARES source runs after the per-app loop, in the SAME orchestrator run. It is a
|
||||
// sibling job — nothing above it changed — and its failure never fails the app tier.
|
||||
if err := m.RunSharesTier2(); err != nil {
|
||||
m.logger.Printf("[WARN] [backup] Tier 2 shares job failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- per-app config-panel view (drives the Tier-2 "Beállítás" page) ---
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
|
||||
// Tier-2 shares job — R-7b Part 2, the local cross-drive leg of Model B′.
|
||||
//
|
||||
// It runs AFTER the per-stack tier-2 loop, inside the SAME orchestrator run, and it is a SIBLING of
|
||||
// that loop, never a modification of it: RunTier2 and its per-app dest tree are untouched (the B′
|
||||
// invariant, enforced by TestSharesTier2LeavesPerAppTreeUntouched). What it reuses instead is every
|
||||
// primitive the per-app path proved: the tier2Mirror seam, selectTier2TargetFrom's target choice and
|
||||
// headroom math, tier2ReconcileRoots' staleness pruning, tier2SafeRemove's destBase-bounded removal,
|
||||
// the marker-written-LAST discipline, and the recordTier2* status helpers.
|
||||
//
|
||||
// Layout — backups/secondary/_shares/ on the target drive:
|
||||
//
|
||||
// .felhom-tier2-layout marker, content "2", written LAST (after every leg + reconcile)
|
||||
// _payload/ the share definitions + credential copy (shares_payload.go)
|
||||
// <sourceDriveKey>/<share>/ one mirrored leg per share, grouped by the drive it came from
|
||||
//
|
||||
// Shares are grouped BY SOURCE DRIVE because a household's shares can span several drives and each
|
||||
// group needs its own cross-drive target: a leg's target may never be that leg's own source drive.
|
||||
|
||||
// sharesPayloadDestRel is the payload's relpath inside the shares dest (a reserved root name; the
|
||||
// leading underscore cannot collide with a drive key because drive keys are derived from paths).
|
||||
const sharesPayloadDestRel = "_payload"
|
||||
|
||||
// sharesDriveKey turns an absolute source-drive path into ONE safe dest path segment. The full path
|
||||
// is encoded (not just its basename) so two drives whose mountpoints share a basename — /mnt/a/data
|
||||
// and /mnt/b/data — can never map onto the same dest subtree and silently overwrite each other.
|
||||
// Deterministic and stable across runs, which is what lets the reconcile pass recognise its own dirs.
|
||||
func sharesDriveKey(drive string) string {
|
||||
s := strings.Trim(filepath.ToSlash(drive), "/")
|
||||
if s == "" {
|
||||
return "root"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// shareSourceDrive resolves the registered storage root a share's folder lives under. Returns "" when
|
||||
// the share sits under no registered root — such a share has no meaningful "other drive" and is
|
||||
// skipped with a warning rather than guessed at.
|
||||
func (m *Manager) shareSourceDrive(path string) string {
|
||||
if m.settings == nil {
|
||||
return ""
|
||||
}
|
||||
p := filepath.ToSlash(path)
|
||||
best := ""
|
||||
for _, sp := range m.settings.GetStoragePaths() {
|
||||
root := filepath.ToSlash(sp.Path)
|
||||
if root == "" {
|
||||
continue
|
||||
}
|
||||
// Longest match wins, so a nested registered root claims its own shares. Containment is
|
||||
// compared in slash form, but the returned value is the REGISTRY'S OWN string: the target
|
||||
// selector compares the source drive against sp.Path by equality, and handing it a normalised
|
||||
// variant would make that comparison miss and let a group target its own source drive.
|
||||
if (p == root || strings.HasPrefix(p, root+"/")) && len(root) > len(best) {
|
||||
best = sp.Path
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// sharesTier2Group is one source drive's worth of shares plus the target chosen for it.
|
||||
type sharesTier2Group struct {
|
||||
sourceDrive string
|
||||
key string
|
||||
shares []classifiedShare
|
||||
}
|
||||
|
||||
// RunSharesTier2 mirrors every classified, available share to a cross-drive target and stages the
|
||||
// payload beside it. Best-effort and idempotent, exactly like RunTier2: an absent target is an
|
||||
// honest recorded status, not an error. Returns the first hard copy error.
|
||||
func (m *Manager) RunSharesTier2() error {
|
||||
if !m.sharesEnabled() {
|
||||
// Feature off or no shares registered: a clean no-op. Deliberately NOT a recorded status —
|
||||
// writing one would make the „Megosztás" page claim a backup tier for a feature in use by
|
||||
// nobody, and would create the dest tree for zero shares.
|
||||
return nil
|
||||
}
|
||||
if m.settings != nil {
|
||||
if cd := m.settings.GetCrossDriveConfig(SharesPseudoStack); cd != nil && cd.UserDisabled {
|
||||
m.logger.Printf("[INFO] [shares] tier-2 skipped — disabled by customer")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
shares := m.classifiedShares()
|
||||
if len(shares) == 0 {
|
||||
// Every registered share is on a drive that is away / a folder that vanished. That is a real
|
||||
// operational state the customer must see, not silence.
|
||||
m.recordTier2NoTarget(SharesPseudoStack, "egyetlen megosztás mappája sem érhető el — ellenőrizd a meghajtókat")
|
||||
m.logger.Printf("[WARN] [shares] tier-2: no available share folders — nothing mirrored")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Group by source drive (deterministic order so logs and dest trees are reproducible).
|
||||
byDrive := map[string]*sharesTier2Group{}
|
||||
var warns []string
|
||||
for _, sh := range shares {
|
||||
drive := m.shareSourceDrive(sh.Path)
|
||||
if drive == "" {
|
||||
m.logger.Printf("[WARN] [shares] tier-2: share %s is not under a registered storage root — skipped", sh.Name)
|
||||
warns = append(warns, fmt.Sprintf("A(z) „%s” megosztás nem regisztrált adatmeghajtón van — kimaradt a 2. mentésből.", sh.Name))
|
||||
continue
|
||||
}
|
||||
g := byDrive[drive]
|
||||
if g == nil {
|
||||
g = &sharesTier2Group{sourceDrive: drive, key: sharesDriveKey(drive)}
|
||||
byDrive[drive] = g
|
||||
}
|
||||
g.shares = append(g.shares, sh)
|
||||
}
|
||||
var groups []*sharesTier2Group
|
||||
for _, g := range byDrive {
|
||||
groups = append(groups, g)
|
||||
}
|
||||
sort.Slice(groups, func(i, j int) bool { return groups[i].sourceDrive < groups[j].sourceDrive })
|
||||
if len(groups) == 0 {
|
||||
m.recordTier2NoTarget(SharesPseudoStack, "a megosztások egyike sincs regisztrált adatmeghajtón")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Payload once per run — both tiers read the same staged directory.
|
||||
payloadDir, _, perr := m.buildSharesPayload()
|
||||
if perr != nil {
|
||||
m.logger.Printf("[WARN] [shares] tier-2: payload staging failed — mirroring files without the definition manifest: %v", perr)
|
||||
warns = append(warns, "A megosztás-beállítások mentése nem sikerült — a fájlok mentése megtörtént.")
|
||||
payloadDir = ""
|
||||
}
|
||||
|
||||
mirror := m.tier2Mirror
|
||||
if mirror == nil {
|
||||
mirror = rsyncMirror
|
||||
}
|
||||
start := time.Now()
|
||||
var (
|
||||
totalSize int64
|
||||
lastTarget *Tier2Target
|
||||
noTargetWhy []string
|
||||
mirrored int
|
||||
)
|
||||
for _, g := range groups {
|
||||
// Two sizes, mirroring RunTier2's contract: full = every share in the group; state-only = the
|
||||
// MANDATORY (Felhőmentés-on) subset, which is what the SSD headroom guard must fit.
|
||||
var fullSize, stateOnlySize int64
|
||||
for _, sh := range g.shares {
|
||||
sz := dirSizeBytes(sh.Path)
|
||||
fullSize += sz
|
||||
if sh.mandatory {
|
||||
stateOnlySize += sz
|
||||
}
|
||||
}
|
||||
target, err := m.selectTier2TargetFrom(SharesPseudoStack, g.sourceDrive, fullSize, stateOnlySize)
|
||||
if err != nil {
|
||||
why := tier2NoTargetReason(err)
|
||||
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: %s", g.sourceDrive, why))
|
||||
m.logger.Printf("[INFO] [shares] tier-2: no off-drive target for shares on %s — %s", g.sourceDrive, why)
|
||||
continue
|
||||
}
|
||||
// Defense-in-depth off-drive guard (selection already enforced it): a leg's target may never
|
||||
// be that leg's own source drive — that would be a same-disk "copy" pretending to be tier 2.
|
||||
if system.SamePhysicalDevice(g.sourceDrive, target.NamespaceRoot) {
|
||||
noTargetWhy = append(noTargetWhy, fmt.Sprintf("%s: a kiválasztott cél ugyanazon a fizikai lemezen van", g.sourceDrive))
|
||||
continue
|
||||
}
|
||||
legs := g.shares
|
||||
if target.StateOnly {
|
||||
kept := legs[:0]
|
||||
dropped := false
|
||||
for _, sh := range legs {
|
||||
if sh.mandatory {
|
||||
kept = append(kept, sh)
|
||||
} else {
|
||||
dropped = true
|
||||
}
|
||||
}
|
||||
legs = kept
|
||||
if dropped {
|
||||
warns = append(warns, "A belső SSD-re csak a felhőmentésre jelölt megosztások férnek el — a többi nem került másolásra.")
|
||||
}
|
||||
}
|
||||
|
||||
destBase := filepath.Join(target.NamespaceRoot, "backups", "secondary", SharesPseudoStack)
|
||||
legRels := make([]string, 0, len(legs)+1)
|
||||
for _, sh := range legs {
|
||||
rel := g.key + "/" + sh.Name
|
||||
if err := mirror(sh.Path, filepath.Join(destBase, filepath.FromSlash(rel))); err != nil {
|
||||
m.recordTier2Failure(SharesPseudoStack, target, err)
|
||||
if m.tier2Notify != nil {
|
||||
m.tier2Notify(SharesPseudoStack, target.Label, time.Since(start), err)
|
||||
}
|
||||
return fmt.Errorf("tier2 shares mirror %s: %w", sh.Name, err)
|
||||
}
|
||||
legRels = append(legRels, rel)
|
||||
totalSize += dirSizeBytes(sh.Path)
|
||||
mirrored++
|
||||
}
|
||||
// The payload rides every target so each one is independently restorable.
|
||||
if payloadDir != "" {
|
||||
if err := mirror(payloadDir, filepath.Join(destBase, sharesPayloadDestRel)); err != nil {
|
||||
m.logger.Printf("[WARN] [shares] tier-2: payload mirror to %s failed (files are copied): %v", destBase, err)
|
||||
} else {
|
||||
legRels = append(legRels, sharesPayloadDestRel)
|
||||
}
|
||||
}
|
||||
// Prune dest dirs no share covers any more (a share deleted or renamed since the last run
|
||||
// stops occupying the secondary drive within one run), then the marker LAST — a half-written
|
||||
// dest therefore has no marker and the restore path refuses it until the next good run.
|
||||
m.tier2ReconcileRoots(destBase, sharesTier2DestRoots(destBase), legRels)
|
||||
if err := m.writeTier2Marker(destBase); err != nil {
|
||||
m.logger.Printf("[WARN] [shares] tier-2: layout marker write failed (restore will refuse until next run): %v", err)
|
||||
}
|
||||
lastTarget = target
|
||||
m.logger.Printf("[INFO] [shares] tier-2 copied %d share(s) from %s → %s (%s)",
|
||||
len(legs), g.sourceDrive, destBase, humanizeBytes(totalSize))
|
||||
}
|
||||
|
||||
dur := time.Since(start)
|
||||
if lastTarget == nil {
|
||||
reason := strings.Join(noTargetWhy, "; ")
|
||||
if reason == "" {
|
||||
reason = "nincs másik fizikai meghajtó — a 2. mentéshez 2. meghajtó szükséges"
|
||||
}
|
||||
m.recordTier2NoTarget(SharesPseudoStack, reason)
|
||||
m.logger.Printf("[INFO] [shares] tier-2: no off-drive target for any share group — %s", reason)
|
||||
return nil
|
||||
}
|
||||
if len(noTargetWhy) > 0 {
|
||||
warns = append(warns, "Néhány meghajtón lévő megosztásnak nincs másodlagos célja: "+strings.Join(noTargetWhy, "; "))
|
||||
}
|
||||
m.recordTier2Success(SharesPseudoStack, lastTarget, totalSize, strings.Join(warns, " "), dur)
|
||||
if m.tier2Notify != nil {
|
||||
m.tier2Notify(SharesPseudoStack, lastTarget.Label, dur, nil)
|
||||
}
|
||||
m.logger.Printf("[INFO] [shares] tier-2 run complete: %d share leg(s), %s, %s",
|
||||
mirrored, humanizeBytes(totalSize), dur.Round(time.Second))
|
||||
return nil
|
||||
}
|
||||
|
||||
// sharesTier2DestRoots lists the top-level dirs under a shares destBase that the reconcile pass may
|
||||
// walk. It reads what is ON DISK rather than what this run produced, so a drive key from a drive that
|
||||
// is no longer registered still gets visited (and pruned) instead of lingering forever.
|
||||
func sharesTier2DestRoots(destBase string) []string {
|
||||
entries, err := os.ReadDir(destBase)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var roots []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
roots = append(roots, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
// writeTier2Marker writes the shared layout marker (content "2") — the LAST write of a dest, so its
|
||||
// presence means "every leg and the reconcile completed".
|
||||
func (m *Manager) writeTier2Marker(destBase string) error {
|
||||
return os.WriteFile(filepath.Join(destBase, tier2LayoutMarker), []byte(tier2LayoutVersion), 0o644)
|
||||
}
|
||||
|
||||
// SharesTier2Status returns the recorded shares tier-2 status for the „Megosztás" page (nil when the
|
||||
// job has never run). It is the SAME CrossDriveBackup record the per-app rows use, keyed by the
|
||||
// reserved pseudo-stack.
|
||||
func (m *Manager) SharesTier2Status() *settings.CrossDriveBackup {
|
||||
if m.settings == nil {
|
||||
return nil
|
||||
}
|
||||
return m.settings.GetCrossDriveConfig(SharesPseudoStack)
|
||||
}
|
||||
Reference in New Issue
Block a user