v0.205.0 — a run that skipped an app the customer selected is not successful (R-234)
gates / gates (push) Successful in 21s
gates / gates (push) Successful in 21s
THE VERDICT. The R-203 block already said "a warning beside a success is read as a success" and applied it to ONE of the two shapes it describes: an app missing a declared mandatory FOLDER made the run incomplete, while an app skipped ENTIRELY still reported ok. Both do now. Which skips count, decided by measurement: selected+deployed with no recovery unit YES; selected but NOT deployed no (named, with what to do — a box left amber by an app somebody removed is a status nobody reads); disconnected/decommissioned drive no (own signal); nothing selected no. LastSuccess and SnapshotCount still record what WAS captured. THE FILED MECHANISM WAS NOT THE MEASURED CAUSE, and saying so is the point. §3 stated that toggling an app on leaves it without a bundle so the first run skips it. Measured on demo-hp: the run's own pre-dump phase calls captureAllRecoveryUnits for every DEPLOYED stack, through admitApp, before the push — a unit moved aside was RECREATED and the run reported ok. That state does not survive a run. What actually produced the 2026-08-06 sequence: the manual run was dropped by the single-flight while an earlier run was still going. runOffboxBackup returned nil, the handler had already answered "A tavoli mentes elindult", and the card then showed the PREVIOUS run's green verdict — read as covering the app just selected. The decision is now taken synchronously in the handler and a dropped request says so. The nightly path still returns nil on purpose: nobody asked, and it retries. §7.3 measured before deciding: CaptureRecoveryUnit writes a few KB of compose + manifest, only ENUMERATES dumps rather than creating them, is idempotent and does NOT stop the app — and already runs inside the off-site run. So there is no wait to remove for a deployed app and NOTHING was built. 28 packages ok, 9/9 gates. Four red-proofs, each asserted to have applied. Fixture note: the shared provider's ListDeployedStacks returned nil, so Scenario A first passed for the wrong reason; fixed with an opt-in deployed set that defaults to nil.
This commit is contained in:
@@ -77,6 +77,16 @@ func (m *Manager) SetOffboxSSH(fn func(ctx context.Context, host, user string, p
|
||||
// the orphan card instead of the raw restic error.
|
||||
var ErrOffboxOrphaned = fmt.Errorf("offbox repo orphaned: exists but keyed under a previous, no-longer-available passphrase")
|
||||
|
||||
// ErrOffboxRunInFlight is returned to the MANUAL caller only, when the single-flight dropped the
|
||||
// request because a run was already going (R-234). It is not a failure of anything — the run in
|
||||
// flight is doing the work — but it IS a request that did nothing, and the page must say so instead
|
||||
// of showing the previous run's verdict under a „started" message.
|
||||
// offboxWholeUnitGap is the pseudo-path used to report a WHOLE-unit gap through the mandatory-gap
|
||||
// notification, so a skipped app and a skipped directory reach the operator in one vocabulary.
|
||||
const offboxWholeUnitGap = "(a teljes alkalmazás — nincs helyi mentési egysége)"
|
||||
|
||||
var ErrOffboxRunInFlight = fmt.Errorf("an off-box backup is already running; this request did not start a new one")
|
||||
|
||||
// classifyResticProbe maps a `restic cat config` failure to a repo class. The signatures are the exact
|
||||
// restic stderr matched in the 2026-07-17 diagnosis + restic's no-repo message:
|
||||
// - "orphaned": repo present, wrong key ("wrong password or no key found") — the definitive signal
|
||||
@@ -746,7 +756,19 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
}
|
||||
if err := m.acquireRunning(); err != nil {
|
||||
m.logger.Printf("[INFO] [offbox] skipped — another backup is running")
|
||||
return nil // single-flight: don't race; the next scheduled run retries
|
||||
// R-234 (the MEASURED cause). The nightly path is unchanged: returning nil is right for it —
|
||||
// nobody asked, and the next scheduled run retries.
|
||||
//
|
||||
// The MANUAL path is a different question, and answering it the same way is what produced the
|
||||
// 2026-08-06 sequence. The customer pressed „Távoli mentés most" and was told
|
||||
// „A távoli mentés elindult"; the run was dropped here and returned nil; the card then showed
|
||||
// the PREVIOUS run's „✓ Rendben", which they read as covering the app they had just selected.
|
||||
// It did not — the restore refused for that app minutes later. A request that did nothing must
|
||||
// not be reported as one that started, so the manual caller is told.
|
||||
if withProgress {
|
||||
return ErrOffboxRunInFlight
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer m.releaseRunning()
|
||||
|
||||
@@ -874,10 +896,38 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
// backup is not no backup, and reporting it as none would be its own lie. `incomplete` is
|
||||
// minted here because the existing vocabulary ("ok" | "error" | "running") has nothing that
|
||||
// means "it ran, and this app is not fully protected".
|
||||
if len(runResult.mandatoryGaps) > 0 {
|
||||
// R-234 EXTENDS THE SAME RULE TO THE BIGGER CASE. Until v0.205.0 the paragraph above was
|
||||
// applied to ONE of the two shapes it describes: an app missing a declared mandatory
|
||||
// FOLDER made the run incomplete, while an app skipped ENTIRELY — no recovery unit, so
|
||||
// nothing of it in the snapshot at all — still reported ok with a warning beside it. The
|
||||
// smaller gap moved the verdict and the bigger one did not. Measured 2026-08-06: a run
|
||||
// reported „✓ Rendben · 1 pillanatkép" and the restore then refused for the app the
|
||||
// customer had just selected.
|
||||
gaps := len(runResult.mandatoryGaps) > 0
|
||||
unprotected := len(runResult.missingUnprotected) > 0
|
||||
if gaps || unprotected {
|
||||
o.LastStatus = "incomplete"
|
||||
if m.offboxGapNotify != nil {
|
||||
m.offboxGapNotify(runResult.mandatoryGaps)
|
||||
// Reuse, not mirror: the operator signal for "this run left an app less protected
|
||||
// than the customer asked for" is the same signal. A skipped app is reported as a
|
||||
// whole-unit gap so one notification shape covers both, and the recipient does not
|
||||
// have to learn a second vocabulary for the worse case.
|
||||
notify := runResult.mandatoryGaps
|
||||
if unprotected {
|
||||
if notify == nil {
|
||||
notify = map[string][]string{}
|
||||
} else {
|
||||
cp := make(map[string][]string, len(notify)+len(runResult.missingUnprotected))
|
||||
for k, v := range notify {
|
||||
cp[k] = v
|
||||
}
|
||||
notify = cp
|
||||
}
|
||||
for _, a := range runResult.missingUnprotected {
|
||||
notify[a] = append(notify[a], offboxWholeUnitGap)
|
||||
}
|
||||
}
|
||||
m.offboxGapNotify(notify)
|
||||
}
|
||||
} else {
|
||||
o.LastStatus = "ok"
|
||||
@@ -895,9 +945,18 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
if len(apps) == 0 && !runResult.sharesBackedUp {
|
||||
warns = append(warns, "Sikeres — nincs mentésre jelölt alkalmazás")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
warns = append(warns, fmt.Sprintf("Figyelmeztetés: %d alkalmazásnak nincs elérhető mentése, ezek kimaradtak: %s",
|
||||
len(missing), strings.Join(missing, ", ")))
|
||||
// R-234 §7.4 — WHICH apps, WHY, and WHEN. The old sentence said only that N apps "had no
|
||||
// available backup and were left out", which names a problem with no next step and reads
|
||||
// the same whether the customer must act or simply wait.
|
||||
if len(runResult.missingUnprotected) > 0 {
|
||||
warns = append(warns, fmt.Sprintf(
|
||||
"Ezek az alkalmazások NEM kerültek be a távoli mentésbe, mert még nincs helyi mentési egységük: %s. A következő mentés általában már elkészíti — ha a második futás után is itt szerepelnek, szólj az üzemeltetőnek.",
|
||||
strings.Join(runResult.missingUnprotected, ", ")))
|
||||
}
|
||||
if len(runResult.missingNotDeployed) > 0 {
|
||||
warns = append(warns, fmt.Sprintf(
|
||||
"Ezek az alkalmazások ki vannak jelölve távoli mentésre, de nincsenek telepítve, ezért nem menthetők: %s. Ha már nincs rájuk szükséged, vedd ki a kijelölésüket a Távoli mentés oldalon.",
|
||||
strings.Join(runResult.missingNotDeployed, ", ")))
|
||||
}
|
||||
// 3a: capture-gap warnings (structurally-refused / on-disk-missing mandatory paths, undeployed).
|
||||
warns = append(warns, runResult.warns...)
|
||||
@@ -1059,6 +1118,39 @@ type offboxRunResult struct {
|
||||
// that could NOT be captured. It is the STRUCTURED form of the warnings above, and it is what
|
||||
// decides the run's verdict: a run that dropped a mandatory directory is not a successful run.
|
||||
mandatoryGaps map[string][]string
|
||||
// missingUnprotected / missingNotDeployed (R-234) split `missing` by WHY, because only one of the
|
||||
// two may move the verdict. See the classification comment at the skip site: an app the customer
|
||||
// selected and that IS deployed but has no unit is unprotected and counts; an app that is no
|
||||
// longer installed is named but does not, so a removed app cannot leave the box amber forever.
|
||||
missingUnprotected []string
|
||||
missingNotDeployed []string
|
||||
}
|
||||
|
||||
// stackDeployed reports whether the stack is currently deployed on this box. Used only to classify a
|
||||
// skip (R-234) — never to decide whether to back something up.
|
||||
func (m *Manager) stackDeployed(stack string) bool {
|
||||
if m.stackProvider == nil {
|
||||
return false
|
||||
}
|
||||
for _, st := range m.stackProvider.ListDeployedStacks() {
|
||||
if st.Name == stack {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// driveUnavailableFor reports whether the app's drive is disconnected or decommissioned — states that
|
||||
// already have their own customer-facing signal, so a skip caused by them is not re-reported here.
|
||||
func (m *Manager) driveUnavailableFor(stack string) bool {
|
||||
if m.settings == nil {
|
||||
return false
|
||||
}
|
||||
d := m.GetAppDrivePath(stack)
|
||||
if d == "" {
|
||||
return false
|
||||
}
|
||||
return m.settings.IsDisconnected(d) || m.settings.IsDecommissioned(d)
|
||||
}
|
||||
|
||||
// runOffboxInternal does the repo-ensure + per-app DISCOVER → capture-set → gate → multi-path backup +
|
||||
@@ -1079,6 +1171,28 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
|
||||
if !ok {
|
||||
m.logger.Printf("[WARN] [offbox] %s: no recovery unit found on any connected drive — skipping", stack)
|
||||
res.missing = append(res.missing, stack)
|
||||
// R-234 §7.2 — WHICH skips make the run not-successful. The list above is prose for the
|
||||
// customer; this classification is what the VERDICT may consult, and the two are not the
|
||||
// same question. Established by measurement on demo-hp 2026-08-06, not assumed:
|
||||
//
|
||||
// * DEPLOYED, no unit — the run's own pre-dump phase (captureAllRecoveryUnits) writes a
|
||||
// unit for every deployed stack before the push, so this state does not normally
|
||||
// survive a run. Reaching here means the capture was refused (the reserve) or failed.
|
||||
// The app the customer selected is NOT protected: it COUNTS.
|
||||
// * NOT DEPLOYED — nothing can protect an app that is not there, and the remedy is to
|
||||
// deselect it. It is NAMED so the customer can act, but it does NOT count: a box left
|
||||
// permanently amber over an app somebody removed is a status that stops being read,
|
||||
// which is how this whole class of defect starts.
|
||||
// * drive disconnected/decommissioned — has its own signal and its own card; not ours to
|
||||
// re-report as a backup gap.
|
||||
switch {
|
||||
case !m.stackDeployed(stack):
|
||||
res.missingNotDeployed = append(res.missingNotDeployed, stack)
|
||||
case m.driveUnavailableFor(stack):
|
||||
// counted as neither: the drive card is the honest surface for this one.
|
||||
default:
|
||||
res.missingUnprotected = append(res.missingUnprotected, stack)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Task 3-core TierOffsite capture set: mandatory userdata paths added to the unit snapshot,
|
||||
|
||||
Reference in New Issue
Block a user