Skip to content

Commit ad15fa1

Browse files
authored
STAC-24549: Handle PARTIAL snapshots in ES restore and add describe command (#25)
- Fix JSON unmarshalling of snapshot failures (was []string, now []SnapshotFailure) - Warn users before restoring PARTIAL snapshots with explicit confirmation - Add --allow-partial flag for non-interactive PARTIAL restore (required with --yes) - Pass partial=true to ES restore API for PARTIAL snapshots to avoid "wasn't fully snapshotted" errors - Add elasticsearch describe command to show snapshot details as pretty JSON
1 parent 9a1c210 commit ad15fa1

10 files changed

Lines changed: 259 additions & 19 deletions

File tree

cmd/elasticsearch/configure_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ func (m *mockESClientForConfigure) IndexExists(_ string) (bool, error) {
9292
return false, fmt.Errorf("not implemented")
9393
}
9494

95-
func (m *mockESClientForConfigure) RestoreSnapshot(_, _, _ string) error {
95+
func (m *mockESClientForConfigure) RestoreSnapshot(_, _, _ string, _ bool) error {
9696
return fmt.Errorf("not implemented")
9797
}
9898

cmd/elasticsearch/describe.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package elasticsearch
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
8+
"github.com/spf13/cobra"
9+
"github.com/stackvista/stackstate-backup-cli/cmd/cmdutils"
10+
"github.com/stackvista/stackstate-backup-cli/internal/app"
11+
"github.com/stackvista/stackstate-backup-cli/internal/foundation/config"
12+
"github.com/stackvista/stackstate-backup-cli/internal/orchestration/portforward"
13+
)
14+
15+
var describeSnapshotName string
16+
17+
func describeCmd(globalFlags *config.CLIGlobalFlags) *cobra.Command {
18+
cmd := &cobra.Command{
19+
Use: "describe",
20+
Short: "Show detailed information about an Elasticsearch snapshot",
21+
Run: func(_ *cobra.Command, _ []string) {
22+
cmdutils.Run(globalFlags, runDescribeSnapshot, cmdutils.StorageIsRequired)
23+
},
24+
}
25+
26+
cmd.Flags().StringVarP(&describeSnapshotName, "snapshot", "s", "", "Snapshot name to describe")
27+
_ = cmd.MarkFlagRequired("snapshot")
28+
29+
return cmd
30+
}
31+
32+
func runDescribeSnapshot(appCtx *app.Context) error {
33+
serviceName := appCtx.Config.Elasticsearch.Service.Name
34+
remotePort := appCtx.Config.Elasticsearch.Service.Port
35+
36+
pf, err := portforward.SetupPortForward(appCtx.K8sClient, appCtx.Namespace, serviceName, remotePort, appCtx.Logger)
37+
if err != nil {
38+
return err
39+
}
40+
defer close(pf.StopChan)
41+
42+
esClient, err := appCtx.NewESClient(pf.LocalPort)
43+
if err != nil {
44+
return fmt.Errorf("failed to create Elasticsearch client: %w", err)
45+
}
46+
47+
repository := appCtx.Config.Elasticsearch.Restore.Repository
48+
appCtx.Logger.Infof("Fetching snapshot '%s' from repository '%s'...", describeSnapshotName, repository)
49+
50+
snapshot, err := esClient.GetSnapshot(repository, describeSnapshotName)
51+
if err != nil {
52+
return fmt.Errorf("failed to get snapshot: %w", err)
53+
}
54+
55+
data, err := json.MarshalIndent(snapshot, "", " ")
56+
if err != nil {
57+
return fmt.Errorf("failed to marshal snapshot: %w", err)
58+
}
59+
60+
_, err = fmt.Fprintln(os.Stdout, string(data))
61+
return err
62+
}

cmd/elasticsearch/describe_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package elasticsearch
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stackvista/stackstate-backup-cli/internal/foundation/config"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestDescribeCmd_Unit(t *testing.T) {
12+
flags := config.NewCLIGlobalFlags()
13+
flags.Namespace = testNamespace
14+
flags.ConfigMapName = testConfigMapName
15+
cmd := describeCmd(flags)
16+
17+
assert.Equal(t, "describe", cmd.Use)
18+
assert.Equal(t, "Show detailed information about an Elasticsearch snapshot", cmd.Short)
19+
assert.NotNil(t, cmd.Run)
20+
21+
snapshotFlag := cmd.Flags().Lookup("snapshot")
22+
require.NotNil(t, snapshotFlag)
23+
assert.Equal(t, "s", snapshotFlag.Shorthand)
24+
25+
// Verify snapshot flag is required
26+
annotations := snapshotFlag.Annotations
27+
require.Contains(t, annotations, "cobra_annotation_bash_completion_one_required_flag")
28+
}

cmd/elasticsearch/elasticsearch.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ func Cmd(globalFlags *config.CLIGlobalFlags) *cobra.Command {
1313

1414
cmd.AddCommand(listCmd(globalFlags))
1515
cmd.AddCommand(listIndicesCmd(globalFlags))
16+
cmd.AddCommand(describeCmd(globalFlags))
1617
cmd.AddCommand(restoreCmd(globalFlags))
1718
cmd.AddCommand(checkAndFinalizeCmd(globalFlags))
1819
cmd.AddCommand(configureCmd(globalFlags))

cmd/elasticsearch/list_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ func (m *mockESClient) IndexExists(_ string) (bool, error) {
263263
return false, fmt.Errorf("not implemented")
264264
}
265265

266-
func (m *mockESClient) RestoreSnapshot(_, _, _ string) error {
266+
func (m *mockESClient) RestoreSnapshot(_, _, _ string, _ bool) error {
267267
return fmt.Errorf("not implemented")
268268
}
269269

cmd/elasticsearch/restore.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ var (
3030
useLatest bool
3131
runBackground bool
3232
skipConfirmation bool
33+
allowPartial bool
3334
)
3435

3536
func restoreCmd(globalFlags *config.CLIGlobalFlags) *cobra.Command {
@@ -45,6 +46,7 @@ func restoreCmd(globalFlags *config.CLIGlobalFlags) *cobra.Command {
4546
cmd.Flags().BoolVar(&useLatest, "latest", false, "Restore from the most recent snapshot (mutually exclusive with --snapshot)")
4647
cmd.Flags().BoolVar(&runBackground, "background", false, "Run restore in background without waiting for completion")
4748
cmd.Flags().BoolVarP(&skipConfirmation, "yes", "y", false, "Skip confirmation prompt")
49+
cmd.Flags().BoolVar(&allowPartial, "allow-partial", false, "Allow restoring from a PARTIAL snapshot without extra confirmation")
4850
cmd.MarkFlagsMutuallyExclusive("snapshot", "latest")
4951
cmd.MarkFlagsOneRequired("snapshot", "latest")
5052
return cmd
@@ -81,13 +83,25 @@ func runRestore(appCtx *app.Context) error {
8183
appCtx.Logger.Successf("Latest snapshot found: %s", selectedSnapshot)
8284
}
8385

86+
// Fetch snapshot details to check state
87+
snapshotDetails, err := esClient.GetSnapshot(repository, selectedSnapshot)
88+
if err != nil {
89+
return fmt.Errorf("failed to get snapshot details: %w", err)
90+
}
91+
92+
// Validate snapshot state
93+
if err := validateSnapshotState(snapshotDetails, appCtx, skipConfirmation, allowPartial); err != nil {
94+
return err
95+
}
96+
8497
// Confirm with user before starting destructive operation
8598
if !skipConfirmation {
8699
appCtx.Logger.Println()
87100
appCtx.Logger.Warningf("WARNING: Restoring from snapshot will DELETE all existing STS indices!")
88101
appCtx.Logger.Warningf("This operation cannot be undone.")
89102
appCtx.Logger.Println()
90103
appCtx.Logger.Infof("Snapshot to restore: %s", selectedSnapshot)
104+
appCtx.Logger.Infof("Snapshot state: %s", snapshotDetails.State)
91105
appCtx.Logger.Infof("Namespace: %s", appCtx.Namespace)
92106
appCtx.Logger.Println()
93107

@@ -119,8 +133,9 @@ func runRestore(appCtx *app.Context) error {
119133

120134
// Trigger async restore
121135
appCtx.Logger.Println()
136+
isPartial := snapshotDetails.State == "PARTIAL"
122137
appCtx.Logger.Infof("Triggering restore for snapshot: %s", selectedSnapshot)
123-
if err := esClient.RestoreSnapshot(repository, selectedSnapshot, appCtx.Config.Elasticsearch.Restore.IndicesPattern); err != nil {
138+
if err := esClient.RestoreSnapshot(repository, selectedSnapshot, appCtx.Config.Elasticsearch.Restore.IndicesPattern, isPartial); err != nil {
124139
return fmt.Errorf("failed to trigger restore: %w", err)
125140
}
126141
appCtx.Logger.Successf("Restore triggered successfully")
@@ -152,6 +167,39 @@ func getLatestSnapshot(esClient es.Interface, repository string) (string, error)
152167
return snapshots[0].Snapshot, nil
153168
}
154169

170+
// validateSnapshotState checks the snapshot state and handles PARTIAL snapshots
171+
func validateSnapshotState(snapshot *es.Snapshot, appCtx *app.Context, skipConfirm, allowPartialFlag bool) error {
172+
switch snapshot.State {
173+
case es.StatusSuccess:
174+
return nil
175+
case "PARTIAL":
176+
failureCount := len(snapshot.Failures)
177+
if allowPartialFlag {
178+
appCtx.Logger.Warningf("Snapshot '%s' is PARTIAL (%d shard failure(s)), proceeding due to --allow-partial flag",
179+
snapshot.Snapshot, failureCount)
180+
return nil
181+
}
182+
if skipConfirm {
183+
return fmt.Errorf("snapshot '%s' is PARTIAL with %d shard failure(s); "+
184+
"use --allow-partial together with --yes to restore a partial snapshot non-interactively",
185+
snapshot.Snapshot, failureCount)
186+
}
187+
// Interactive mode: warn and ask for explicit confirmation
188+
appCtx.Logger.Println()
189+
appCtx.Logger.Warningf("WARNING: Snapshot '%s' is in PARTIAL state!", snapshot.Snapshot)
190+
appCtx.Logger.Warningf(" %d shard(s) failed out of %d total (%d successful)",
191+
snapshot.Shards.Failed, snapshot.Shards.Total, snapshot.Shards.Successful)
192+
appCtx.Logger.Warningf("Restoring this snapshot will result in incomplete data for the failed shards.")
193+
appCtx.Logger.Println()
194+
if !restore.PromptForConfirmation() {
195+
return fmt.Errorf("restore operation cancelled by user")
196+
}
197+
return nil
198+
default:
199+
return fmt.Errorf("snapshot '%s' is in %s state and cannot be restored", snapshot.Snapshot, snapshot.State)
200+
}
201+
}
202+
155203
// deleteAllSTSIndices deletes all STS indices including datastream rollover if needed
156204
func deleteAllSTSIndices(esClient es.Interface, appCtx *app.Context) error {
157205
appCtx.Logger.Infof("Fetching current Elasticsearch indices...")

cmd/elasticsearch/restore_test.go

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"testing"
66
"time"
77

8+
"github.com/stackvista/stackstate-backup-cli/internal/app"
89
"github.com/stackvista/stackstate-backup-cli/internal/clients/elasticsearch"
910
"github.com/stackvista/stackstate-backup-cli/internal/foundation/config"
11+
"github.com/stackvista/stackstate-backup-cli/internal/foundation/logger"
1012
"github.com/stretchr/testify/assert"
1113
"github.com/stretchr/testify/require"
1214
)
@@ -60,7 +62,7 @@ func (m *mockESClientForRestore) IndexExists(index string) (bool, error) {
6062
return exists, nil
6163
}
6264

63-
func (m *mockESClientForRestore) RestoreSnapshot(_, snapshotName, _ string) error {
65+
func (m *mockESClientForRestore) RestoreSnapshot(_, snapshotName, _ string, _ bool) error {
6466
if m.restoreErr != nil {
6567
return m.restoreErr
6668
}
@@ -348,7 +350,7 @@ func TestMockESClientForRestore(t *testing.T) {
348350
}
349351

350352
// Test restore
351-
err := mockClient.RestoreSnapshot("backup-repo", "test-snapshot", "sts_*")
353+
err := mockClient.RestoreSnapshot("backup-repo", "test-snapshot", "sts_*", false)
352354
if tt.expectRestoreOK {
353355
assert.NoError(t, err)
354356
assert.Equal(t, "test-snapshot", mockClient.restoredSnapshot)
@@ -430,6 +432,94 @@ func TestRestoreSnapshot_Integration(t *testing.T) {
430432
assert.Equal(t, 3, len(snapshot.Indices))
431433
}
432434

435+
// TestRestoreCmd_AllowPartialFlag tests that the allow-partial flag is registered
436+
func TestRestoreCmd_AllowPartialFlag(t *testing.T) {
437+
flags := config.NewCLIGlobalFlags()
438+
flags.Namespace = testNamespace
439+
flags.ConfigMapName = testConfigMapName
440+
cmd := restoreCmd(flags)
441+
442+
allowPartialFlag := cmd.Flags().Lookup("allow-partial")
443+
require.NotNil(t, allowPartialFlag)
444+
assert.Equal(t, "false", allowPartialFlag.DefValue)
445+
}
446+
447+
// TestValidateSnapshotState tests snapshot state validation logic
448+
func TestValidateSnapshotState(t *testing.T) {
449+
appCtx := &app.Context{
450+
Logger: logger.New(false, false),
451+
}
452+
453+
tests := []struct {
454+
name string
455+
state string
456+
failures []elasticsearch.SnapshotFailure
457+
shards struct{ Total, Failed, Successful int }
458+
skipConfirm bool
459+
allowPartial bool
460+
expectErr bool
461+
errContains string
462+
}{
463+
{
464+
name: "SUCCESS state passes",
465+
state: "SUCCESS",
466+
},
467+
{
468+
name: "PARTIAL with --allow-partial passes",
469+
state: "PARTIAL",
470+
failures: []elasticsearch.SnapshotFailure{{Index: "idx1", Reason: "conn refused"}},
471+
allowPartial: true,
472+
},
473+
{
474+
name: "PARTIAL with --yes but no --allow-partial fails",
475+
state: "PARTIAL",
476+
failures: []elasticsearch.SnapshotFailure{{Index: "idx1", Reason: "conn refused"}},
477+
skipConfirm: true,
478+
expectErr: true,
479+
errContains: "use --allow-partial together with --yes",
480+
},
481+
{
482+
name: "FAILED state is rejected",
483+
state: "FAILED",
484+
expectErr: true,
485+
errContains: "FAILED state and cannot be restored",
486+
},
487+
{
488+
name: "IN_PROGRESS state is rejected",
489+
state: "IN_PROGRESS",
490+
expectErr: true,
491+
errContains: "IN_PROGRESS state and cannot be restored",
492+
},
493+
}
494+
495+
for _, tt := range tests {
496+
t.Run(tt.name, func(t *testing.T) {
497+
snapshot := &elasticsearch.Snapshot{
498+
Snapshot: "test-snapshot",
499+
State: tt.state,
500+
Failures: tt.failures,
501+
Shards: struct {
502+
Total int `json:"total"`
503+
Failed int `json:"failed"`
504+
Successful int `json:"successful"`
505+
}{
506+
Total: tt.shards.Total,
507+
Failed: tt.shards.Failed,
508+
Successful: tt.shards.Successful,
509+
},
510+
}
511+
512+
err := validateSnapshotState(snapshot, appCtx, tt.skipConfirm, tt.allowPartial)
513+
if tt.expectErr {
514+
require.Error(t, err)
515+
assert.Contains(t, err.Error(), tt.errContains)
516+
} else {
517+
assert.NoError(t, err)
518+
}
519+
})
520+
}
521+
}
522+
433523
// TestRestoreConstants tests the restore command constants
434524
func TestRestoreConstants(t *testing.T) {
435525
assert.Equal(t, 30, defaultMaxIndexDeleteAttempts)

internal/clients/elasticsearch/client.go

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,29 @@ type IndexInfo struct {
3838
DatasetSize string `json:"dataset.size"`
3939
}
4040

41+
// SnapshotFailure represents a shard-level failure in an Elasticsearch snapshot
42+
type SnapshotFailure struct {
43+
Index string `json:"index"`
44+
IndexUUID string `json:"index_uuid"`
45+
ShardID int `json:"shard_id"`
46+
Reason string `json:"reason"`
47+
NodeID string `json:"node_id"`
48+
Status string `json:"status"`
49+
}
50+
4151
// Snapshot represents an Elasticsearch snapshot
4252
type Snapshot struct {
43-
Snapshot string `json:"snapshot"`
44-
UUID string `json:"uuid"`
45-
Repository string `json:"repository"`
46-
State string `json:"state"`
47-
StartTime string `json:"start_time"`
48-
StartTimeMillis int64 `json:"start_time_in_millis"`
49-
EndTime string `json:"end_time"`
50-
EndTimeMillis int64 `json:"end_time_in_millis"`
51-
DurationInMillis int64 `json:"duration_in_millis"`
52-
Indices []string `json:"indices"`
53-
Failures []string `json:"failures"`
53+
Snapshot string `json:"snapshot"`
54+
UUID string `json:"uuid"`
55+
Repository string `json:"repository"`
56+
State string `json:"state"`
57+
StartTime string `json:"start_time"`
58+
StartTimeMillis int64 `json:"start_time_in_millis"`
59+
EndTime string `json:"end_time"`
60+
EndTimeMillis int64 `json:"end_time_in_millis"`
61+
DurationInMillis int64 `json:"duration_in_millis"`
62+
Indices []string `json:"indices"`
63+
Failures []SnapshotFailure `json:"failures"`
5464
Shards struct {
5565
Total int `json:"total"`
5666
Failed int `json:"failed"`
@@ -350,9 +360,10 @@ func (c *Client) ConfigureSLMPolicy(name, schedule, snapshotName, repository, in
350360
// RestoreSnapshot restores a snapshot from a repository asynchronously
351361
// The restore is triggered and returns immediately (waitForCompletion=false)
352362
// Use GetRestoreStatus to check the progress of the restore operation
353-
func (c *Client) RestoreSnapshot(repository, snapshotName, indicesPattern string) error {
363+
func (c *Client) RestoreSnapshot(repository, snapshotName, indicesPattern string, partial bool) error {
354364
body := map[string]interface{}{
355365
"indices": indicesPattern,
366+
"partial": partial,
356367
}
357368

358369
bodyJSON, err := json.Marshal(body)

internal/clients/elasticsearch/client_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ func TestClient_RestoreSnapshot(t *testing.T) {
400400
require.NoError(t, err)
401401

402402
// Execute test
403-
err = client.RestoreSnapshot(tt.repository, tt.snapshotName, tt.indicesPattern)
403+
err = client.RestoreSnapshot(tt.repository, tt.snapshotName, tt.indicesPattern, false)
404404

405405
// Assertions
406406
if tt.expectError {

0 commit comments

Comments
 (0)