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
@@ -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) }