R-1252 (P2): a rejected or absent backup block never deletes second-drive copies — reconcile only on a valid classification; a rejected block is said on the app's status and once a day to the operator (backup_run_failures); kept copies stay kept (H2); Remove drops the kept records it deleted (H9). Commit only.
gates / gates (push) Successful in 1m18s

This commit is contained in:
2026-10-24 20:07:16 +02:00
parent 062f9b41d9
commit 518a13592d
16 changed files with 400 additions and 6 deletions
@@ -215,3 +215,26 @@ func TestCatalogBlocks_Fidelity(t *testing.T) {
})
}
}
// R-1252: the second-drive copy must tell a REJECTED block from an absent one (a rejected one keeps every copy and
// alarms; an absent one is quiet). Through the REAL LoadMetadata path. RED-PROOF: drop the BackupRejected assignment
// in LoadMetadata → the rejected fixture reads "" and this fails.
func TestR1252_BackupBlockRejectedNamesTheReason(t *testing.T) {
compose := `services:
app:
volumes:
- ${USERDATA_PATH}/media/audiobooks:/audiobooks
`
bad := newClassifyManager(t, "display_name: App\nbackup:\n userdata:\n - path: media/nope\n class: mandatory\n", compose)
if why := bad.BackupBlockRejected("app"); why == "" {
t.Fatal("a block whose path matches no compose bind must be reported as rejected, with its reason")
}
good := newClassifyManager(t, "display_name: App\nbackup:\n userdata:\n - path: media/audiobooks\n class: mandatory\n", compose)
if why := good.BackupBlockRejected("app"); why != "" {
t.Fatalf("a valid block is not rejected, got %q", why)
}
none := newClassifyManager(t, "display_name: App\n", compose)
if why := none.BackupBlockRejected("app"); why != "" {
t.Fatalf("an absent block is not rejected, got %q", why)
}
}
+14
View File
@@ -92,6 +92,9 @@ type Metadata struct {
// block (sets this back to nil + logs one ERROR) on any validation defect, so a bad catalog push
// degrades to legacy loudly rather than partially classifying. Consumed by Task 3/4 — INERT today.
Backup *appbackup.BackupSpec `yaml:"backup,omitempty" json:"backup,omitempty"`
// BackupRejected (R-1252) is why LoadMetadata rejected the backup block ("" = not rejected). Never read from the
// file: the second-drive copy must tell a REJECTED block (keep every copy, alarm) from an absent one.
BackupRejected string `yaml:"-" json:"-"`
// DataPaths is the customer-facing folder ANNOTATION (R-75): role + Hungarian label over paths
// that must ALREADY exist as compose binds. It never declares a path. Validated in LoadMetadata
// with the Fork-3 asymmetry — a malformed PATH rejects the whole block, an unknown ROLE drops
@@ -551,9 +554,11 @@ func loadMetadata(stackDir string, validateBackup bool) Metadata {
if composeErr != nil {
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: docker-compose.yml unreadable: %v", stackDir, composeErr)
meta.Backup = nil
meta.BackupRejected = "docker-compose.yml unreadable: " + composeErr.Error()
} else if err := appbackup.ValidateBackupSpec(meta.Backup, binds); err != nil {
log.Printf("[ERROR] [stacks] .felhom.yml backup block rejected in %s: %v", stackDir, err)
meta.Backup = nil
meta.BackupRejected = err.Error()
}
}
// data_paths (R-75) rides the same choke point, with its own asymmetric rules: a malformed
@@ -597,6 +602,15 @@ func (m *Manager) ClassifiedBinds(name string) ([]appbackup.ClassifiedBind, bool
return appbackup.ClassifyBinds(meta.Backup, binds)
}
// BackupBlockRejected (R-1252) is why this app's backup block was rejected, or "" when it is valid or absent.
func (m *Manager) BackupBlockRejected(name string) string {
stack, ok := m.GetStack(name)
if !ok || name == SambaStackName {
return ""
}
return LoadMetadata(filepath.Dir(stack.ComposePath)).BackupRejected
}
// ExcludedDockerVolumes returns the Docker names of the stack's named volumes its template excludes from every app-data backup
// (R-49, D21 = B; appbackup.ExcludedDockerVolumes), and a sentence per template entry that was ignored.
func (m *Manager) ExcludedDockerVolumes(name string) ([]string, []string) {