Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/elasticsearch/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ stackgraph:
restore:
scaleDownLabelSelector: "app=stackgraph"
loggingConfigConfigMap: logging-config
stsBackupConfigConfigMap: backup-config
zookeeperQuorum: "zookeeper:2181"
job:
image: backup:latest
Expand Down Expand Up @@ -107,6 +108,7 @@ settings:
restore:
scaleDownLabelSelector: "app=settings"
loggingConfigConfigMap: logging-config
stsBackupConfigConfigMap: backup-config
baseUrl: "http://server:7070"
receiverBaseUrl: "http://receiver:7077"
platformVersion: "5.2.0"
Expand Down Expand Up @@ -152,6 +154,7 @@ stackgraph:
restore:
scaleDownLabelSelector: "app=stackgraph"
loggingConfigConfigMap: logging-config
stsBackupConfigConfigMap: backup-config
zookeeperQuorum: "zookeeper:2181"
job:
image: backup:latest
Expand Down Expand Up @@ -192,6 +195,7 @@ settings:
restore:
scaleDownLabelSelector: "app=settings"
loggingConfigConfigMap: logging-config
stsBackupConfigConfigMap: backup-config
baseUrl: "http://server:7070"
receiverBaseUrl: "http://receiver:7077"
platformVersion: "5.2.0"
Expand Down
28 changes: 26 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,7 @@ 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)

var backups []BackupFileInfo
for _, obj := range filteredObjects {
Expand Down Expand Up @@ -298,6 +307,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 +388,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
}
25 changes: 21 additions & 4 deletions cmd/settings/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,15 @@ 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 +47,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,12 +195,14 @@ 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: "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
Expand All @@ -211,17 +216,19 @@ func buildEnvVar(extraEnvVar []corev1.EnvVar, config *config.Config) []corev1.En

// 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: "config-volume", MountPath: "/opt/docker/etc/application_stackstate.conf", SubPath: "application_stackstate.conf"},
{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"})
}
return mounts

return volumeMounts
}

// buildVolumes constructs volumes for the restore job pod
Expand All @@ -237,6 +244,16 @@ func buildVolumes(config *config.Config, defaultMode int32) []corev1.Volume {
},
},
},
{
Name: "config-volume",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: config.Settings.Restore.StsBackupConfigConfigMapName,
},
},
},
},
{
Name: "backup-restore-scripts",
VolumeSource: corev1.VolumeSource{
Expand Down
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
27 changes: 23 additions & 4 deletions cmd/stackgraph/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@ var (
useLatest bool
background bool
skipConfirmation bool
skipStackpacks bool
)

func restoreCmd(globalFlags *config.CLIGlobalFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "restore",
Short: "Restore Stackgraph from a backup archive",
Long: `Restore Stackgraph data from a backup archive stored in S3. Can use --latest or --archive to specify which backup to restore.`,
Long: `Restore Stackgraph 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.StorageIsRequired)
},
Expand All @@ -51,6 +53,7 @@ func restoreCmd(globalFlags *config.CLIGlobalFlags) *cobra.Command {
cmd.Flags().BoolVar(&useLatest, "latest", false, "Restore from the most recent backup")
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(&skipStackpacks, "skip-stackpacks", false, "Skip restoring stackpacks backup")
cmd.MarkFlagsMutuallyExclusive("archive", "latest")
cmd.MarkFlagsOneRequired("archive", "latest")

Expand Down Expand Up @@ -178,7 +181,7 @@ func getLatestBackup(k8sClient *k8s.Client, namespace string, config *config.Con
}

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

if len(filteredObjects) == 0 {
return "", fmt.Errorf("no backups found in bucket %s", bucket)
Expand Down Expand Up @@ -268,20 +271,25 @@ func buildRestoreEnvVars(backupFile string, config *config.Config) []corev1.EnvV
{Name: "FORCE_DELETE", Value: purgeStackgraphDataFlag},
{Name: "BACKUP_STACKGRAPH_BUCKET_NAME", Value: config.Stackgraph.Bucket},
{Name: "BACKUP_STACKGRAPH_S3_PREFIX", Value: config.Stackgraph.S3Prefix},
{Name: "BACKUP_STACKGRAPH_STACKPACKS_S3_PREFIX", Value: config.Stackgraph.StackpacksS3Prefix},
{Name: "BACKUP_STACKGRAPH_MULTIPART_ARCHIVE", Value: strconv.FormatBool(config.Stackgraph.MultipartArchive)},
{Name: "MINIO_ENDPOINT", Value: fmt.Sprintf("%s:%d", storageService.Name, storageService.Port)},
{Name: "ZOOKEEPER_QUORUM", Value: config.Stackgraph.Restore.ZookeeperQuorum},
{Name: "SKIP_STACKPACKS", Value: strconv.FormatBool(skipStackpacks)},
}
}

// buildRestoreVolumeMounts constructs volume mounts for the restore job container
func buildRestoreVolumeMounts() []corev1.VolumeMount {
return []corev1.VolumeMount{
volumeMounts := []corev1.VolumeMount{
{Name: "backup-log", MountPath: "/opt/docker/etc_log"},
{Name: "config-volume", MountPath: "/opt/docker/etc/application_stackstate.conf", SubPath: "application_stackstate.conf"},
{Name: "backup-restore-scripts", MountPath: "/backup-restore-scripts"},
{Name: "minio-keys", MountPath: "/aws-keys"},
{Name: "tmp-data", MountPath: "/tmp-data"},
}

return volumeMounts
}

// buildRestoreInitContainers constructs init containers for the restore job
Expand All @@ -304,7 +312,7 @@ func buildRestoreInitContainers(config *config.Config) []corev1.Container {

// buildRestoreVolumes constructs volumes for the restore job pod
func buildRestoreVolumes(jobName string, config *config.Config, defaultMode int32) []corev1.Volume {
return []corev1.Volume{
volumes := []corev1.Volume{
{
Name: "backup-log",
VolumeSource: corev1.VolumeSource{
Expand All @@ -315,6 +323,16 @@ func buildRestoreVolumes(jobName string, config *config.Config, defaultMode int3
},
},
},
{
Name: "config-volume",
VolumeSource: corev1.VolumeSource{
ConfigMap: &corev1.ConfigMapVolumeSource{
LocalObjectReference: corev1.LocalObjectReference{
Name: config.Stackgraph.Restore.StsBackupConfigConfigMapName,
},
},
},
},
{
Name: "backup-restore-scripts",
VolumeSource: corev1.VolumeSource{
Expand Down Expand Up @@ -343,6 +361,7 @@ func buildRestoreVolumes(jobName string, config *config.Config, defaultMode int3
},
},
}
return volumes
}

// buildRestoreContainers constructs containers for the restore job
Expand Down
49 changes: 47 additions & 2 deletions internal/clients/s3/filter.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package s3

import (
"fmt"
"regexp"
"strings"
"time"

Expand All @@ -19,10 +21,10 @@ type Object struct {
Size int64
}

// FilterBackupObjects filters S3 objects based on whether the archive is split or not
// FilterMultipartBackupObjects filters S3 objects based on whether the archive is split or not
// If it is not multipartArchive, it filters out multipart archives (files ending with .digits)
// Otherwise, it groups multipart archives by base name and sums their sizes
func FilterBackupObjects(objects []s3types.Object, multipartArchive bool) []Object {
func FilterMultipartBackupObjects(objects []s3types.Object, multipartArchive bool) []Object {
if !multipartArchive {
return filterNonMultipart(objects)
}
Expand Down Expand Up @@ -141,6 +143,49 @@ func getBaseName(key string) (string, bool) {
return key, false
}

// FilterByPrefixAndRegex filters objects to only include direct children of the given prefix
// that match the specified regex pattern. It excludes objects in nested subdirectories and
// strips the prefix from the key, returning just the filename portion.
//
// For example, with prefix "backups/" and pattern `^sts-backup-.*\.graph$`:
// - "backups/sts-backup-20240101.graph" -> included, Key becomes "sts-backup-20240101.graph"
// - "backups/other-file.txt" -> excluded (doesn't match pattern)
// - "backups/subdir/sts-backup-20240101.graph" -> excluded (nested)
func FilterByPrefixAndRegex(objects []Object, prefix string, pattern string) ([]Object, error) {
re, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid regex pattern: %w", err)
}

var filtered []Object
for _, obj := range objects {
// Strip the prefix from the key
relativePath := strings.TrimPrefix(obj.Key, prefix)

// Skip if the relative path contains a slash (indicating nested directory)
if strings.Contains(relativePath, "/") {
continue
}

// Skip empty relative paths (the prefix itself)
if relativePath == "" {
continue
}

// Check if the filename matches the regex pattern
if !re.MatchString(relativePath) {
continue
}

filtered = append(filtered, Object{
Key: relativePath,
LastModified: obj.LastModified,
Size: obj.Size,
})
}
return filtered, nil
}

func FilterByCommonPrefix(objects []s3types.CommonPrefix) []Object {
var filteredObjects []Object

Expand Down
Loading
Loading