7863e62f29
Restore recreates an app from its on-drive unit + the guest's own secrets, regenerating nothing. reconcileRestoreSecrets (pure, unit-tested) merges the unit's non-secret env with secrets recovered from the live app.yaml and FAILS CLOSED if a data-encrypting key is unrecoverable (refuse — a PBS whole-guest restore is needed — rather than regenerate and corrupt). Resettable secrets missing → warn + proceed. - backup: RestoreFromRecoveryUnit (manifest -> recover secrets -> gate -> restore volumes -> recreate definition + redeploy w/ re-pull); falls back to volume-only. - seams: RecoverStackSecrets/RecreateStackFromUnit (adapter +encKey), stacks.RedeployFromEnv. Wired into /backup/restore. - tests: gate (refuse/proceed/verbatim) + data_key parsing. Gate + reconcile + data_key parsing unit-tested; capture live-validated (v0.53.1). Full readable-data e2e vs AdventureLog needs the auth-gated dashboard restore — pending. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
269 lines
8.6 KiB
Go
269 lines
8.6 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
|
|
|
|
// RecreateStackFromUnit restores an app's definition from the unit's compose dir into the stack
|
|
// dir, writes app.yaml from fullEnv (encrypting secret fields), and (re-)deploys it via
|
|
// `docker compose up -d`, which re-pulls the pinned image. Secrets are NEVER regenerated.
|
|
RecreateStackFromUnit(name, composeSrcDir string, fullEnv map[string]string) error
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|