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:
2026-07-18 12:45:57 +02:00
parent 3dfc49e578
commit c81df55dcb
8 changed files with 1050 additions and 13 deletions
+9
View File
@@ -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) }
+415
View File
@@ -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 == '/' }
+33 -1
View File
@@ -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) ---
+291
View File
@@ -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)
}
+25 -10
View File
@@ -30,6 +30,21 @@ type SambaData struct {
// SambaHouseholdUser is the single household SMB account name (matches the entrypoint's unix user).
const SambaHouseholdUser = "felhom"
// SambaContainerName is the fixed container name the compose render pins. It is THE single source of
// truth for it: the renderer below interpolates this constant, stacks.sambaContainer aliases it for
// the exec paths, monitor's effective-protected set watches it (R-7b liveness), and the backup
// package execs it for the passdb capture/restore. Anything that needs "which container is samba"
// reads this — never a second string literal.
const SambaContainerName = "felhom-samba"
// SambaPassdbVolume / SambaPassdbMount name the docker volume that holds the household SMB credential
// (the passdb tdb set) and where the container mounts it. R-7b's payload capture/restore targets this
// mount point, so it is pinned here beside the render that creates it rather than duplicated there.
const (
SambaPassdbVolume = "samba-passdb"
SambaPassdbMount = "/var/lib/samba"
)
// RenderSambaConfig renders smb.conf: the hardened global block (bind interfaces only = lo eth0,
// SMB2+ floor, NetBIOS on for flat-name resolution) plus one [section] per share. Deterministic:
// shares are emitted in the given order (the caller preserves registry order). force user/group pin
@@ -95,21 +110,21 @@ func RenderSambaCompose(d SambaData) string {
# receive the LAN multicast that WSD/mDNS discovery needs.
services:
felhom-samba:
image: %s
container_name: felhom-samba
%[1]s:
image: %[2]s
container_name: %[1]s
restart: unless-stopped
network_mode: host
environment:
- FELHOM_SERVER_NAME=%s
- FELHOM_SERVER_NAME=%[3]s
- FELHOM_IFACE=eth0
- FELHOM_UID=%d
- FELHOM_GID=%d
- FELHOM_UID=%[4]d
- FELHOM_GID=%[4]d
volumes:
- ./smb.conf:/etc/samba/smb.conf:ro
- samba-passdb:/var/lib/samba
%s
- %[5]s:%[6]s
%[7]s
volumes:
samba-passdb:
`, SambaImage, d.ServerName, d.UID, d.UID, binds.String())
%[5]s:
`, SambaContainerName, SambaImage, d.ServerName, d.UID, SambaPassdbVolume, SambaPassdbMount, binds.String())
}
+9
View File
@@ -66,6 +66,15 @@ func ValidateSMBShareName(name string) error {
if strings.ContainsAny(name, `/\.` ) {
return fmt.Errorf("a megosztás neve nem tartalmazhat perjelet vagy pontot")
}
// RESERVED NAMESPACE (R-7b). The backup engines key the shares source by the pseudo-stack „_shares"
// — a restic tag, a tier-2 dest root and a status record. nbNameRe below starts with [A-Za-z0-9_],
// so before this guard „_shares" was an ACCEPTED share name and the underscore namespace was not in
// fact reserved (the R-7b task's assumption to the contrary was verified false here). Reserving the
// whole leading-underscore space keeps future system keys collision-free too. Validation runs on
// ADD only, so an already-registered share is never invalidated retroactively.
if strings.HasPrefix(name, "_") {
return fmt.Errorf("a megosztás neve nem kezdődhet aláhúzással — ezek a nevek a rendszernek vannak fenntartva")
}
if !nbNameRe.MatchString(name) {
return fmt.Errorf("a megosztás neve csak betűt, számot, kötőjelet és aláhúzást tartalmazhat")
}
+3 -2
View File
@@ -22,8 +22,9 @@ import (
const (
// SambaStackName is the stack directory name under StacksDir (and the protected-stack key).
SambaStackName = "samba"
// sambaContainer is the fixed container name (set in the generated compose).
sambaContainer = "felhom-samba"
// sambaContainer is the fixed container name. Aliased from the RENDERER's constant so the compose
// this package writes and the execs it runs can never name different containers.
sambaContainer = infra.SambaContainerName
// sambaUID is the household uid/gid every SMB write is forced to, so apps (group 1000) and both
// backup tiers see consistent ownership.
sambaUID = 1000