Placement hardening (F-3a-1..4) + enlarge-blocked delivery chain (Task 3a-fix, v0.134.1)

PlaceOffsiteRestore: live target via raw GetStackHDDPath not AppNamespaceRoot (F-3a-1a: no SSD
merge; undeployed refused), placement headroom gate (F-3a-1b), stat pre-pass over all placements
before any copy (F-3a-4: no partial writes), scratch removed on success/kept on failure (F-3a-2).
mapOffsiteRestorePaths refuses the namespace root itself (F-3a-3).
Delivery chain: DefaultEnabledEvents + GetNotificationPrefs append-if-absent migration + settings
checkbox + handler slice; paired with hub v0.55.0 allowlist (no customerMessages entry — raw
dynamic message survives). +8 tests; all 6 controller §10 red-proofs verified.
This commit is contained in:
2026-07-15 07:54:29 +02:00
parent 482d0d98e3
commit 0cfcc42464
9 changed files with 387 additions and 106 deletions
+34 -10
View File
@@ -284,7 +284,10 @@ func mapOffsiteRestorePaths(snapPaths []string, stack, scratch, liveNsRoot strin
}
out := make([]placement, 0, len(snapPaths))
for _, p := range snapPaths {
if p != oldNs && !strings.HasPrefix(p, oldNs+"/") {
// 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+"/")
@@ -344,22 +347,36 @@ func (m *Manager) PlaceOffsiteRestore(ctx context.Context, stack string) error {
return err
}
_ = id
liveNs := m.AppNamespaceRoot(stack)
if liveNs == "" {
return fmt.Errorf("a(z) %s élő adatmeghajtója nem határozható meg", stack)
// 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 _, sErr := os.Stat(pl.src); sErr != nil {
// The full scratch is incomplete for this path (e.g. only a unit-only restore ran) — refuse
// rather than place a partial set.
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))
}
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)
@@ -368,10 +385,17 @@ func (m *Manager) PlaceOffsiteRestore(ctx context.Context, stack string) error {
}
n, cErr := copier(pl.src, pl.dst)
if cErr != nil {
return fmt.Errorf("a(z) %s helyreállítása sikertelen: %w", stack, cErr)
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
}