Files
felhom-controller/controller/internal/appbackup/appdata.go
T
admin 78ff991f1c v0.153.0 — R-47: the DB replay no longer races the app, on BOTH restore paths
Closes R-47. No new agent coupling — MinAgent stays 0.90.0.

The replay needs a running DB container, so both restore paths started the
WHOLE stack first, giving the application a window to rebuild the very schema
objects the dump was about to create. Measured live on 2026-07-19 (H4,
DIAG-immich-restore-round2): immich-server rebuilt clip_index two seconds
before the dump's CREATE INDEX, the replay aborted "already exists" under
ON_ERROR_STOP=1, and immich reported schema drift. The data survived only
because pg_dump emits COPY before CREATE INDEX.

Both paths now open a DB-ONLY window: only the stack's database service(s)
come up, the dump is replayed with the app still down, and the full start
runs only after the replay exits 0. Fail-closed: a dump with no identifiable
DB service refuses BEFORE the first mutation. Every exit from the window
still does a best-effort full start, so a failed restore never leaves a box
with a database and no application.

New: appbackup.DBServiceNames (yaml.v3 services-map parse — never a line
scan; immich's top-level volume keys are the decoy) sharing dbTypeForImage
with DiscoverDatabases; stacks.Manager.StartStackServices (refuses an empty
list — argument-less `up -d` is a full start); RedeployFromEnv split into
PersistUnitRedeployConfig + its unchanged tail. StackDataProvider's
RecreateStackFromUnit becomes RecreateStackDefinitionFromUnit — the hidden
`up -d` inside the old name is what carried the defect on the local path.

19 new tests (ordering plus state-at-replay-time, zero-mutation fail-closed
effects, replay-failure bring-up, parser decoys, empty-list refusal); three
companion red-proofs run and reverted. 23/23 packages green.

Not yet live-validated: STOP-1 supervised reconstitute, golden 0.153.0.
2026-07-20 17:01:52 +02:00

282 lines
9.5 KiB
Go

package appbackup
import (
"bufio"
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// StackDataProvider provides stack data to the backup packages without circular imports.
type StackDataProvider interface {
GetStackComposePath(name string) (composePath string, ok bool)
ListDeployedStacks() []StackSummary
GetStackHDDMounts(name string) []string
GetStackHDDPath(name string) string // raw HDD_PATH from app.yaml (empty if no HDD)
GetDockerVolumes(name string) []string // full Docker volume names (project-prefixed)
StopStack(name string) error
StartStack(name string) error
RefreshAndIsRunning(name string) bool
// GetStackRecoveryInfo returns the data needed to capture a SECRET-FREE recovery unit
// (Phase 2): the stack dir, pinned image tags, the non-secret env, and the NAMES of the
// secret/data-key env vars (values are NEVER returned — they are recovered at restore time
// from the guest's own app.yaml, live or via the PBS whole-guest snapshot). ok=false if the
// stack is unknown.
GetStackRecoveryInfo(name string) (RecoveryInfo, bool)
// --- Phase 2b: restore-from-recovery-unit ---
// RecoverStackSecrets returns the live decrypted values for the named secret env vars that are
// currently present (non-empty) in the stack's app.yaml (the guest's own — live rootfs, or
// PBS-restored). Names that are absent/empty are simply omitted from the map; the caller's
// fail-closed gate decides what to do. The unit is never the source of secrets.
RecoverStackSecrets(name string, names []string) map[string]string
// RecreateStackDefinitionFromUnit restores an app's DEFINITION from the unit's compose dir into
// the stack dir and writes app.yaml from fullEnv (encrypting secret fields). Secrets are NEVER
// regenerated. It starts NOTHING: the caller owns the bring-up order, because a DB-bearing app
// must have its database service started alone for the dump replay (R-47). It was
// `RecreateStackFromUnit` until v0.153.0 and ended in a full `docker compose up -d` — that full
// start before the replay IS the H4 race.
RecreateStackDefinitionFromUnit(name, composeSrcDir string, fullEnv map[string]string) error
// StartStackServices brings up ONLY the named compose services, leaving the rest of the stack
// down — the DB-only window in which a dump is replayed without the application racing it.
// Implementations must REFUSE an empty list (an argument-less `up -d` is a full start).
StartStackServices(name string, services []string) error
// GetStackClassifiedBinds returns the app's backup-classified compose binds + whether it carries a
// (valid) backup block (Task 2, referential coupling). INERT — no tier consumes it yet; wired now
// so Task 3 gets a tested seam. Implemented by delegating to stacks.Manager.ClassifiedBinds.
GetStackClassifiedBinds(name string) ([]ClassifiedBind, bool)
}
// RecoveryInfo carries everything needed to write a secret-free recovery unit for a stack.
// It deliberately holds NO secret values — only the names of secret/data-key env vars, so the
// manifest can record what must be recovered from elsewhere (guest app.yaml / PBS) without the
// unit ever storing a secret or a data-encrypting key.
type RecoveryInfo struct {
StackDir string // dir holding docker-compose.yml + .felhom.yml + app.yaml
DisplayName string // app display name
ImagePins []string // pinned image tags from compose `image:` lines (re-pulled on restore)
NonSecretEnv map[string]string // env with all secret/password/data-key values removed (plaintext only)
SecretEnvVars []string // NAMES of stripped secret/password fields (recovered from guest/PBS)
DataKeyEnvVars []string // NAMES of data-encrypting-key fields (fail-closed gate on restore)
}
// ParseComposeImages extracts the pinned image references (`image: repo:tag`) from a
// docker-compose.yml, in file order, de-duplicated. The image bytes are never stored in the
// recovery unit — only these pins, so restore re-pulls from the registry.
func ParseComposeImages(composePath string) []string {
data, err := os.ReadFile(composePath)
if err != nil {
return nil
}
var images []string
seen := make(map[string]bool)
scanner := bufio.NewScanner(strings.NewReader(string(data)))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "image:") {
continue
}
img := strings.TrimSpace(strings.TrimPrefix(line, "image:"))
img = strings.Trim(img, "\"'")
// Skip variable-only images we can't pin (e.g. image: ${SOME_IMAGE})
if img == "" || strings.HasPrefix(img, "${") {
continue
}
if !seen[img] {
seen[img] = true
images = append(images, img)
}
}
return images
}
// StackSummary holds minimal stack info needed for app data discovery.
type StackSummary struct {
Name string
DisplayName string
ComposePath string
NeedsHDD bool
HasVolumes bool
}
// AppBackupInfo holds backup-relevant data paths for a deployed app.
type AppBackupInfo struct {
StackName string
DisplayName string
NeedsHDD bool
HDDPaths []AppDataPath
HDDTotalSize int64
HDDSizeHuman string
DockerVolumes []AppDockerVolume
BackupEnabled bool
HasHDDData bool
HasDBDump bool
HasVolumeData bool
StorageLabel string // resolved from registered storage paths
}
// AppDataPath represents a single HDD bind mount path.
type AppDataPath struct {
HostPath string
Exists bool
SizeHuman string
SizeBytes int64
}
// AppDockerVolume represents a named Docker volume.
type AppDockerVolume struct {
Name string
Contains string
}
// DiscoverAppData discovers backup-relevant data for all deployed apps.
// All apps with HDD data are backed up automatically (mandatory — no opt-in).
func DiscoverAppData(provider StackDataProvider, discoveredDBs []DiscoveredDB) []AppBackupInfo {
if provider == nil {
return nil
}
var result []AppBackupInfo
for _, stack := range provider.ListDeployedStacks() {
info := AppBackupInfo{
StackName: stack.Name,
DisplayName: stack.DisplayName,
NeedsHDD: stack.NeedsHDD,
}
// Discover HDD bind mounts via adapter
hddMounts := provider.GetStackHDDMounts(stack.Name)
for _, mount := range hddMounts {
path := AppDataPath{HostPath: mount}
if fi, err := os.Stat(mount); err == nil && fi.IsDir() {
path.Exists = true
path.SizeBytes, path.SizeHuman = appDirSize(mount)
}
info.HDDPaths = append(info.HDDPaths, path)
info.HDDTotalSize += path.SizeBytes
}
info.HDDSizeHuman = humanizeBytes(info.HDDTotalSize)
info.HasHDDData = len(info.HDDPaths) > 0
// Discover Docker named volumes from compose
info.DockerVolumes = ParseComposeNamedVolumes(stack.ComposePath)
info.HasVolumeData = len(info.DockerVolumes) > 0
// Check if app has a DB container (already backed up via DB dump)
for _, db := range discoveredDBs {
if db.StackName == stack.Name {
info.HasDBDump = true
break
}
}
// All apps with HDD data are backed up automatically (mandatory)
info.BackupEnabled = info.HasHDDData
result = append(result, info)
}
log.Printf("[INFO] [backup] Discovered app data: %d apps", len(result))
return result
}
// ParseComposeNamedVolumes extracts named Docker volumes from a docker-compose.yml.
func ParseComposeNamedVolumes(composePath string) []AppDockerVolume {
data, err := os.ReadFile(composePath)
if err != nil {
return nil
}
var compose struct {
Volumes map[string]interface{} `yaml:"volumes"`
}
if err := yaml.Unmarshal(data, &compose); err != nil {
return nil
}
var volumes []AppDockerVolume
for name, cfg := range compose.Volumes {
// Skip external volumes
if cfgMap, ok := cfg.(map[string]interface{}); ok {
if ext, ok := cfgMap["external"]; ok && ext == true {
continue
}
}
volumes = append(volumes, AppDockerVolume{Name: name})
}
return volumes
}
// ResolveDockerVolumeNames returns full Docker volume names with the compose project prefix.
// Docker Compose V2 creates volumes as <project>_<volumeName> where project = directory name.
func ResolveDockerVolumeNames(composePath string) []string {
vols := ParseComposeNamedVolumes(composePath)
if len(vols) == 0 {
return nil
}
project := filepath.Base(filepath.Dir(composePath))
names := make([]string, 0, len(vols))
for _, v := range vols {
names = append(names, project+"_"+v.Name)
}
return names
}
// appDirSize returns the total byte count and a human-readable string for a directory.
// H2/H3: Single du invocation with 30s timeout replaces two separate calls.
func appDirSize(path string) (int64, string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "du", "-sb", path)
output, err := cmd.Output()
if err != nil {
return 0, "?"
}
fields := strings.Fields(string(output))
if len(fields) == 0 {
return 0, "?"
}
var size int64
if n, _ := fmt.Sscanf(fields[0], "%d", &size); n != 1 {
return 0, "?"
}
return size, humanizeBytes(size)
}
// HumanizeBytes converts bytes to a human-readable string.
// Exported so the backup package can forward to it (shared helper).
func HumanizeBytes(b int64) string {
return humanizeBytes(b)
}
// humanizeBytes converts bytes to a human-readable string.
func humanizeBytes(b int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case b >= GB:
return fmt.Sprintf("%.1f GB", float64(b)/float64(GB))
case b >= MB:
return fmt.Sprintf("%.1f MB", float64(b)/float64(MB))
case b >= KB:
return fmt.Sprintf("%.1f KB", float64(b)/float64(KB))
default:
return fmt.Sprintf("%d B", b)
}
}