Skip to content

Commit a9afa54

Browse files
jparrillclaude
andcommitted
feat(restore): read snapshotURL from HostedCluster status for etcd restore
Simplify the restore flow by reading lastSuccessfulEtcdBackupURL from the HostedCluster status (persisted by the HCPEtcdBackup controller) instead of relying on the ephemeral HCPEtcdBackup CR. The snapshotURL travels with the HC in the Velero backup tarball, eliminating ordering dependencies and surviving CR retention/deletion. Uses unstructured access to avoid vendor dependency on the field until the hypershift API PRs are merged (CNTRLPLANE-3173). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Juan Manuel Parrilla Madrid <jparrill@redhat.com>
1 parent 1d580b1 commit a9afa54

2 files changed

Lines changed: 90 additions & 11 deletions

File tree

pkg/core/restore.go

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,6 @@ type RestorePlugin struct {
3434
*plugtypes.RestoreOptions
3535
}
3636

37-
type RestoreOptions struct {
38-
// Migration is a flag to indicate if the backup is for migration purposes.
39-
migration bool
40-
// Readopt Nodes is a flag to indicate if the nodes should be reprovisioned or not during restore.
41-
readoptNodes bool
42-
// ManagedServices is a flag to indicate if the backup is done for ManagedServices like ROSA, ARO, etc.
43-
managedServices bool
44-
// AWSRegenPrivateLink is a flag to indicate if the PrivateLink should be regenerated in AWS.
45-
awsRegenPrivateLink bool
46-
}
47-
4837
// NewRestorePlugin instantiates RestorePlugin.
4938
func NewRestorePlugin(logger logrus.FieldLogger) (*RestorePlugin, error) {
5039
var (
@@ -175,6 +164,29 @@ func (p *RestorePlugin) Execute(input *velero.RestoreItemActionExecuteInput) (*v
175164
}
176165
common.AddAnnotation(metadata, common.HostedClusterRestoredFromBackupAnnotation, "")
177166
p.log.Infof("Added restore annotation to HostedCluster %s", metadata.GetName())
167+
168+
// Inject restoreSnapshotURL from the HC status field that was
169+
// persisted by the HCPEtcdBackup controller during backup.
170+
// Read the snapshotURL from unstructured status to avoid vendor
171+
// dependency on the LastSuccessfulEtcdBackupURL field.
172+
snapshotURL := getLastSuccessfulEtcdBackupURL(input.Item.UnstructuredContent())
173+
174+
if snapshotURL != "" {
175+
hc := &hyperv1.HostedCluster{}
176+
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(input.Item.UnstructuredContent(), hc); err != nil {
177+
return nil, fmt.Errorf("error converting item to HostedCluster: %v", err)
178+
}
179+
if hc.Spec.Etcd.Managed != nil {
180+
hc.Spec.Etcd.Managed.Storage.RestoreSnapshotURL = []string{snapshotURL}
181+
p.log.Infof("Injected restoreSnapshotURL into HostedCluster %s: %s", hc.Name, snapshotURL)
182+
183+
unstructuredHC, err := runtime.DefaultUnstructuredConverter.ToUnstructured(hc)
184+
if err != nil {
185+
return nil, fmt.Errorf("error converting HostedCluster to unstructured: %v", err)
186+
}
187+
input.Item.SetUnstructuredContent(unstructuredHC)
188+
}
189+
}
178190
}
179191

180192
case kind == common.ClusterDeploymentKind:
@@ -194,3 +206,17 @@ func (p *RestorePlugin) Execute(input *velero.RestoreItemActionExecuteInput) (*v
194206

195207
return velero.NewRestoreItemActionExecuteOutput(input.Item), nil
196208
}
209+
210+
// getLastSuccessfulEtcdBackupURL reads status.lastSuccessfulEtcdBackupURL from
211+
// an unstructured HostedCluster object. This avoids a vendor dependency on the
212+
// field which may not be present in the vendored API yet.
213+
// TODO(CNTRLPLANE-3173): Replace with hc.Status.LastSuccessfulEtcdBackupURL
214+
// once the hypershift API vendor includes this field.
215+
func getLastSuccessfulEtcdBackupURL(obj map[string]interface{}) string {
216+
status, ok := obj["status"].(map[string]interface{})
217+
if !ok {
218+
return ""
219+
}
220+
url, _ := status["lastSuccessfulEtcdBackupURL"].(string)
221+
return url
222+
}

pkg/core/restore_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package core
2+
3+
import (
4+
"testing"
5+
6+
. "github.com/onsi/gomega"
7+
)
8+
9+
func TestGetLastSuccessfulEtcdBackupURL(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
obj map[string]interface{}
13+
want string
14+
}{
15+
{
16+
name: "When status contains lastSuccessfulEtcdBackupURL, It Should return the URL",
17+
obj: map[string]interface{}{
18+
"status": map[string]interface{}{
19+
"lastSuccessfulEtcdBackupURL": "s3://bucket/backups/test/etcd-backup/123.db",
20+
},
21+
},
22+
want: "s3://bucket/backups/test/etcd-backup/123.db",
23+
},
24+
{
25+
name: "When status has no lastSuccessfulEtcdBackupURL, It Should return empty string",
26+
obj: map[string]interface{}{
27+
"status": map[string]interface{}{
28+
"conditions": []interface{}{},
29+
},
30+
},
31+
want: "",
32+
},
33+
{
34+
name: "When object has no status, It Should return empty string",
35+
obj: map[string]interface{}{},
36+
want: "",
37+
},
38+
{
39+
name: "When status is not a map, It Should return empty string",
40+
obj: map[string]interface{}{
41+
"status": "invalid",
42+
},
43+
want: "",
44+
},
45+
}
46+
47+
for _, tt := range tests {
48+
t.Run(tt.name, func(t *testing.T) {
49+
g := NewWithT(t)
50+
g.Expect(getLastSuccessfulEtcdBackupURL(tt.obj)).To(Equal(tt.want))
51+
})
52+
}
53+
}

0 commit comments

Comments
 (0)