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
}
@@ -241,6 +241,16 @@ func (m *Manager) PlaceSharesRestore(ctx context.Context) (SharesRestoreResult,
// THE PREFIX ASSERT. The snapshot's path is untrusted layout input; a destination that is not
// strictly inside a live registered root is refused, never created.
if !m.liveShareRootOK(sh.Path) {
// W28 (rehearsal 2026-10-20): the controller's OWN drop-zone share („beolvasas", R-75) lives on the
// SYSTEM drive, never under a data-drive root, so it is always refused here — and the household read
// „Nem állítható vissza (…): beolvasas." about a share it never made. Its definition comes back by
// itself (ensureImportShare, whenever sharing is on) and its folder is an inbox apps empty, so it is
// skipped without a word to the household; the refusal of any OTHER share is unchanged.
// Pinned by TestW28_TheSystemDropZoneIsNotNamedAsRefused.
if sh.System {
m.logger.Printf("[INFO] [shares] restore skipped for the system drop-zone share %s — the controller re-creates it while sharing is on", sh.Name)
continue
}
m.logger.Printf("[WARN] [shares] restore refused for %s — destination is not under a live storage root", sh.Name)
res.Refused = append(res.Refused, sh.Name)
continue
@@ -266,3 +266,40 @@ func TestSharesRestoreFlashMessage(t *testing.T) {
// jsonMarshalIndent is a tiny indirection so the test file needs no direct encoding/json import
// beyond this one helper.
func jsonMarshalIndent(v any) ([]byte, error) { return json.MarshalIndent(v, "", " ") }
// W28 (rehearsal 2026-10-20): the share restore said „Nem állítható vissza (a mappa nincs élő adatmeghajtón):
// beolvasas." — „beolvasas" is the controller's own drop-zone share (R-75, System), which lives on the system drive
// and is re-created by the controller while sharing is on. It is skipped without naming it; a household share outside
// every live root is still refused and named.
func TestW28_TheSystemDropZoneIsNotNamedAsRefused(t *testing.T) {
env := newSharesEnv(t, "hdd_1")
dropZone := filepath.Join(env.tmp, "system", "felhom-data", "userdata", "import")
outside := filepath.Join(env.tmp, "kivul", "csaladi")
mf := SharesManifest{
Version: sharesManifestVersion, ServerName: "FELHOM",
Shares: []settings.SMBShare{
{Name: settings.SystemImportShareName, Path: dropZone, System: true, CreatedAt: "2026-07-18T00:00:00Z"},
{Name: "csaladi", Path: outside, Offsite: true, CreatedAt: "2026-07-18T00:00:00Z"},
},
}
seedSharesScratch(t, env, mf, map[string]string{filepath.Join(dropZone, "scan.pdf"): "X"})
var passdbCalls int
wirePlaceSeams(env, &passdbCalls)
res, err := env.m.PlaceSharesRestore(t.Context())
if err != nil {
t.Fatal(err)
}
msg := res.FlashMessage()
if strings.Contains(msg, settings.SystemImportShareName) {
t.Errorf("the household was told about the controller's drop-zone share: %q", msg)
}
if len(res.Refused) != 1 || res.Refused[0] != "csaladi" || !strings.Contains(msg, "csaladi") {
t.Errorf("a household share outside every live root must still be refused and named: %+v %q", res.Refused, msg)
}
if _, sErr := os.Stat(filepath.Join(dropZone, "scan.pdf")); !os.IsNotExist(sErr) {
t.Error("the drop-zone's files were placed — the skip must write nothing")
}
if len(env.sett.GetSMBShares()) != 0 {
t.Errorf("a skipped share must not enter the registry here: %+v", env.sett.GetSMBShares())
}
}
@@ -0,0 +1,69 @@
package backup
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"time"
)
// W25 (rehearsal 2026-10-20): a failed off-site run showed the household „Utolsó hiba: offbox backup immich: exit
// status 1". The household now reads which app failed (its own name), that the other apps are fine when they are,
// and that the box tries again the next night; the raw text stays in the log and reaches the operator's event.
func TestW25_FailedOffsiteRunSpeaksToTheHousehold(t *testing.T) {
m, sett := newOffboxManager(t)
usb := t.TempDir()
addSchedulablePath(t, sett, usb)
ns := m.namespaceRoot(usb)
writeUnit(t, ns, "immich", "2026-10-20T00:00:00Z")
writeUnit(t, ns, "mealie", "2026-10-20T00:00:00Z")
b, _ := json.Marshal(RecoveryManifest{AppName: "immich", DisplayName: "Immich Fotók", CreatedAt: "2026-10-20T00:00:00Z"})
if err := os.WriteFile(RecoveryUnitManifestPath(ns, "immich"), b, 0o644); err != nil {
t.Fatal(err)
}
_ = sett.SetAppOffbox("immich", true)
_ = sett.SetAppOffbox("mealie", true)
rr := &recordingOffboxRunner{backupErr: map[string]error{"immich": errors.New("exit status 1")}}
m.SetOffboxRunner(rr.run)
var operatorErr error
m.SetOffboxNotify(func(_ time.Duration, _ int, err error) { operatorErr = err })
if err := m.RunOffboxBackup(context.Background()); err == nil {
t.Fatal("a run with a failed app must report an error")
}
got := sett.GetOffboxTarget().LastError
for _, want := range []string{"Nem sikerült a távoli mentés: Immich Fotók.", "A többi alkalmazás távoli mentése rendben van.",
"A doboz a következő éjjel újra próbálkozik."} {
if !strings.Contains(got, want) {
t.Errorf("the household's note lacks %q: %q", want, got)
}
}
for _, raw := range []string{"exit status", "offbox backup", "immich:"} {
if strings.Contains(got, raw) {
t.Errorf("raw technical text %q reached the household's note: %q", raw, got)
}
}
// the operator's event keeps the technical detail, sanitised
if op := m.OffsiteFailureMessage(operatorErr, time.Second); !strings.Contains(op, "offbox backup immich: exit status 1") {
t.Errorf("the operator's event lost the technical detail: %q", op)
}
// English, and the cases where the other apps are NOT fine or no night comes
res := offboxRunResult{backedUp: 1, failedApps: []string{"Immich", offsiteSharesName}}
runErr := errors.New("offbox backup immich: exit status 1")
en := offsiteHouseholdFailure("en", runErr, res, true)
if en != "The remote backup did not work for: Immich, Shared folders. The remote backup of the other apps is fine. The box tries again the next night." {
t.Errorf("en: %q", en)
}
res.missingUnprotected = []string{"paperless"}
if hu := offsiteHouseholdFailure("hu", runErr, res, false); strings.Contains(hu, "többi") || strings.Contains(hu, "éjjel") {
t.Errorf("with an app left out the others are not all fine, and with no nightly run there is no retry: %q", hu)
}
// a whole-run failure: its class sentence, never the raw text
tr := offsiteHouseholdFailure("hu", errors.New("ssh: connect to host x port 23: connection refused"), offboxRunResult{}, true)
if tr != "A távoli tárhely nem érhető el (hálózat vagy bejelentkezés). A doboz a következő éjjel újra próbálkozik." {
t.Errorf("transport: %q", tr)
}
}