feat(shares): R-7b Parts 4-6 — shares restore, samba liveness, UI truth-up

Part 4 — restore: RestoreSharesScratch + PlaceSharesRestore as SIBLINGS of the
per-app scratch/place pair. Files merged missing-only (never overwriting), each
destination PREFIX-ASSERTED against registered LIVE storage roots; definitions
merged with existing-wins; ReconcileSamba via a seam (backup must not import
stacks); credential restored best-effort into the samba named volume.
New routes POST /backup/shares/{restore,place} + a restore-page entry that renders
'Megosztasok', never the raw reserved key.
Also adds scratchJoin: reconstructing an absolute captured path under a scratch
must strip the volume name rather than rely on filepath.Join.

Part 5 — liveness: EffectiveProtected gains a settings-backed dynamic extra so the
samba CONTAINER (not the stack name — they differ) is watched exactly while sharing
is on. FINDING: the issue -> health 'fail' -> existing health_critical event ->
alert -> Hungarian degradation e-mail path needs NO further change, and introduces
no new event type, so the allowlist gotcha does not apply.

Part 6 — UI: per-tier backup status lines on the Megosztas page (amber only on
deviation). Verified the two warning-prose sites (offbox_capture/tier2_capture)
only ever receive per-app stack names, so no mapping is needed there.

RED-PROOFS RUN AND REVERTED (both fired):
  4. prefix-assert removed        -> place-guard traversal test FAILS
  5. dynamic samba extra removed  -> Scenario E enabled-case FAILS
This commit is contained in:
2026-07-18 13:02:41 +02:00
parent 85b76e0fc3
commit 900c870212
14 changed files with 822 additions and 18 deletions
@@ -4,6 +4,8 @@ import (
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
func contains(ss []string, want string) bool {
@@ -21,7 +23,7 @@ func TestEffectiveProtectedDropsCloudflaredWithoutToken(t *testing.T) {
base := config.StacksConfig{Protected: []string{"traefik", "cloudflared", "felhom-controller", "filebrowser"}}
cfgNoTok := &config.Config{Stacks: base}
got := EffectiveProtected(cfgNoTok)
got := EffectiveProtected(cfgNoTok, settings.SMBSettings{})
if contains(got, "cloudflared") {
t.Errorf("cloudflared must be dropped when no tunnel token: %v", got)
}
@@ -33,7 +35,33 @@ func TestEffectiveProtectedDropsCloudflaredWithoutToken(t *testing.T) {
cfgTok := &config.Config{Stacks: base}
cfgTok.Infrastructure.CFTunnelToken = "tok"
if !contains(EffectiveProtected(cfgTok), "cloudflared") {
if !contains(EffectiveProtected(cfgTok, settings.SMBSettings{}), "cloudflared") {
t.Error("cloudflared must remain protected when a tunnel token is configured")
}
}
// R-7b Scenario E, BOTH directions. Sharing is a customer-toggled feature, so the samba container can
// never be in the golden controller.yaml — the effective set must add it dynamically when sharing is
// ON (so a dead sharing service raises the same protected-container issue as a dead traefik) and must
// leave it out when sharing is OFF (so a box that never enabled it never reports a missing container).
// Red-proof: delete the `if smb.Enabled` append and the enabled case fails.
func TestEffectiveProtectedTracksSharingToggle(t *testing.T) {
cfg := &config.Config{Stacks: config.StacksConfig{Protected: []string{"traefik", "felhom-controller"}}}
on := EffectiveProtected(cfg, settings.SMBSettings{Enabled: true})
if !contains(on, infra.SambaContainerName) {
t.Errorf("sharing ON: %q must be watched, got %v", infra.SambaContainerName, on)
}
off := EffectiveProtected(cfg, settings.SMBSettings{Enabled: false})
if contains(off, infra.SambaContainerName) {
t.Errorf("sharing OFF: %q must NOT be watched, got %v", infra.SambaContainerName, off)
}
// The base set is untouched in both directions.
for _, set := range [][]string{on, off} {
for _, must := range []string{"traefik", "felhom-controller"} {
if !contains(set, must) {
t.Errorf("%s must remain protected: %v", must, set)
}
}
}
}
+26 -8
View File
@@ -9,6 +9,7 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
@@ -23,7 +24,7 @@ type HealthReport struct {
}
// RunHealthCheck runs system checks and returns a diagnostic report.
func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, storagePaths []settings.StoragePath, logger *log.Logger) *HealthReport {
func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, storagePaths []settings.StoragePath, smb settings.SMBSettings, logger *log.Logger) *HealthReport {
report := &HealthReport{
Status: "ok",
Timestamp: time.Now(),
@@ -159,7 +160,7 @@ func RunHealthCheck(cfg *config.Config, cpuCollector *system.CPUCollector, stora
// 6. Protected containers (effective set: cloudflared only counts when a tunnel token is
// configured, so a LAN-only node doesn't report FAIL forever for a stack it intentionally skips).
protected := EffectiveProtected(cfg)
protected := EffectiveProtected(cfg, smb)
if debug {
logger.Printf("[DEBUG] [monitor] Checking %d protected containers: %v", len(protected), protected)
}
@@ -245,18 +246,35 @@ func checkDocker() error {
}
// EffectiveProtected returns the protected-container set that actually applies to this node. It is
// the configured cfg.Stacks.Protected minus stacks that are intentionally not deployed here:
// cloudflared is dropped when no tunnel token is configured (a LAN-only node legitimately runs
// without it, so it must not be reported as a missing protected container forever). The bring-up
// (stacks.EnsureBaseStack) applies the same cloudflared condition, so detection and deployment agree.
func EffectiveProtected(cfg *config.Config) []string {
out := make([]string, 0, len(cfg.Stacks.Protected))
// the configured cfg.Stacks.Protected minus stacks that are intentionally not deployed here, plus
// the DYNAMIC extras whose deployment depends on customer state rather than config:
//
// - cloudflared is dropped when no tunnel token is configured (a LAN-only node legitimately runs
// without it, so it must not be reported as a missing protected container forever);
// - the samba container is ADDED when network sharing is switched on (R-7b). Sharing is a
// customer-toggled feature, so it can never appear in the golden controller.yaml — but once it
// IS on, a dead sharing service is exactly as customer-visible as a dead traefik and must raise
// the same protected-container issue → alert → Hungarian degradation e-mail. When sharing is
// off the container is absent from the set, so a box that never enabled it stays quiet.
//
// The bring-up applies the same conditions (stacks.EnsureBaseStack for cloudflared, ensureSamba's
// `if !smb.Enabled { return }` for samba), so detection and deployment agree in both directions.
//
// NOTE: the entries are CONTAINER names (checkProtectedContainers docker-inspects them). For the
// base stacks the container name happens to equal the stack name; for samba it does NOT — the stack
// is „samba" but the container is infra.SambaContainerName — which is why the constant is read here
// rather than the stack name assumed.
func EffectiveProtected(cfg *config.Config, smb settings.SMBSettings) []string {
out := make([]string, 0, len(cfg.Stacks.Protected)+1)
for _, name := range cfg.Stacks.Protected {
if name == "cloudflared" && cfg.Infrastructure.CFTunnelToken == "" {
continue
}
out = append(out, name)
}
if smb.Enabled {
out = append(out, infra.SambaContainerName)
}
return out
}