7c05b59708
gates / gates (push) Successful in 24s
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by whoever caught them: too late to translate where they are shown, too early where they are made. Every one now carries its key across that gap. ZERO Hungarian error literals remain. util.MsgError does three things at once, each earned: - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged; - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause); - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole. A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours. 76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the household's language come from one value; UpdateRefusal gained a Cause to carry it. Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a per-call-site flag — the producer somebody forgot would read "3 app is not running". The guard caught a real key collision (alert.deadapp.one) the day the rule landed. TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could not see it, because every surviving fragment WAS a real base literal while the CALL had lost text; two behaviour tests caught it. And the counting script was case-sensitive, so it said "0 left" while five remained. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
334 lines
14 KiB
Go
334 lines
14 KiB
Go
package backup
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
|
|
)
|
|
|
|
// Shares restore — R-7b Part 4. A SIBLING of the per-app scratch/place flow in offbox_restore.go,
|
|
// mirroring its shape (restore to an on-data-drive scratch first, then a separate, deliberate
|
|
// place-to-live merge) without touching it.
|
|
//
|
|
// Three things come back, in this order of importance:
|
|
// 1. the FILES — placed missing-only into each share's live folder, never overwriting;
|
|
// 2. the DEFINITIONS — merged into the share registry so the „Megosztás" page is whole again;
|
|
// 3. the CREDENTIAL — best-effort, into the samba named volume, so the household need not re-set it.
|
|
//
|
|
// The load-bearing guard is the PREFIX ASSERT: a destination is only written when it resolves
|
|
// strictly inside a REGISTERED, LIVE storage root. A snapshot is untrusted input for this purpose —
|
|
// it was written by an older version of this box, possibly with a different drive layout — so a path
|
|
// that no longer sits under a live root is refused rather than created.
|
|
|
|
// SetSharesReconciler wires the post-restore samba re-render (main.go → stacks.ReconcileSamba).
|
|
func (m *Manager) SetSharesReconciler(fn func() error) { m.sharesReconcile = fn }
|
|
|
|
// SetSharesPassdbRestorer overrides the passdb restore exec (tests).
|
|
func (m *Manager) SetSharesPassdbRestorer(fn func(tar []byte) error) { m.sharesPassdbRestore = fn }
|
|
|
|
func (m *Manager) sharesPassdbRestorer() func([]byte) error {
|
|
if m.sharesPassdbRestore != nil {
|
|
return m.sharesPassdbRestore
|
|
}
|
|
return defaultSharesPassdbRestore
|
|
}
|
|
|
|
// defaultSharesPassdbRestore untars a captured passdb archive back into the samba named volume. It
|
|
// writes ONLY into infra.SambaPassdbMount inside the samba container — never onto the host — so a
|
|
// malformed archive cannot reach anything outside the volume it came from.
|
|
func defaultSharesPassdbRestore(tar []byte) error {
|
|
cmd := exec.Command("docker", "exec", "-i", infra.SambaContainerName,
|
|
"tar", "xf", "-", "-C", infra.SambaPassdbMount)
|
|
cmd.Stdin = bytes.NewReader(tar)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("passdb restore: %s: %w", truncate(out), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sharesRestoreScratchDir returns the on-DATA-DRIVE scratch for the shares restore. Never the
|
|
// controller data dir (the F-A1 rootfs-filler lesson) and never network storage when a local drive
|
|
// exists (the F-6C-1 ownership-fidelity lesson).
|
|
func (m *Manager) sharesRestoreScratchDir() (scratch, nsRoot string, err error) {
|
|
if m.settings == nil {
|
|
return "", "", util.MsgError("err.backup.nincs_elerheto_adatmeghajto_a_visszaallitashoz")
|
|
}
|
|
pick := func(networkOK bool) (string, string, bool) {
|
|
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
|
if strings.TrimSpace(sp.Path) == "" || (!networkOK && sp.IsNetwork()) {
|
|
continue
|
|
}
|
|
nr := m.namespaceRoot(sp.Path)
|
|
return filepath.Join(nr, "backups", "offsite-restore", SharesPseudoStack), nr, true
|
|
}
|
|
return "", "", false
|
|
}
|
|
if s, nr, ok := pick(false); ok {
|
|
return s, nr, nil
|
|
}
|
|
if s, nr, ok := pick(true); ok {
|
|
m.logger.Printf("[WARN] [shares] restore scratch on network storage — ownership fidelity not guaranteed under squash")
|
|
return s, nr, nil
|
|
}
|
|
return "", "", util.MsgError("err.backup.nincs_elerheto_adatmeghajto_a_visszaallitashoz")
|
|
}
|
|
|
|
// RestoreSharesScratch restores the latest `_shares` snapshot into the scratch dir. Non-destructive:
|
|
// it never touches a live share folder, the registry, or the credential — PlaceSharesRestore is the
|
|
// deliberate second action that does.
|
|
func (m *Manager) RestoreSharesScratch(ctx context.Context) error {
|
|
// R-411 — FOUND BY THE R-408 WALK, not by the report that prompted it. This is the SAME shape as
|
|
// the off-site scratch restore: it runs `unlockStale` (a delete verb against `locks/`) and then
|
|
// `resticStep`, whose `unlock --remove-all` escalation is licensed only by every caller holding
|
|
// this flag. Its sibling `PlaceSharesRestore` below has always taken it; this one never did.
|
|
//
|
|
// The handler's `restoreOpBlocked()` + `BeginRestoreOp` set the DISPLAY flag (`opRunning`), not
|
|
// this one, so nothing nests and `acquireRunning` is safe to take here.
|
|
if err := m.acquireRunning(); err != nil {
|
|
return err
|
|
}
|
|
defer m.releaseRunning()
|
|
if !m.OffboxConfigured() {
|
|
return util.MsgError("err.backup.a_tavoli_mentes_nincs_beallitva")
|
|
}
|
|
scratch, nsRoot, err := m.sharesRestoreScratchDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if free := m.offboxFree()(nsRoot); free > 0 && free < offboxUnitOnlyFreeFloor {
|
|
return util.MsgError("err.backup.nincs_eleg_szabad_hely_a_visszaallitashoz",
|
|
humanizeBytes(offboxUnitOnlyFreeFloor), humanizeBytes(free))
|
|
}
|
|
id, _, err := m.offboxLatestSnapshot(ctx, SharesPseudoStack)
|
|
if err != nil {
|
|
return util.MsgError("err.backup.nincs_visszaallithato_megosztas_mentes", err)
|
|
}
|
|
if err := os.MkdirAll(scratch, 0o755); err != nil {
|
|
return fmt.Errorf("restore dir: %w", err)
|
|
}
|
|
t := m.settings.GetOffboxTarget()
|
|
base, env := m.offboxBaseArgs(t)
|
|
rctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout)
|
|
defer cancel()
|
|
m.unlockStale(rctx, base, env)
|
|
out, rerr := m.resticStep(rctx, env, base, "restore:"+SharesPseudoStack, "restore", id, "--target", scratch)
|
|
if rerr != nil {
|
|
return util.MsgError("err.backup.a_megosztasok_visszaallitasa_sikertelen", rerr, truncate(out))
|
|
}
|
|
m.logger.Printf("[INFO] [shares] restored snapshot %s → %s", id, scratch)
|
|
return nil
|
|
}
|
|
|
|
// SharesScratchReady reports whether a completed shares scratch exists (gates the place action).
|
|
func (m *Manager) SharesScratchReady() bool {
|
|
scratch, _, err := m.sharesRestoreScratchDir()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
entries, rErr := os.ReadDir(scratch)
|
|
return rErr == nil && len(entries) > 0
|
|
}
|
|
|
|
// SharesRestoreResult is what the flash message reports back to the customer.
|
|
type SharesRestoreResult struct {
|
|
FilesRestored int // files merged into live share folders
|
|
SharesPlaced []string // share folders whose files were merged
|
|
DefinitionsAdded []string // share definitions re-added to the registry
|
|
DefinitionsKept []string // definitions skipped because a live share already owns the name
|
|
Refused []string // definitions refused: destination is not under a live storage root
|
|
PasswordRestored bool // the household SMB credential was put back
|
|
}
|
|
|
|
// liveShareRootOK prefix-asserts a destination against the REGISTERED, LIVE storage roots. It
|
|
// requires a STRICT descendant: equal-to-the-root is refused too, because placing a share's contents
|
|
// at a drive root would scatter restored files across the whole drive. A `..` segment is refused
|
|
// outright rather than relying on Clean, so a traversal attempt is visible in the logs.
|
|
func (m *Manager) liveShareRootOK(dst string) bool {
|
|
if m.settings == nil || strings.TrimSpace(dst) == "" {
|
|
return false
|
|
}
|
|
clean := filepath.Clean(dst)
|
|
for _, seg := range strings.Split(filepath.ToSlash(dst), "/") {
|
|
if seg == ".." {
|
|
return false
|
|
}
|
|
}
|
|
p := filepath.ToSlash(clean)
|
|
for _, sp := range m.settings.GetStoragePaths() {
|
|
if sp.Decommissioned || sp.Disconnected {
|
|
continue
|
|
}
|
|
root := filepath.ToSlash(filepath.Clean(sp.Path))
|
|
if root == "" || root == "/" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(p, root+"/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// scratchJoin reconstructs an absolute captured path UNDER a restore scratch — restic restores with
|
|
// the absolute source structure preserved, so /mnt/hdd_1/dokumentumok lands at
|
|
// <scratch>/mnt/hdd_1/dokumentumok. The volume name and leading separator are stripped explicitly
|
|
// rather than relying on filepath.Join, which on a non-POSIX host would splice a drive letter into
|
|
// the middle of the path and produce an unopenable name.
|
|
func scratchJoin(scratch, abs string) string {
|
|
rel := abs
|
|
if vol := filepath.VolumeName(rel); vol != "" {
|
|
rel = rel[len(vol):]
|
|
}
|
|
rel = strings.TrimLeft(filepath.ToSlash(rel), "/")
|
|
return filepath.Join(scratch, filepath.FromSlash(rel))
|
|
}
|
|
|
|
// readSharesManifestFrom reads the manifest out of a restored scratch tree.
|
|
func (m *Manager) readSharesManifestFrom(scratch string) (SharesManifest, error) {
|
|
var mf SharesManifest
|
|
p := filepath.Join(scratchJoin(scratch, m.sharesPayloadDir()), sharesManifestName)
|
|
blob, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return mf, util.MsgError("err.backup.a_mentesben_nincs_megosztas_leiro", err)
|
|
}
|
|
if err := json.Unmarshal(blob, &mf); err != nil {
|
|
return mf, util.MsgError("err.backup.a_megosztas_leiro_olvashatatlan", err)
|
|
}
|
|
return mf, nil
|
|
}
|
|
|
|
// PlaceSharesRestore places a completed shares scratch into live locations: files first (missing-only
|
|
// merge, never overwriting), then the definitions (existing live definitions WIN on a name conflict —
|
|
// a restore must not silently flip a live share's read-only or cloud setting), then the samba
|
|
// re-render, then the credential. Single-flight.
|
|
func (m *Manager) PlaceSharesRestore(ctx context.Context) (SharesRestoreResult, error) {
|
|
var res SharesRestoreResult
|
|
if err := m.acquireRunning(); err != nil {
|
|
return res, util.MsgError("err.backup.egy_masik_mentesi_visszaallitasi_muvelet_mar")
|
|
}
|
|
defer m.releaseRunning()
|
|
|
|
scratch, _, err := m.sharesRestoreScratchDir()
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
if _, sErr := os.Stat(scratch); sErr != nil {
|
|
return res, util.MsgError("err.backup.nincs_elokeszitett_visszaallitas_futtass_elobb_egy")
|
|
}
|
|
mf, err := m.readSharesManifestFrom(scratch)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
|
|
// Live registry, indexed case-insensitively (AddSMBShare's own collision rule).
|
|
live := map[string]bool{}
|
|
if m.settings != nil {
|
|
for _, sh := range m.settings.GetSMBShares() {
|
|
live[strings.ToLower(sh.Name)] = true
|
|
}
|
|
}
|
|
copier := m.placeCopier()
|
|
for _, sh := range mf.Shares {
|
|
// THE PREFIX ASSERT. The snapshot's path is untrusted layout input; a destination that is not
|
|
// strictly inside a live registered root is refused, never created.
|
|
if !m.liveShareRootOK(sh.Path) {
|
|
m.logger.Printf("[WARN] [shares] restore refused for %s — destination is not under a live storage root", sh.Name)
|
|
res.Refused = append(res.Refused, sh.Name)
|
|
continue
|
|
}
|
|
src := scratchJoin(scratch, sh.Path)
|
|
if _, sErr := os.Stat(src); sErr == nil {
|
|
n, cErr := copier(src, sh.Path)
|
|
if cErr != nil {
|
|
return res, util.MsgError("err.backup.a_z_megosztas_fajljainak_visszaallitasa_sikertelen", sh.Name, cErr)
|
|
}
|
|
res.FilesRestored += n
|
|
res.SharesPlaced = append(res.SharesPlaced, sh.Name)
|
|
} else {
|
|
// A definitions-only snapshot (the quota-degraded shape) legitimately has no file tree.
|
|
m.logger.Printf("[DEBUG] [shares] no restored file tree for %s — definitions-only snapshot", sh.Name)
|
|
}
|
|
// Definitions: existing live share WINS. Restoring must never silently change a share the
|
|
// household is using right now.
|
|
if live[strings.ToLower(sh.Name)] {
|
|
res.DefinitionsKept = append(res.DefinitionsKept, sh.Name)
|
|
continue
|
|
}
|
|
if m.settings != nil {
|
|
if aErr := m.settings.AddSMBShare(sh); aErr != nil {
|
|
m.logger.Printf("[WARN] [shares] could not re-add definition %s: %v", sh.Name, aErr)
|
|
continue
|
|
}
|
|
res.DefinitionsAdded = append(res.DefinitionsAdded, sh.Name)
|
|
}
|
|
}
|
|
|
|
// Re-render smb.conf so the restored definitions are actually exported.
|
|
if len(res.DefinitionsAdded) > 0 {
|
|
if m.sharesReconcile == nil {
|
|
m.logger.Printf("[WARN] [shares] no samba reconciler wired — smb.conf will catch up on the next health tick")
|
|
} else if rErr := m.sharesReconcile(); rErr != nil {
|
|
m.logger.Printf("[WARN] [shares] samba re-render after restore failed: %v", rErr)
|
|
}
|
|
}
|
|
|
|
// Credential, best-effort and last: the files and definitions are the load-bearing parts, and
|
|
// re-setting an SMB password is a cheap, well-signposted UX step.
|
|
passdb := filepath.Join(scratchJoin(scratch, m.sharesPayloadDir()), sharesPassdbName)
|
|
if blob, rErr := os.ReadFile(passdb); rErr == nil && len(blob) > 0 {
|
|
if pErr := m.sharesPassdbRestorer()(blob); pErr != nil {
|
|
m.logger.Printf("[WARN] [shares] credential restore failed — the SMB password must be re-set: %v", pErr)
|
|
} else {
|
|
res.PasswordRestored = true
|
|
if m.settings != nil {
|
|
if sErr := m.settings.SetSMBUserSet(true); sErr != nil {
|
|
m.logger.Printf("[WARN] [shares] persist user-set flag after credential restore failed: %v", sErr)
|
|
}
|
|
}
|
|
m.logger.Printf("[INFO] [shares] household credential restored into the sharing service")
|
|
}
|
|
}
|
|
|
|
if rmErr := os.RemoveAll(scratch); rmErr != nil {
|
|
m.logger.Printf("[WARN] [shares] scratch cleanup failed (harmless): %v", rmErr)
|
|
}
|
|
m.logger.Printf("[INFO] [shares] restore placed: %d file(s), %d definition(s) re-added, %d kept, %d refused, credential=%v",
|
|
res.FilesRestored, len(res.DefinitionsAdded), len(res.DefinitionsKept), len(res.Refused), res.PasswordRestored)
|
|
return res, nil
|
|
}
|
|
|
|
// FlashMessage renders the Hungarian summary the „Megosztások visszaállítása" action flashes back.
|
|
func (r SharesRestoreResult) FlashMessage() string {
|
|
var parts []string
|
|
parts = append(parts, fmt.Sprintf("%s visszaállítva: %d fájl, %d megosztás-beállítás.",
|
|
SharesDisplayName, r.FilesRestored, len(r.DefinitionsAdded)))
|
|
for _, n := range r.DefinitionsKept {
|
|
parts = append(parts, fmt.Sprintf("A(z) %s megosztás beállítása már létezik — a meglévő maradt.", n))
|
|
}
|
|
if len(r.Refused) > 0 {
|
|
parts = append(parts, fmt.Sprintf("Nem állítható vissza (a mappa nincs élő adatmeghajtón): %s.",
|
|
strings.Join(r.Refused, ", ")))
|
|
}
|
|
if !r.PasswordRestored {
|
|
parts = append(parts, "A megosztás jelszavát újra meg kell adni.")
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
// SharesRegistryCount is a small helper for the page (how many shares the registry holds).
|
|
func (m *Manager) SharesRegistryCount() int {
|
|
if m.settings == nil {
|
|
return 0
|
|
}
|
|
return len(m.settings.GetSMBShares())
|
|
}
|