Skip to content
33 changes: 31 additions & 2 deletions cmd/settings/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"regexp"
"slices"
"sort"
"strconv"
Expand All @@ -29,6 +30,7 @@ const (
isMultiPartArchive = false
expectedListJobPodCount = 1
expectedListJobContainerCount = 1
backupFileNameRegex = `^sts-backup-.*\.sty$`
)

// Shared flag for --from-old-pvc, used by both list and restore commands
Expand Down Expand Up @@ -182,7 +184,14 @@ func getBackupListFromS3(appCtx *app.Context) ([]BackupFileInfo, error) {
}

// Filter objects based on whether the archive is split or not
filteredObjects := s3client.FilterBackupObjects(result.Contents, isMultiPartArchive)
filteredObjects := s3client.FilterMultipartBackupObjects(result.Contents, isMultiPartArchive)

// Filter to only include direct children of the prefix that match the backup filename pattern,
// and strip the prefix from the key
filteredObjects, err = s3client.FilterByPrefixAndRegex(filteredObjects, prefix, backupFileNameRegex)
if err != nil {
return nil, fmt.Errorf("failed to filter objects: %w", err)
}

var backups []BackupFileInfo
for _, obj := range filteredObjects {
Expand Down Expand Up @@ -229,7 +238,12 @@ func getBackupListFromLocalBucket(appCtx *app.Context) ([]BackupFileInfo, error)
return nil, fmt.Errorf("failed to list objects in local bucket: %w", err)
}

filteredObjects := s3client.FilterBackupObjects(result.Contents, isMultiPartArchive)
filteredObjects := s3client.FilterMultipartBackupObjects(result.Contents, isMultiPartArchive)

filteredObjects, err = s3client.FilterByPrefixAndRegex(filteredObjects, "", backupFileNameRegex)
if err != nil {
return nil, fmt.Errorf("failed to filter objects: %w", err)
}

var backups []BackupFileInfo
for _, obj := range filteredObjects {
Expand Down Expand Up @@ -298,6 +312,9 @@ func getBackupListFromPVC(appCtx *app.Context) ([]BackupFileInfo, error) {
return nil, fmt.Errorf("failed to parse list job output: %w", err)
}

// Filter by backup filename pattern
files = filterBackupsByRegex(files, backupFileNameRegex)

return files, nil
}

Expand Down Expand Up @@ -376,3 +393,15 @@ func ParseListJobOutput(input string) ([]BackupFileInfo, error) {

return files, nil
}

// filterBackupsByRegex filters BackupFileInfo by matching filename against a regex pattern
func filterBackupsByRegex(backups []BackupFileInfo, pattern string) []BackupFileInfo {
re := regexp.MustCompile(pattern)
var filtered []BackupFileInfo
for _, b := range backups {
if re.MatchString(b.Filename) {
filtered = append(filtered, b)
}
}
return filtered
}
43 changes: 36 additions & 7 deletions cmd/settings/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package settings
import (
"fmt"
"strconv"
"strings"
"time"

"github.com/spf13/cobra"
Expand All @@ -28,13 +29,16 @@ var (
useLatest bool
background bool
skipConfirmation bool
skipStackpacks bool
)

func restoreCmd(globalFlags *config.CLIGlobalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "restore",
Short: "Restore Settings from a backup archive",
Long: `Restore Settings data from a backup archive stored in S3. Can use --latest or --archive to specify which backup to restore.`,
Long: "Restore Settings data from a backup archive stored in S3. Automatically also restores " +
"Stackpacks backup that was made at the same time, it can be skipped with --skip-stackpacks. " +
"Can use --latest or --archive to specify which backup to restore.",
Run: func(_ *cobra.Command, _ []string) {
cmdutils.Run(globalFlags, runRestore, cmdutils.StorageIsNotRequired)
},
Expand All @@ -45,6 +49,7 @@ func restoreCmd(globalFlags *config.CLIGlobalFlags) *cobra.Command {
cmd.Flags().BoolVar(&background, "background", false, "Run restore job in background without waiting for completion")
cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt")
cmd.Flags().BoolVar(&fromPVC, "from-old-pvc", false, "Restore backup from legacy PVC instead of S3")
cmd.Flags().BoolVar(&skipStackpacks, "skip-stackpacks", false, "Skip restoring stackpacks backup")
cmd.MarkFlagsMutuallyExclusive("archive", "latest")
cmd.MarkFlagsOneRequired("archive", "latest")

Expand Down Expand Up @@ -192,36 +197,49 @@ func buildEnvVar(extraEnvVar []corev1.EnvVar, config *config.Config) []corev1.En
commonVar := []corev1.EnvVar{
{Name: "BACKUP_CONFIGURATION_BUCKET_NAME", Value: config.Settings.Bucket},
{Name: "BACKUP_CONFIGURATION_S3_PREFIX", Value: config.Settings.S3Prefix},
{Name: "BACKUP_CONFIGURATION_STACKPACKS_S3_PREFIX", Value: config.Settings.StackpacksS3Prefix},
{Name: "MINIO_ENDPOINT", Value: fmt.Sprintf("%s:%d", storageService.Name, storageService.Port)},
{Name: "STACKSTATE_BASE_URL", Value: config.Settings.Restore.BaseURL},
{Name: "RECEIVER_BASE_URL", Value: config.Settings.Restore.ReceiverBaseURL},
{Name: "PLATFORM_VERSION", Value: config.Settings.Restore.PlatformVersion},
{Name: "STACKSTATE_BASE_URL", Value: config.GetBaseURL()},
{Name: "RECEIVER_BASE_URL", Value: config.GetReceiverBaseURL()},
{Name: "PLATFORM_VERSION", Value: config.GetPlatformVersion()},
{Name: "ZOOKEEPER_QUORUM", Value: config.Settings.Restore.ZookeeperQuorum},
{Name: "BACKUP_CONFIGURATION_UPLOAD_REMOTE", Value: strconv.FormatBool(config.GlobalBackupEnabled())},
{Name: "SKIP_STACKPACKS", Value: strconv.FormatBool(skipStackpacks)},
}
if fromPVC {
// Force PVC mode in the shell script, suppress local bucket
commonVar = append(commonVar, corev1.EnvVar{Name: "BACKUP_RESTORE_FROM_PVC", Value: "true"})
} else if config.Settings.LocalBucket != "" {
commonVar = append(commonVar, corev1.EnvVar{Name: "BACKUP_CONFIGURATION_LOCAL_BUCKET", Value: config.Settings.LocalBucket})
}
if config.Stackpacks != nil {
commonVar = append(commonVar, corev1.EnvVar{Name: "CONFIG_FORCE_stackstate_stackPacks_localStackPacksUri", Value: config.Stackpacks.LocalStackPacksURI})
}
commonVar = append(commonVar, extraEnvVar...)
return commonVar
}

// buildVolumeMounts constructs volume mounts for the restore job container
func buildVolumeMounts(config *config.Config) []corev1.VolumeMount {
mounts := []corev1.VolumeMount{
volumeMounts := []corev1.VolumeMount{
{Name: "backup-log", MountPath: "/opt/docker/etc_log"},
{Name: "backup-restore-scripts", MountPath: "/backup-restore-scripts"},
{Name: "minio-keys", MountPath: "/aws-keys"},
{Name: "tmp-data", MountPath: "/tmp-data"},
}
// Mount PVC in legacy mode or when --from-old-pvc is set
if config.IsLegacyMode() || fromPVC {
mounts = append(mounts, corev1.VolumeMount{Name: "settings-backup-data", MountPath: "/settings-backup-data"})
volumeMounts = append(volumeMounts, corev1.VolumeMount{Name: "settings-backup-data", MountPath: "/settings-backup-data"})
}

if config.Stackpacks != nil && config.Stackpacks.PVC != "" && strings.HasPrefix(config.Stackpacks.LocalStackPacksURI, "file://") {
volumeMounts = append(volumeMounts, corev1.VolumeMount{
Name: "stackpacks-local",
MountPath: strings.TrimPrefix(config.Stackpacks.LocalStackPacksURI, "file://"),
})
}
return mounts

return volumeMounts
}

// buildVolumes constructs volumes for the restore job pod
Expand Down Expand Up @@ -274,6 +292,17 @@ func buildVolumes(config *config.Config, defaultMode int32) []corev1.Volume {
},
})
}
if config.Stackpacks != nil && config.Stackpacks.PVC != "" {
volumes = append(volumes, corev1.Volume{
Name: "stackpacks-local",
VolumeSource: corev1.VolumeSource{
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
ClaimName: config.Stackpacks.PVC,
},
},
})
}

return volumes
}

Expand Down
64 changes: 64 additions & 0 deletions cmd/settings/restore_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package settings

import (
"testing"

"github.com/stackvista/stackstate-backup-cli/internal/foundation/config"
"github.com/stretchr/testify/assert"
)

func TestBuildVolumeMounts_StackpacksLocalFileURI(t *testing.T) {
tests := []struct {
name string
stackpacks *config.StackpacksConfig
expectStackpacks bool
expectedMountPath string
}{
{
name: "no stackpacks config",
stackpacks: nil,
expectStackpacks: false,
},
{
name: "stackpacks with no PVC",
stackpacks: &config.StackpacksConfig{LocalStackPacksURI: "file:///var/stackpacks_local"},
expectStackpacks: false,
},
{
name: "stackpacks with file:// URI and PVC",
stackpacks: &config.StackpacksConfig{LocalStackPacksURI: "file:///var/stackpacks_local", PVC: "stackpacks-pvc"},
expectStackpacks: true,
expectedMountPath: "/var/stackpacks_local",
},
{
name: "stackpacks with non-file URI and PVC",
stackpacks: &config.StackpacksConfig{LocalStackPacksURI: "s3://my-bucket/stackpacks", PVC: "stackpacks-pvc"},
expectStackpacks: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Ensure the package-level fromPVC flag does not interfere
fromPVC = false

cfg := &config.Config{Stackpacks: tt.stackpacks}
mounts := buildVolumeMounts(cfg)

var stackpacksMount *struct{ Name, MountPath string }
for _, m := range mounts {
if m.Name == "stackpacks-local" {
stackpacksMount = &struct{ Name, MountPath string }{m.Name, m.MountPath}
break
}
}

if tt.expectStackpacks {
assert.NotNil(t, stackpacksMount, "expected stackpacks-local volume mount to be present")
assert.Equal(t, tt.expectedMountPath, stackpacksMount.MountPath)
} else {
assert.Nil(t, stackpacksMount, "expected stackpacks-local volume mount to be absent")
}
})
}
}
13 changes: 12 additions & 1 deletion cmd/stackgraph/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import (
"github.com/stackvista/stackstate-backup-cli/internal/orchestration/portforward"
)

const (
backupFileNameRegex = `^sts-backup-.*\.graph$`
)

func listCmd(globalFlags *config.CLIGlobalFlags) *cobra.Command {
return &cobra.Command{
Use: "list",
Expand Down Expand Up @@ -63,7 +67,14 @@ func runList(appCtx *app.Context) error {
}

// Filter objects based on whether the archive is split or not
filteredObjects := s3client.FilterBackupObjects(result.Contents, multipartArchive)
filteredObjects := s3client.FilterMultipartBackupObjects(result.Contents, multipartArchive)

// Filter to only include direct children of the prefix that match the backup filename pattern,
// and strip the prefix from the key
filteredObjects, err = s3client.FilterByPrefixAndRegex(filteredObjects, prefix, backupFileNameRegex)
if err != nil {
return fmt.Errorf("failed to filter objects: %w", err)
}

// Sort by LastModified time (most recent first)
sort.Slice(filteredObjects, func(i, j int) bool {
Expand Down
Loading
Loading