package backup import ( "errors" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // ── D64 = B (operator ruling 2026-10-29 12:10) + R-1343: the operator clears a hold IN THE RUNNING CONTROLLER ────── // // One door for both of the operator's routes — the hub's „Clear hold" (operator action clear_hold) and the box command // `--clear-restore-hold`, which now asks the running controller over its local socket (cmd/controller/holdctl.go). // // WHY IN THIS PROCESS (R-1343, measured 2026-10-29 on Tester 1): the CLI used to run as a SECOND process that edited // settings.json while the running controller kept the hold in its own in-memory Settings — and that controller's next // save (after a hub report) wrote the hold back. Clearing through the running Settings removes the hold from memory // AND saves the file at once, so no later save of any component can bring it back. // Pinned by TestD64_ClearHoldStaysGoneAfterALaterSave (cmd/controller). // // EVERY REASON (D64 = B): the hold's reason does not matter — R-379 now reads „a person — on the box, or the operator // on the hub". One reason needs a second act: a put-back WAITING for its drive (HoldReasonPutBackPending) is also // recorded in the app-stop marker, and a controller start with the drive bound would pour that old copy back over // whatever the app wrote after the clear. So the clear also drops the app from the marker; the copy stays on disk // (nothing is deleted). It runs under resumeMu, so it can never interleave with a put-back of the same app. // Pinned by TestD64_ClearPutBackPendingDropsTheMarkerEntry. // ErrHoldBadStack: the argument is not a valid stack name. ErrNotHeld: the app has no hold. Both are refusals — // nothing was changed. var ( ErrHoldBadStack = errors.New("not a valid app name") ErrNotHeld = errors.New("this app has no hold") ) // IsValidHoldStack reports whether s may name an app for the hold commands (the backup package's strict // isSafeStackName: letters, digits, '-' and '_', at most 64). func IsValidHoldStack(s string) bool { return isSafeStackName(s) } // ClearHoldByOperator lifts stack's hold, whatever its reason, in the hold store the running controller uses, and // returns the hold it lifted. A waiting put-back is dropped from the app-stop marker too (see above). Refusals // (ErrHoldBadStack, ErrNotHeld) change nothing. func (g *AppStopGuard) ClearHoldByOperator(stack string) (settings.RestoreHold, error) { if !isSafeStackName(stack) { return settings.RestoreHold{}, ErrHoldBadStack } if g == nil || g.holds == nil { return settings.RestoreHold{}, errors.New("no hold store is wired on this box") } resumeMu.Lock() defer resumeMu.Unlock() h, ok := g.holds.GetRestoreHold(stack) if !ok { return settings.RestoreHold{}, ErrNotHeld } cleared, err := g.holds.ClearRestoreHold(stack) if err != nil { return h, err } if !cleared { return settings.RestoreHold{}, ErrNotHeld } if h.Reason == settings.HoldReasonPutBackPending { g.dropFromMarker(stack) g.logger.Printf("[WARN] [appstop] %s: its waiting put-back is DROPPED with the hold — the copy stays on disk at %s and is never poured back by itself", stack, h.SafetyDump) } return h, nil }