v0.318.0: the internal data place, partitions, an honest whole-box backup offer (R-1084), sys_drive never offered (R-1082)
gates / gates (push) Successful in 46s
gates / gates (push) Successful in 46s
felhom.eu architecture/16 §4-§6 (build B). MinAgent 0.157.0 — the internal place and the partitions appear only when agent >= 0.158.0 reports them. Tests red-proved; four parity fixtures refreshed (only the new lines). Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
@@ -37,6 +37,9 @@ type BackupTargetState struct {
|
||||
// was told the backup was on the system disk (false) and offered the drive that just disappeared
|
||||
// (felhom.eu audits/E2D-fresh-vm-2026-07-29.md §5.3).
|
||||
TargetAbsent bool
|
||||
// OfferUnproven (R-1084) marks an offered drive the agent cannot prove is another disk than the system data:
|
||||
// the page then makes no disk-failure promise.
|
||||
OfferUnproven bool
|
||||
// TargetID is the agent's primary tier storage id (e.g. "felhom-backup" / "local").
|
||||
TargetID string
|
||||
// Label is the customer-facing drive name when the target is a real drive.
|
||||
@@ -95,7 +98,7 @@ func (s *Server) resolveBackupTargetState(ctx context.Context) BackupTargetState
|
||||
// this state must be correct on its own. Belt here, braces there.
|
||||
return st
|
||||
}
|
||||
st.OfferPath, st.OfferLabel = s.firstOfferableDrive(disks.Disks)
|
||||
st.OfferPath, st.OfferLabel, st.OfferUnproven = s.firstOfferableDrive(ctx, disks.Disks)
|
||||
return st
|
||||
}
|
||||
|
||||
@@ -127,20 +130,81 @@ func targetIsConfiguredDrive(targetID string) bool {
|
||||
// firstOfferableDrive picks a registered, connected, non-network drive that could hold the backup.
|
||||
// It is a SUGGESTION for the offer — the customer still has to choose (E-2 §3). Nothing here assigns
|
||||
// anything.
|
||||
func (s *Server) firstOfferableDrive(disks []agentapi.DiskInfo) (path, label string) {
|
||||
//
|
||||
// R-1084 (v0.318.0, felhom.eu architecture/16 §4): the offer promises survival of a DISK failure, so it now asks the
|
||||
// agent whether the drive is really another disk than the system data. A proven different disk wins; a drive the
|
||||
// agent cannot prove (a VM, a USB bridge that hides the serial) is still offered, but unproven=true makes the page say
|
||||
// so in build A's words instead of the promise; a drive on the SAME disk is never offered (a copy there dies with the
|
||||
// disk); an agent older than 0.157.0 keeps today's offer. Pinned by r1084_offer_identity_test.go.
|
||||
func (s *Server) firstOfferableDrive(ctx context.Context, disks []agentapi.DiskInfo) (path, label string, unproven bool) {
|
||||
fallback, fallbackLabel, fallbackUnproven := "", "", false
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.IsNetwork() || sp.Decommissioned || sp.Disconnected {
|
||||
if sp.IsNetwork() || sp.IsInternal() || sp.Decommissioned || sp.Disconnected {
|
||||
continue
|
||||
}
|
||||
for _, d := range disks {
|
||||
// Only a drive the agent classifies as user-data with its OWN mountpoint can be a target;
|
||||
// the agent refuses anything else anyway, so offering it would be a dead end.
|
||||
if d.GuestPath == sp.Path && d.MountPath != "" && d.Role == "user-data" {
|
||||
return sp.Path, s.storageLabelFor(sp.Path)
|
||||
if d.GuestPath != sp.Path || d.MountPath == "" || d.Role != "user-data" {
|
||||
continue
|
||||
}
|
||||
switch s.offerRelationFor(ctx, sp.Path) {
|
||||
case offerDifferent:
|
||||
return sp.Path, s.storageLabelFor(sp.Path), false
|
||||
case offerSame:
|
||||
continue
|
||||
case offerUnknown:
|
||||
if fallback == "" {
|
||||
fallback, fallbackLabel, fallbackUnproven = sp.Path, s.storageLabelFor(sp.Path), true
|
||||
}
|
||||
default: // offerLegacy: today's behaviour
|
||||
if fallback == "" {
|
||||
fallback, fallbackLabel, fallbackUnproven = sp.Path, s.storageLabelFor(sp.Path), false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
return fallback, fallbackLabel, fallbackUnproven
|
||||
}
|
||||
|
||||
// offerRelation is the agent's verdict on a candidate backup drive against the system data (R-1084).
|
||||
type offerRelation int
|
||||
|
||||
const (
|
||||
offerLegacy offerRelation = iota // the agent cannot judge (older than 0.157.0): today's offer, today's words
|
||||
offerDifferent // proven different disks: the disk-failure promise holds
|
||||
offerUnknown // not proven (a VM, a hiding USB bridge, or the question failed): no promise
|
||||
offerSame // the same physical disk: never offered
|
||||
)
|
||||
|
||||
// offerRelationFor asks the agent (POST /disks/same-disk) through the sameDiskFn seam (nil → the real client).
|
||||
func (s *Server) offerRelationFor(ctx context.Context, drive string) offerRelation {
|
||||
sdp := "/mnt/sys_drive"
|
||||
if s.cfg != nil && s.cfg.Paths.SystemDataPath != "" {
|
||||
sdp = s.cfg.Paths.SystemDataPath
|
||||
}
|
||||
if s.sameDiskFn != nil {
|
||||
return s.sameDiskFn(ctx, drive, sdp)
|
||||
}
|
||||
client, err := s.agentClient()
|
||||
if err != nil {
|
||||
return offerLegacy
|
||||
}
|
||||
if client.Supports(ctx, agentapi.FeatureSameDisk) == agentapi.SupportNo {
|
||||
return offerLegacy
|
||||
}
|
||||
r, err := client.SameDisk(ctx, drive, sdp)
|
||||
switch {
|
||||
case err != nil:
|
||||
s.logger.Printf("[WARN] [backup-target] same-disk question for %s failed: %v — the offer makes no promise", drive, err)
|
||||
return offerUnknown
|
||||
case r.Proven && r.Different:
|
||||
return offerDifferent
|
||||
case r.Proven:
|
||||
return offerSame
|
||||
default:
|
||||
return offerUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// baseName is path.Base without importing path into this file's surface.
|
||||
@@ -171,6 +235,9 @@ func baseName(p string) string {
|
||||
const (
|
||||
backupTargetDegradedKey = "backup.target.degraded"
|
||||
backupTargetOfferKey = "backup.target.offer"
|
||||
// backupTargetOfferUnprovenKey (R-1084, v0.318.0): the offer for a drive the agent cannot prove is another disk —
|
||||
// build A's words, no disk-failure promise.
|
||||
backupTargetOfferUnprovenKey = "backup.target.offer_unproven"
|
||||
// backupTargetAbsentText (R-114) is the CONFIGURED-BUT-GONE state. It is VERBATIM the hub's
|
||||
// customerMessages["backup_target_absent"] (felhom.eu hub/internal/notify/templates.go:93) so the
|
||||
// banner a customer reads on the page and the email they receive say exactly the same thing — a
|
||||
@@ -245,7 +312,11 @@ func (s *Server) backupTargetView(ctx context.Context, lang string) *BackupTarge
|
||||
}
|
||||
v := &BackupTargetView{Message: s.msgLang(lang, key)}
|
||||
if st.OfferPath != "" {
|
||||
v.OfferPath, v.OfferLabel, v.OfferText = st.OfferPath, st.OfferLabel, s.msgLang(lang, backupTargetOfferKey)
|
||||
offerKey := backupTargetOfferKey
|
||||
if st.OfferUnproven {
|
||||
offerKey = backupTargetOfferUnprovenKey // R-1084: no disk-failure promise for an unproven disk
|
||||
}
|
||||
v.OfferPath, v.OfferLabel, v.OfferText = st.OfferPath, st.OfferLabel, s.msgLang(lang, offerKey)
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -264,8 +335,13 @@ func (s *Server) handleBackupTargetState(w http.ResponseWriter, r *http.Request)
|
||||
out["message"] = s.msg(r, degradedMessageFor(st))
|
||||
if st.OfferPath != "" {
|
||||
out["offer_path"] = st.OfferPath
|
||||
out["offer_unproven"] = st.OfferUnproven // R-1084
|
||||
out["offer_label"] = st.OfferLabel
|
||||
out["offer_message"] = s.msg(r, backupTargetOfferKey)
|
||||
offerKey := backupTargetOfferKey
|
||||
if st.OfferUnproven {
|
||||
offerKey = backupTargetOfferUnprovenKey // R-1084
|
||||
}
|
||||
out["offer_message"] = s.msg(r, offerKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user