Files
felhom-controller/controller/internal/backup/shares_payload.go
T
admin c81df55dcb 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)
2026-07-18 12:45:57 +02:00

266 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) }