W21 sharing switched on without a password asks for it and says why it waits; W25 failed off-site run shows the household a sentence built from the classifier, raw text only to log and operator; W28 share restore skips the controller's drop-zone share; unreleased
gates / gates (push) Successful in 1m40s

This commit is contained in:
2026-10-21 23:19:07 +02:00
parent 28b05d6d68
commit 5776d54901
16 changed files with 330 additions and 7 deletions
+77 -1
View File
@@ -271,6 +271,66 @@ func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duratio
return fmt.Sprintf("%s (%s): %s", head, dur.Round(time.Second), sanitiseOffsiteErrorFor(t, err))
}
// offsiteSharesName marks the shares source in offboxRunResult.failedApps; offsiteHouseholdFailure names it in
// the household's language.
const offsiteSharesName = SharesPseudoStack
// offsiteHouseholdFailure is the sentence the household reads on the „Távoli mentés" page after a failed run (W25,
// rehearsal 2026-10-20 — it read „Utolsó hiba: offbox backup immich: exit status 1"). Built from the failure
// classifier (07 §6.7) and the run's own result, in the box's language, with NO raw error text: that goes to the
// log and, sanitised, to the operator's event (OffsiteFailureMessage), unchanged.
//
// - apps failed while the run went on: they are named; „the other apps are fine" only when at least one app was
// copied and no other app was left out or left incomplete;
// - otherwise our own keyed sentence when the error carries one (the quota refusal, with its numbers), the apps
// with no local unit when there was nothing to send, or the run's cause, one sentence per class
// (note.offsite.fail_<class>);
// - „the box tries again the next night" only when a nightly run will come AND a retry can help (not for a full
// store, a lost key, or nothing to send — those need a person).
//
// Pinned by TestW25_FailedOffsiteRunSpeaksToTheHousehold.
func offsiteHouseholdFailure(lang string, runErr error, res offboxRunResult, nightly bool) string {
class := ClassifyOffsiteFailure(runErr)
var parts []string
retry := false
if len(res.failedApps) > 0 {
names := make([]string, 0, len(res.failedApps))
for _, n := range res.failedApps {
if n == offsiteSharesName {
n = util.Text(lang, "note.offsite.shares_name")
}
names = append(names, n)
}
parts = append(parts, util.Text(lang, "note.offsite.fail_apps", strings.Join(names, ", ")))
if res.backedUp > 0 && len(res.missingUnprotected) == 0 && len(res.mandatoryGaps) == 0 {
parts = append(parts, util.Text(lang, "note.offsite.others_ok"))
}
retry = class != OffsiteFailQuota && class != OffsiteFailOrphaned
} else if msg, ok := util.AsMsg(runErr); ok {
// Our own sentence (the quota refusal with its numbers and its next step): already the household's.
parts = append(parts, msg.Text(lang))
retry = class == OffsiteFailTransport || class == OffsiteFailLocked || class == OffsiteFailUnknown
} else if class == OffsiteFailNoUnits && len(res.missing) > 0 {
parts = append(parts, util.Text(lang, "note.offsite.no_local_unit", strings.Join(res.missing, ", ")))
} else {
key := map[OffsiteFailureClass]string{
OffsiteFailQuota: "note.offsite.fail_quota", OffsiteFailOrphaned: "note.offsite.fail_orphaned",
OffsiteFailNoRepo: "note.offsite.fail_no_repo", OffsiteFailNoUnits: "note.offsite.fail_no_units",
OffsiteFailTransport: "note.offsite.fail_transport", OffsiteFailUnknown: "note.offsite.fail_unknown",
OffsiteFailLocked: "note.offsite.fail_locked",
}[class]
if key == "" {
key = "note.offsite.fail_head"
}
parts = append(parts, util.Text(lang, key)+".")
retry = class == OffsiteFailTransport || class == OffsiteFailLocked || class == OffsiteFailUnknown
}
if retry && nightly {
parts = append(parts, util.Text(lang, "note.offsite.retry_next_night"))
}
return strings.Join(parts, " ")
}
func defaultOffboxSSH(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) {
if port == 0 {
port = 22
@@ -1099,6 +1159,10 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
// everything else that touches settings.json on that box. Written this way once and caught by the
// suite timing out at 25 minutes; TestNoteHelpersAreNotCalledUnderTheSettingsLock keeps it fixed.
lang := m.boxLang()
householdNote := ""
if runErr != nil {
householdNote = offsiteHouseholdFailure(lang, runErr, runResult, t != nil && t.Enabled && t.Schedule == "daily")
}
if perr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.LastRun = time.Now().UTC().Format(time.RFC3339)
// R-100: LastRun above records the ATTEMPT; this records the RESULT. The hub's staleness
@@ -1115,7 +1179,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
o.LastWarningKind = ""
} else if runErr != nil {
o.LastStatus = "error"
o.LastError = runErr.Error()
o.LastError = householdNote // W25: the household's sentence; the raw text is in the log and the operator's event
o.LastWarning = ""
o.LastWarningKind = ""
} else {
@@ -1371,6 +1435,10 @@ type offboxRunResult struct {
// longer installed is named but does not, so a removed app cannot leave the box amber forever.
missingUnprotected []string
missingNotDeployed []string
// failedApps (W25) names, for the household, every app whose push FAILED while the run went on — the
// display name from the app's recovery unit (the stack name when the unit has none), and the shares
// source under its own name. firstErr still carries the first raw error for the log and the operator.
failedApps []string
}
// stackDeployed reports whether the stack is currently deployed on this box. Used only to classify a
@@ -1481,6 +1549,11 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
if firstErr == nil {
firstErr = fmt.Errorf("offbox backup %s: %w", stack, berr)
}
display := stack
if man := readManifest(UnitManifestFile(src)); man != nil && strings.TrimSpace(man.DisplayName) != "" {
display = man.DisplayName
}
res.failedApps = append(res.failedApps, display)
continue
}
res.backedUp++
@@ -1517,6 +1590,9 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
if sharesRes.ran {
res.sharesBackedUp = true
}
if sharesErr != nil {
res.failedApps = append(res.failedApps, offsiteSharesName)
}
if sharesErr != nil && firstErr == nil {
firstErr = sharesErr
}