Handle unchecked errors (errcheck), fix nil-deref false positives (SA5011), suppress deprecated-but-functional API warnings (SA1019), remove unused code, and use fmt.Fprintf over WriteString(fmt.Sprintf(...)). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
483 lines
12 KiB
Go
483 lines
12 KiB
Go
package strategies
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
btypes "github.com/wild-cloud/wild-central/daemon/internal/backup/types"
|
|
"github.com/wild-cloud/wild-central/daemon/internal/tools"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ConfigStrategy implements backup strategy for app configuration files
|
|
type ConfigStrategy struct {
|
|
dataDir string
|
|
}
|
|
|
|
// NewConfigStrategy creates a new config backup strategy
|
|
func NewConfigStrategy(dataDir string) *ConfigStrategy {
|
|
return &ConfigStrategy{
|
|
dataDir: dataDir,
|
|
}
|
|
}
|
|
|
|
// Name returns the strategy identifier
|
|
func (c *ConfigStrategy) Name() string {
|
|
return "config"
|
|
}
|
|
|
|
// Backup creates a backup of app configuration files, writing results to the plan
|
|
func (c *ConfigStrategy) Backup(plan *btypes.RecoveryPlan, dest btypes.BackupDestination) error {
|
|
entry := plan.GetStrategyEntry("config")
|
|
if entry == nil {
|
|
return fmt.Errorf("no strategy entry for config in plan")
|
|
}
|
|
entry.Status = "backing_up"
|
|
|
|
instancePath := filepath.Join(c.dataDir, "instances", plan.Instance)
|
|
appPath := plan.Source.AppDir
|
|
if !filepath.IsAbs(appPath) {
|
|
appPath = filepath.Join(c.dataDir, appPath)
|
|
}
|
|
|
|
key := fmt.Sprintf("config/%s/%s/%s.tar.gz", plan.Instance, plan.App, plan.Timestamp)
|
|
|
|
// Create tar.gz archive in memory
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
tarWriter := tar.NewWriter(gzWriter)
|
|
|
|
// Files to backup from app directory
|
|
patterns := []string{
|
|
"manifest.yaml",
|
|
"kustomization.yaml",
|
|
"*.yaml",
|
|
}
|
|
|
|
totalSize := int64(0)
|
|
fileCount := 0
|
|
|
|
// Add app files to archive
|
|
for _, pattern := range patterns {
|
|
matches, _ := filepath.Glob(filepath.Join(appPath, pattern))
|
|
for _, file := range matches {
|
|
if err := c.addFileToTar(tarWriter, file, instancePath); err != nil {
|
|
tarWriter.Close()
|
|
gzWriter.Close()
|
|
return fmt.Errorf("failed to add file %s to archive: %w", file, err)
|
|
}
|
|
|
|
info, _ := os.Stat(file)
|
|
if info != nil {
|
|
totalSize += info.Size()
|
|
}
|
|
fileCount++
|
|
}
|
|
}
|
|
|
|
// Add app configuration from config.yaml
|
|
if err := c.addAppConfig(tarWriter, plan.Instance, plan.App); err != nil {
|
|
tarWriter.Close()
|
|
gzWriter.Close()
|
|
return fmt.Errorf("failed to add app config: %w", err)
|
|
}
|
|
fileCount++
|
|
|
|
// Add app secrets from secrets.yaml (optional, might not exist)
|
|
if err := c.addAppSecrets(tarWriter, plan.Instance, plan.App); err == nil {
|
|
fileCount++
|
|
}
|
|
|
|
// Close the archive
|
|
if err := tarWriter.Close(); err != nil {
|
|
gzWriter.Close()
|
|
return fmt.Errorf("failed to close tar: %w", err)
|
|
}
|
|
|
|
if err := gzWriter.Close(); err != nil {
|
|
return fmt.Errorf("failed to close gzip: %w", err)
|
|
}
|
|
|
|
// Upload to destination
|
|
reader := bytes.NewReader(buf.Bytes())
|
|
size, err := dest.Put(key, reader)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to upload config backup: %w", err)
|
|
}
|
|
|
|
// Write results to plan entry
|
|
entry.Backup = map[string]interface{}{
|
|
"location": key,
|
|
"size": size,
|
|
"files": fileCount,
|
|
"format": "tar.gz",
|
|
"totalSize": totalSize,
|
|
}
|
|
entry.Status = "backed_up"
|
|
return nil
|
|
}
|
|
|
|
// Restore restores app configuration from backup using plan-driven coordination
|
|
func (c *ConfigStrategy) Restore(plan *btypes.RecoveryPlan, dest btypes.BackupDestination) error {
|
|
entry := plan.GetStrategyEntry("config")
|
|
if entry == nil {
|
|
return fmt.Errorf("no strategy entry for config in plan")
|
|
}
|
|
entry.Status = "restoring"
|
|
|
|
location, _ := entry.Backup["location"].(string)
|
|
if location == "" {
|
|
return fmt.Errorf("no backup location in config strategy entry")
|
|
}
|
|
|
|
instancePath := filepath.Join(c.dataDir, "instances", plan.Instance)
|
|
|
|
// Determine target app directory (standby)
|
|
targetAppDir := plan.Standby.AppDir
|
|
if !filepath.IsAbs(targetAppDir) {
|
|
targetAppDir = filepath.Join(c.dataDir, targetAppDir)
|
|
}
|
|
|
|
// Get backup from destination
|
|
reader, err := dest.Get(location)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to retrieve config backup: %w", err)
|
|
}
|
|
defer reader.Close()
|
|
|
|
// Decompress and extract
|
|
gzReader, err := gzip.NewReader(reader)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create gzip reader: %w", err)
|
|
}
|
|
defer gzReader.Close()
|
|
|
|
tarReader := tar.NewReader(gzReader)
|
|
|
|
// Create target app directory if it doesn't exist
|
|
if err := os.MkdirAll(targetAppDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create app directory: %w", err)
|
|
}
|
|
|
|
// Extract files
|
|
for {
|
|
header, err := tarReader.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read tar: %w", err)
|
|
}
|
|
|
|
// Determine target path
|
|
targetPath := filepath.Join(instancePath, header.Name)
|
|
|
|
// Security check: ensure path is within instance directory
|
|
if !strings.HasPrefix(targetPath, instancePath) {
|
|
return fmt.Errorf("invalid file path in archive: %s", header.Name)
|
|
}
|
|
|
|
switch header.Typeflag {
|
|
case tar.TypeDir:
|
|
if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil {
|
|
return fmt.Errorf("failed to create directory: %w", err)
|
|
}
|
|
|
|
case tar.TypeReg:
|
|
// Create directory if needed
|
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
|
|
return fmt.Errorf("failed to create parent directory: %w", err)
|
|
}
|
|
|
|
// Handle special files (config.yaml and secrets.yaml)
|
|
if strings.HasSuffix(header.Name, ".config.yaml.part") {
|
|
if err := c.mergeConfig(tarReader, instancePath, plan.App); err != nil {
|
|
return fmt.Errorf("failed to merge config: %w", err)
|
|
}
|
|
} else if strings.HasSuffix(header.Name, ".secrets.yaml.part") {
|
|
if err := c.mergeSecrets(tarReader, instancePath, plan.App); err != nil {
|
|
return fmt.Errorf("failed to merge secrets: %w", err)
|
|
}
|
|
} else {
|
|
file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create file: %w", err)
|
|
}
|
|
|
|
if _, err := io.Copy(file, tarReader); err != nil {
|
|
file.Close()
|
|
return fmt.Errorf("failed to extract file: %w", err)
|
|
}
|
|
file.Close()
|
|
}
|
|
}
|
|
}
|
|
|
|
entry.Restore = map[string]interface{}{
|
|
"appDir": plan.Standby.AppDir,
|
|
}
|
|
entry.Status = "restored"
|
|
return nil
|
|
}
|
|
|
|
// Switch is a no-op for config — config is shared, already merged during restore
|
|
func (c *ConfigStrategy) Switch(plan *btypes.RecoveryPlan) error {
|
|
entry := plan.GetStrategyEntry("config")
|
|
if entry != nil {
|
|
entry.Status = "switched"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Cleanup is a no-op for config — config is shared, not per-color
|
|
func (c *ConfigStrategy) Cleanup(plan *btypes.RecoveryPlan) error {
|
|
entry := plan.GetStrategyEntry("config")
|
|
if entry != nil {
|
|
entry.Status = "cleaned_up"
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Verify checks if a config backup exists and is valid
|
|
func (c *ConfigStrategy) Verify(plan *btypes.RecoveryPlan, dest btypes.BackupDestination) error {
|
|
entry := plan.GetStrategyEntry("config")
|
|
if entry == nil {
|
|
return fmt.Errorf("no strategy entry for config in plan")
|
|
}
|
|
|
|
location, _ := entry.Backup["location"].(string)
|
|
if location == "" {
|
|
return fmt.Errorf("no backup location in config strategy entry")
|
|
}
|
|
|
|
// Check if backup exists
|
|
reader, err := dest.Get(location)
|
|
if err != nil {
|
|
return fmt.Errorf("backup not found in destination: %w", err)
|
|
}
|
|
defer reader.Close()
|
|
|
|
// Verify it's a valid gzip file
|
|
gzReader, err := gzip.NewReader(reader)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid gzip format: %w", err)
|
|
}
|
|
defer gzReader.Close()
|
|
|
|
// Verify it's a valid tar archive with manifest
|
|
tarReader := tar.NewReader(gzReader)
|
|
hasManifest := false
|
|
|
|
for {
|
|
header, err := tarReader.Next()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("invalid tar format: %w", err)
|
|
}
|
|
|
|
if strings.Contains(header.Name, "manifest.yaml") {
|
|
hasManifest = true
|
|
}
|
|
}
|
|
|
|
if !hasManifest {
|
|
return fmt.Errorf("backup missing essential file: manifest.yaml")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// addFileToTar adds a file to the tar archive
|
|
func (c *ConfigStrategy) addFileToTar(tw *tar.Writer, filePath, baseDir string) error {
|
|
file, err := os.Open(filePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
stat, err := file.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
header, err := tar.FileInfoHeader(stat, "")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
relPath, err := filepath.Rel(baseDir, filePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
header.Name = relPath
|
|
|
|
if err := tw.WriteHeader(header); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = io.Copy(tw, file)
|
|
return err
|
|
}
|
|
|
|
// addAppConfig extracts and adds app configuration from config.yaml
|
|
func (c *ConfigStrategy) addAppConfig(tw *tar.Writer, instanceName, appName string) error {
|
|
configPath := tools.GetInstanceConfigPath(c.dataDir, instanceName)
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read config.yaml: %w", err)
|
|
}
|
|
|
|
var config map[string]interface{}
|
|
if err := yaml.Unmarshal(data, &config); err != nil {
|
|
return fmt.Errorf("failed to parse config.yaml: %w", err)
|
|
}
|
|
|
|
// Extract app-specific configuration
|
|
appConfig := make(map[string]interface{})
|
|
if apps, ok := config["apps"].(map[string]interface{}); ok {
|
|
if ac, ok := apps[appName].(map[string]interface{}); ok {
|
|
appConfig = ac
|
|
}
|
|
}
|
|
|
|
configData, err := yaml.Marshal(appConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal app config: %w", err)
|
|
}
|
|
|
|
header := &tar.Header{
|
|
Name: fmt.Sprintf("apps/%s/.config.yaml.part", appName),
|
|
Mode: 0644,
|
|
Size: int64(len(configData)),
|
|
ModTime: time.Now(),
|
|
}
|
|
|
|
if err := tw.WriteHeader(header); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = tw.Write(configData)
|
|
return err
|
|
}
|
|
|
|
// addAppSecrets extracts and adds app secrets from secrets.yaml
|
|
func (c *ConfigStrategy) addAppSecrets(tw *tar.Writer, instanceName, appName string) error {
|
|
secretsPath := filepath.Join(c.dataDir, "instances", instanceName, "secrets.yaml")
|
|
data, err := os.ReadFile(secretsPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var secrets map[string]interface{}
|
|
if err := yaml.Unmarshal(data, &secrets); err != nil {
|
|
return fmt.Errorf("failed to parse secrets.yaml: %w", err)
|
|
}
|
|
|
|
appSecrets := make(map[string]interface{})
|
|
if apps, ok := secrets["apps"].(map[string]interface{}); ok {
|
|
if as, ok := apps[appName].(map[string]interface{}); ok {
|
|
appSecrets = as
|
|
}
|
|
}
|
|
|
|
secretsData, err := yaml.Marshal(appSecrets)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal app secrets: %w", err)
|
|
}
|
|
|
|
header := &tar.Header{
|
|
Name: fmt.Sprintf("apps/%s/.secrets.yaml.part", appName),
|
|
Mode: 0600,
|
|
Size: int64(len(secretsData)),
|
|
ModTime: time.Now(),
|
|
}
|
|
|
|
if err := tw.WriteHeader(header); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = tw.Write(secretsData)
|
|
return err
|
|
}
|
|
|
|
// mergeConfig merges restored config with existing config.yaml
|
|
func (c *ConfigStrategy) mergeConfig(reader io.Reader, instancePath, appName string) error {
|
|
configPath := filepath.Join(instancePath, "config.yaml")
|
|
|
|
restoredData, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var restoredConfig map[string]interface{}
|
|
if err := yaml.Unmarshal(restoredData, &restoredConfig); err != nil {
|
|
return err
|
|
}
|
|
|
|
var config map[string]interface{}
|
|
if data, err := os.ReadFile(configPath); err == nil {
|
|
_ = yaml.Unmarshal(data, &config)
|
|
}
|
|
if config == nil {
|
|
config = make(map[string]interface{})
|
|
}
|
|
|
|
if config["apps"] == nil {
|
|
config["apps"] = make(map[string]interface{})
|
|
}
|
|
apps := config["apps"].(map[string]interface{})
|
|
apps[appName] = restoredConfig
|
|
|
|
output, err := yaml.Marshal(config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(configPath, output, 0644)
|
|
}
|
|
|
|
// mergeSecrets merges restored secrets with existing secrets.yaml
|
|
func (c *ConfigStrategy) mergeSecrets(reader io.Reader, instancePath, appName string) error {
|
|
secretsPath := filepath.Join(instancePath, "secrets.yaml")
|
|
|
|
restoredData, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var restoredSecrets map[string]interface{}
|
|
if err := yaml.Unmarshal(restoredData, &restoredSecrets); err != nil {
|
|
return err
|
|
}
|
|
|
|
var secrets map[string]interface{}
|
|
if data, err := os.ReadFile(secretsPath); err == nil {
|
|
_ = yaml.Unmarshal(data, &secrets)
|
|
}
|
|
if secrets == nil {
|
|
secrets = make(map[string]interface{})
|
|
}
|
|
|
|
if secrets["apps"] == nil {
|
|
secrets["apps"] = make(map[string]interface{})
|
|
}
|
|
apps := secrets["apps"].(map[string]interface{})
|
|
apps[appName] = restoredSecrets
|
|
|
|
output, err := yaml.Marshal(secrets)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(secretsPath, output, 0600)
|
|
}
|