b5d78d1e0f
The systemic complaint, twice in one evening: you press a button and nothing happens. No progress, no ETA, no named result. Three worst offenders, fixed on the two patterns already here (deploy 3-step panel, storage-init status poll). No new framework — that is a ROADMAP item; three targeted cards ship tonight. 4a — a verification restore names its result. The flash said the app had been restored "to a verification folder on the drive"; which folder, on which drive, was invisible, so the customer could not go and look at what they had just asked for. Full path now. The restore page gained a listing of existing verification copies (app, size, date, path) — nothing anywhere showed these, so they piled up and the only way to find them was SSH — each with a double-confirmed delete. That delete is the only one this release adds, so it names a STACK, never a path: the Manager resolves the name inside a backups/offsite-restore root it computed itself and refuses anything landing outside. Red-proofed — neutralise the name guard and stack:"" resolves to the offsite-restore ROOT and takes every copy with it. Refusals are asserted as non-effects. 4b — Megosztás enable shows what it is waiting for. Enabling ran ReconcileSamba synchronously inside the POST handler; on a golden without felhom-samba baked that is compose pulling ~100MB, i.e. minutes of an apparently-hung form post followed by "Beállítás mentve." whether or not anything came up. Detached + polled now, distinguishing "képfájl letöltése" from "indítás" — decided BEFORE the work starts, since afterwards the image is always present. Success is probed, not inferred (compose up -d exits 0 on a crash-loop). The password form starts the same job: with UserSet false reconcile deploys nothing, so on a fresh box that is where the pull actually happens. 4c — "Távoli mentés most" streams real progress. restic was already reporting bytes and percent; the runner seam used CombinedOutput() and discarded them. The manual run now passes --json and scans stdout line-by-line: total bytes, percent, current app. Manual only — the nightly stays silent, pinned by a test that fails if it ever passes --json. The poll now arms unconditionally, closing a race the manual trigger always ran: the redirect rendered before the goroutine wrote LastStatus=running, so the poll never armed and the page sat static during the very run just started. Red-proofed twice. Also closes the golden/controller infra-image drift at the source: infra.Images() derives from the existing pins and --print-infra-images exposes it, so the golden bake can stop carrying its own copy. That copy had already drifted — felhom-samba was never added, so the golden baked 3 of 4, which is why enabling Megosztás pulled at runtime in the first place. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
423 lines
18 KiB
Go
423 lines
18 KiB
Go
package backup
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Offsite restore rework (Task 3a §7). With mandatory userdata now in snapshots, restore needs three
|
||
// changes over the old dump-to-rootfs-scratch:
|
||
// 1. scratch relocated off the ~8 GB guest rootfs onto a data drive, behind a headroom gate (F-A1);
|
||
// 2. a unit-only DEFAULT restore (`--include <absolute-unit-path>`, SP-3.2) — full is a deliberate,
|
||
// size-gated second action;
|
||
// 3. place-to-live = a missing-only merge (never --delete) so the SQ3 immich case is restorable
|
||
// from offsite alone.
|
||
// ID-first everywhere (§3): `restic stats --tag` is UNPROVEN on 0.14.0, so the size lookup resolves the
|
||
// snapshot ID via `snapshots latest --tag` and calls `stats <ID>`.
|
||
|
||
const (
|
||
// offboxUnitOnlyFreeFloor — a unit-only restore needs at least this much free on the scratch drive.
|
||
// Catalog recovery units are MB–1 GB (SQ4); 2 GiB is a safe floor without a per-snapshot size probe.
|
||
offboxUnitOnlyFreeFloor = int64(2) << 30
|
||
)
|
||
|
||
// SetOffboxFreeFn overrides the restore free-space probe (tests; the Windows go-test host has no df).
|
||
func (m *Manager) SetOffboxFreeFn(fn func(path string) int64) { m.offboxFreeFn = fn }
|
||
|
||
// offboxFree returns the free-space probe (nil seam → the real diskFreeBytes).
|
||
func (m *Manager) offboxFree() func(string) int64 {
|
||
if m.offboxFreeFn != nil {
|
||
return m.offboxFreeFn
|
||
}
|
||
return diskFreeBytes
|
||
}
|
||
|
||
// diskFreeBytes returns available bytes on the filesystem holding path (0 on any error). Mirrors
|
||
// appexport.DiskFree; kept local so the backup package needs no cross-package dependency.
|
||
func diskFreeBytes(path string) int64 {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
out, err := exec.CommandContext(ctx, "df", "--output=avail", "-B1", path).Output()
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||
if len(lines) < 2 {
|
||
return 0
|
||
}
|
||
var size int64
|
||
fmt.Sscanf(strings.TrimSpace(lines[1]), "%d", &size)
|
||
return size
|
||
}
|
||
|
||
// offboxUnitPathOf returns the snapshot path that is the recovery unit for stack (suffix
|
||
// backups/primary/<stack>), or "" if none is present.
|
||
func offboxUnitPathOf(paths []string, stack string) string {
|
||
suffix := "/backups/primary/" + stack
|
||
for _, p := range paths {
|
||
if strings.HasSuffix(p, suffix) {
|
||
return p
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// offboxLatestSnapshot resolves the newest snapshot for stack: its short ID + captured paths, via
|
||
// `snapshots latest --tag <stack> --json`. When the tag spans more than one group (old unit-only shape
|
||
// + new enlarged shape), it returns the newest by time.
|
||
func (m *Manager) offboxLatestSnapshot(ctx context.Context, stack string) (id string, paths []string, err error) {
|
||
t := m.settings.GetOffboxTarget()
|
||
base, env := m.offboxBaseArgs(t)
|
||
sctx, cancel := context.WithTimeout(ctx, offboxProbeTimeout)
|
||
defer cancel()
|
||
out, serr := m.runner()(sctx, env, append(append([]string{}, base...), "snapshots", "latest", "--tag", stack, "--json")...)
|
||
if serr != nil {
|
||
return "", nil, fmt.Errorf("offbox snapshots %s: %w: %s", stack, serr, truncate(out))
|
||
}
|
||
var snaps []struct {
|
||
ShortID string `json:"short_id"`
|
||
ID string `json:"id"`
|
||
Time time.Time `json:"time"`
|
||
Paths []string `json:"paths"`
|
||
}
|
||
if json.Unmarshal(out, &snaps) != nil || len(snaps) == 0 {
|
||
return "", nil, fmt.Errorf("offbox: nincs pillanatkép a(z) %s alkalmazáshoz", stack)
|
||
}
|
||
best := 0
|
||
for i := 1; i < len(snaps); i++ {
|
||
if snaps[i].Time.After(snaps[best].Time) {
|
||
best = i
|
||
}
|
||
}
|
||
id = snaps[best].ShortID
|
||
if id == "" {
|
||
id = snaps[best].ID
|
||
}
|
||
return id, snaps[best].Paths, nil
|
||
}
|
||
|
||
// offboxSnapshotSize returns the restore-size (logical bytes) of ONE snapshot via `stats <ID> --json`
|
||
// (default mode — for a single snapshot ID this is exactly that snapshot's on-disk-when-restored size,
|
||
// the correct headroom meaning; SP-1). ID-first: never `stats --tag` (unproven on 0.14.0).
|
||
func (m *Manager) offboxSnapshotSize(ctx context.Context, id string) (int64, error) {
|
||
t := m.settings.GetOffboxTarget()
|
||
base, env := m.offboxBaseArgs(t)
|
||
sctx, cancel := context.WithTimeout(ctx, offboxProbeTimeout)
|
||
defer cancel()
|
||
out, err := m.runner()(sctx, env, append(append([]string{}, base...), "stats", id, "--json")...)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("offbox stats %s: %w: %s", id, err, truncate(out))
|
||
}
|
||
var st struct {
|
||
TotalSize int64 `json:"total_size"`
|
||
}
|
||
if json.Unmarshal(out, &st) != nil || st.TotalSize <= 0 {
|
||
return 0, fmt.Errorf("offbox: a(z) %s pillanatkép mérete ismeretlen", id)
|
||
}
|
||
return st.TotalSize, nil
|
||
}
|
||
|
||
// offboxRestoreScratchDir returns the on-DATA-DRIVE scratch dir for an app's offsite restore
|
||
// (<nsRoot>/backups/offsite-restore/<app>) plus the namespace root (an existing dir, for the free-space
|
||
// probe). NEVER cfg.Paths.DataDir (the rootfs — the F-A1 filler). App's HDD drive first; else the first
|
||
// schedulable storage path; else a Hungarian refusal.
|
||
func (m *Manager) offboxRestoreScratchDir(stack string) (scratch, nsRoot string, err error) {
|
||
// offsiteRestoreRootFor is THE place `backups/offsite-restore` is spelled (offbox_verify_copies.go)
|
||
// — the listing/delete surface must resolve byte-identical paths to the ones written here.
|
||
scratchFor := func(root string) (string, string) {
|
||
return filepath.Join(m.offsiteRestoreRootFor(root), stack), m.namespaceRoot(root)
|
||
}
|
||
isNet := func(path string) bool { return m.settings != nil && m.settings.IsNetworkStoragePath(path) }
|
||
// (1) the app's own drive — preferred, but ONLY if it is not NETWORK storage (F-3afix-1). restic
|
||
// restores uid/gid/setgid fully onto a LOCAL fs (SP-3.3); a squashed network scratch would feed
|
||
// PlaceOffsiteRestore wrong-owner files — the F-6C-1 silently-broken-restore class, offsite-side.
|
||
if m.stackProvider != nil {
|
||
if hdd := strings.TrimSpace(m.stackProvider.GetStackHDDPath(stack)); hdd != "" && !isNet(hdd) {
|
||
s, nr := scratchFor(hdd)
|
||
return s, nr, nil
|
||
}
|
||
}
|
||
// (2) the first NON-network schedulable path.
|
||
if m.settings != nil {
|
||
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
||
if strings.TrimSpace(sp.Path) != "" && !sp.IsNetwork() {
|
||
s, nr := scratchFor(sp.Path)
|
||
return s, nr, nil
|
||
}
|
||
}
|
||
// (3) last resort ONLY: any schedulable path, with a loud WARN — a network scratch cannot
|
||
// guarantee ownership fidelity under root_squash.
|
||
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
||
if strings.TrimSpace(sp.Path) != "" {
|
||
m.logger.Printf("[WARN] [offbox] %s: restore scratch on network storage %s — ownership fidelity not guaranteed under squash", stack, sp.Path)
|
||
s, nr := scratchFor(sp.Path)
|
||
return s, nr, nil
|
||
}
|
||
}
|
||
}
|
||
return "", "", fmt.Errorf("nincs elérhető adatmeghajtó a visszaállításhoz")
|
||
}
|
||
|
||
// RestoreOffboxScratch restores an app's latest offsite snapshot to an on-data-drive scratch dir
|
||
// (non-destructive — never overwrites live data). full=false (the default) restores the recovery UNIT
|
||
// only (`--include <absolute-unit-path>`, SP-3.2); full=true restores the whole snapshot (unit +
|
||
// mandatory userdata) behind a size×1.1 headroom gate. Fail-closed: an unknown snapshot size refuses a
|
||
// full restore.
|
||
func (m *Manager) RestoreOffboxScratch(ctx context.Context, stack string, full bool) error {
|
||
if !m.OffboxConfigured() {
|
||
return fmt.Errorf("off-box backup not configured")
|
||
}
|
||
if !isSafeStackName(stack) {
|
||
return fmt.Errorf("invalid stack name")
|
||
}
|
||
id, paths, err := m.offboxLatestSnapshot(ctx, stack)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
unitPath := offboxUnitPathOf(paths, stack)
|
||
if unitPath == "" {
|
||
return fmt.Errorf("a(z) %s pillanatképében nincs mentési egység — a visszaállítás nem indítható", stack)
|
||
}
|
||
scratch, nsRoot, err := m.offboxRestoreScratchDir(stack)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// Headroom gate (F-A1) — probed on the namespace root (an existing dir).
|
||
free := m.offboxFree()(nsRoot)
|
||
if full {
|
||
size, serr := m.offboxSnapshotSize(ctx, id)
|
||
if serr != nil {
|
||
// SizeUnknown never renders as fits — fail closed.
|
||
return fmt.Errorf("A mentés mérete nem állapítható meg — a teljes visszaállítás biztonsági okból nem indítható.")
|
||
}
|
||
need := size + size/10 // ×1.1
|
||
if free < need {
|
||
return fmt.Errorf("Nincs elég szabad hely a visszaállításhoz (%s szükséges, %s szabad).", humanizeBytes(need), humanizeBytes(free))
|
||
}
|
||
} else if free < offboxUnitOnlyFreeFloor {
|
||
return fmt.Errorf("Nincs elég szabad hely a visszaállításhoz (%s szükséges, %s szabad).", humanizeBytes(offboxUnitOnlyFreeFloor), humanizeBytes(free))
|
||
}
|
||
// F-A1 hygiene: drop the legacy rootfs scratch (DataDir/offbox-restore/<app>) best-effort.
|
||
legacy := filepath.Join(m.cfg.Paths.DataDir, "offbox-restore", stack)
|
||
if _, sErr := os.Stat(legacy); sErr == nil {
|
||
if rmErr := os.RemoveAll(legacy); rmErr != nil {
|
||
m.logger.Printf("[WARN] [offbox] could not remove legacy rootfs restore scratch %s: %v", legacy, rmErr)
|
||
} else {
|
||
m.logger.Printf("[INFO] [offbox] removed legacy rootfs restore scratch %s", legacy)
|
||
}
|
||
}
|
||
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) // pre-restore hygiene
|
||
args := []string{"restore", id, "--target", scratch}
|
||
if !full {
|
||
args = append(args, "--include", unitPath) // SP-3.2: absolute snapshot unit path = unit-only
|
||
}
|
||
out, rerr := m.resticStep(rctx, env, base, "restore:"+stack, args...)
|
||
if rerr != nil {
|
||
return fmt.Errorf("offbox restore %s: %w: %s", stack, rerr, truncate(out))
|
||
}
|
||
m.logger.Printf("[INFO] [offbox] restored %s (%s, full=%v) → %s", stack, id, full, scratch)
|
||
return nil
|
||
}
|
||
|
||
// OffboxRestorePrepareFull resolves the latest snapshot's restore-size and verifies scratch headroom
|
||
// for a FULL restore WITHOUT starting it (the two-step size-first gate). Returns the human size on
|
||
// success, or a Hungarian error to flash on refusal (size unknown / no headroom — fail-closed).
|
||
func (m *Manager) OffboxRestorePrepareFull(ctx context.Context, stack string) (string, error) {
|
||
if !m.OffboxConfigured() {
|
||
return "", fmt.Errorf("off-box backup not configured")
|
||
}
|
||
if !isSafeStackName(stack) {
|
||
return "", fmt.Errorf("invalid stack name")
|
||
}
|
||
id, _, err := m.offboxLatestSnapshot(ctx, stack)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
size, serr := m.offboxSnapshotSize(ctx, id)
|
||
if serr != nil {
|
||
return "", fmt.Errorf("A mentés mérete nem állapítható meg — a teljes visszaállítás biztonsági okból nem indítható.")
|
||
}
|
||
_, nsRoot, derr := m.offboxRestoreScratchDir(stack)
|
||
if derr != nil {
|
||
return "", derr
|
||
}
|
||
need := size + size/10
|
||
if free := m.offboxFree()(nsRoot); free < need {
|
||
return "", fmt.Errorf("Nincs elég szabad hely a visszaállításhoz (%s szükséges, %s szabad).", humanizeBytes(need), humanizeBytes(free))
|
||
}
|
||
return humanizeBytes(size), nil
|
||
}
|
||
|
||
// OffboxFullScratchReady reports whether a (non-empty) full-restore scratch exists for stack — the gate
|
||
// for showing the place-to-live action. PlaceOffsiteRestore re-validates per-path completeness.
|
||
func (m *Manager) OffboxFullScratchReady(stack string) bool {
|
||
if !isSafeStackName(stack) {
|
||
return false
|
||
}
|
||
scratch, _, err := m.offboxRestoreScratchDir(stack)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
if fi, sErr := os.Stat(scratch); sErr != nil || !fi.IsDir() {
|
||
return false
|
||
}
|
||
entries, _ := os.ReadDir(scratch)
|
||
return len(entries) > 0
|
||
}
|
||
|
||
// placement is one source→dest pair for place-to-live: src is the reconstructed absolute path under the
|
||
// scratch (SP-3.1), dst is the live location under the app's current namespace root.
|
||
type placement struct {
|
||
src string
|
||
dst string
|
||
isUnit bool
|
||
}
|
||
|
||
// mapOffsiteRestorePaths maps a completed full-scratch restore to live placements (pure). The anchor
|
||
// oldNs is derived by trimming backups/primary/<stack> off the unit path (the snapshot may come from a
|
||
// DIFFERENT drive after churn — liveNsRoot is where it goes). Refuses the WHOLE placement (no partial
|
||
// writes) on: no unit path; a path outside oldNs (escape); a `..` segment; a non-unit path in the
|
||
// reserved backups/ zone.
|
||
func mapOffsiteRestorePaths(snapPaths []string, stack, scratch, liveNsRoot string) ([]placement, error) {
|
||
unitSuffix := "/backups/primary/" + stack
|
||
oldNs := ""
|
||
for _, p := range snapPaths {
|
||
if strings.HasSuffix(p, unitSuffix) {
|
||
oldNs = strings.TrimSuffix(p, unitSuffix)
|
||
break
|
||
}
|
||
}
|
||
if oldNs == "" {
|
||
return nil, fmt.Errorf("a pillanatképben nincs mentési egység (backups/primary/%s)", stack)
|
||
}
|
||
out := make([]placement, 0, len(snapPaths))
|
||
for _, p := range snapPaths {
|
||
// Every captured path must be a STRICT descendant of oldNs. Requiring the trailing "/" also
|
||
// catches p == oldNs (the namespace root itself — F-3a-3), which would otherwise map to a junk
|
||
// placement nesting the whole old namespace under the live root.
|
||
if !strings.HasPrefix(p, oldNs+"/") {
|
||
return nil, fmt.Errorf("a pillanatkép egy útvonala a névtéren kívülre mutat: %s", p)
|
||
}
|
||
rel := strings.TrimPrefix(p, oldNs+"/")
|
||
for _, seg := range strings.Split(rel, "/") {
|
||
if seg == ".." {
|
||
return nil, fmt.Errorf("a pillanatkép egy útvonala érvénytelen (..): %s", p)
|
||
}
|
||
}
|
||
isUnit := rel == "backups/primary/"+stack
|
||
if !isUnit && (rel == "backups" || strings.HasPrefix(rel, "backups/")) {
|
||
return nil, fmt.Errorf("nem-egység útvonal a fenntartott backups zónában: %s", p)
|
||
}
|
||
out = append(out, placement{
|
||
src: filepath.Join(scratch, p), // SP-3.1: abs source reconstructed under the target
|
||
dst: filepath.Join(liveNsRoot, rel),
|
||
isUnit: isUnit,
|
||
})
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// placeCopier returns the place-to-live missing-only merge (nil seam → rsyncRestoreMissing, the
|
||
// `-a --ignore-existing` additive copy). NEVER rsyncMirror (--delete).
|
||
func (m *Manager) placeCopier() func(src, dst string) (int, error) {
|
||
if m.offboxPlaceCopier != nil {
|
||
return m.offboxPlaceCopier
|
||
}
|
||
return rsyncRestoreMissing
|
||
}
|
||
|
||
// PlaceOffsiteRestore places a COMPLETED full-scratch restore into the app's live locations via a
|
||
// missing-only merge (§7.3), so the SQ3 immich case is restorable from offsite alone. The recovery
|
||
// unit is placed ONLY if the live unit is ABSENT (never overwrites a local unit); every other path is
|
||
// merged missing-only. Does NOT deploy/start anything — RecreateStackFromUnit / the restore flow owns
|
||
// that. Single-flight. Requires a completed full scratch (deterministic path + existence check).
|
||
func (m *Manager) PlaceOffsiteRestore(ctx context.Context, stack string) error {
|
||
if !m.OffboxConfigured() {
|
||
return fmt.Errorf("off-box backup not configured")
|
||
}
|
||
if !isSafeStackName(stack) {
|
||
return fmt.Errorf("invalid stack name")
|
||
}
|
||
if err := m.acquireRunning(); err != nil {
|
||
return fmt.Errorf("egy másik mentési/visszaállítási művelet már fut")
|
||
}
|
||
defer m.releaseRunning()
|
||
|
||
scratch, _, err := m.offboxRestoreScratchDir(stack)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if _, sErr := os.Stat(scratch); sErr != nil {
|
||
return fmt.Errorf("nincs előkészített teljes visszaállítás — futtass előbb egy teljes visszaállítást")
|
||
}
|
||
id, paths, err := m.offboxLatestSnapshot(ctx, stack)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_ = id
|
||
// F-3a-1a: the live target uses the RAW HDD path (mirrors offboxCaptureSet). NOT AppNamespaceRoot —
|
||
// its systemDataPath fallback would merge userdata onto the SSD system namespace. Empty HDD ⇒
|
||
// undeployed ⇒ refuse: the app must be restored first, then its data placed under its live drive.
|
||
hdd := ""
|
||
if m.stackProvider != nil {
|
||
hdd = strings.TrimSpace(m.stackProvider.GetStackHDDPath(stack))
|
||
}
|
||
if hdd == "" {
|
||
return fmt.Errorf("a(z) %s nincs telepítve — előbb állítsd helyre az alkalmazást, utána az adatokat", stack)
|
||
}
|
||
liveNs := m.namespaceRoot(hdd)
|
||
// F-3a-1b: headroom gate — a missing-only merge copies at most the scratch size; refuse before any
|
||
// copy if the live drive lacks that (conservative — scratch and live often share a drive).
|
||
if free, need := m.offboxFree()(liveNs), m.offboxSize()(scratch); free < need {
|
||
return fmt.Errorf("Nincs elég szabad hely a visszaállításhoz (%s szükséges, %s szabad).", humanizeBytes(need), humanizeBytes(free))
|
||
}
|
||
placements, err := mapOffsiteRestorePaths(paths, stack, scratch, liveNs)
|
||
if err != nil {
|
||
return err // whole-placement refusal (no partial writes)
|
||
}
|
||
// F-3a-4: stat pre-pass over EVERY placement BEFORE the first copy — an incomplete scratch (e.g. a
|
||
// unit-only restore, userdata srcs absent) refuses with ZERO copies, making "no partial writes" true.
|
||
for _, pl := range placements {
|
||
if _, sErr := os.Stat(pl.src); sErr != nil {
|
||
return fmt.Errorf("a teljes visszaállítás hiányos (%s nincs meg) — futtass előbb egy teljes visszaállítást", filepath.Base(pl.src))
|
||
}
|
||
}
|
||
copier := m.placeCopier()
|
||
var placed int
|
||
for _, pl := range placements {
|
||
if pl.isUnit {
|
||
if _, liveErr := os.Stat(pl.dst); liveErr == nil {
|
||
m.logger.Printf("[INFO] [offbox] place %s: live recovery unit present — not overwriting", stack)
|
||
continue // never overwrite a local unit
|
||
}
|
||
}
|
||
n, cErr := copier(pl.src, pl.dst)
|
||
if cErr != nil {
|
||
return fmt.Errorf("a(z) %s helyreállítása sikertelen: %w", stack, cErr) // scratch KEPT for retry
|
||
}
|
||
placed += n
|
||
}
|
||
// F-3a-2: on FULL success, remove the scratch best-effort (OffboxFullScratchReady then turns false →
|
||
// the place button disappears). A failed placement returned above, keeping the scratch for a retry.
|
||
if rmErr := os.RemoveAll(scratch); rmErr != nil {
|
||
m.logger.Printf("[WARN] [offbox] place %s: scratch cleanup failed (harmless): %v", stack, rmErr)
|
||
} else {
|
||
m.logger.Printf("[INFO] [offbox] place %s: scratch removed after successful placement", stack)
|
||
}
|
||
m.logger.Printf("[INFO] [offbox] placed %s from offsite scratch: %d file(s) merged (missing-only)", stack, placed)
|
||
return nil
|
||
}
|