88 common "github.com/openshift/hypershift-oadp-plugin/pkg/common"
99 plugtypes "github.com/openshift/hypershift-oadp-plugin/pkg/core/types"
1010 validation "github.com/openshift/hypershift-oadp-plugin/pkg/core/validation"
11+ "github.com/openshift/hypershift-oadp-plugin/pkg/etcdbackup"
1112 "github.com/openshift/hypershift-oadp-plugin/pkg/platform/agent"
1213 hyperv1 "github.com/openshift/hypershift/api/hypershift/v1beta1"
1314 "github.com/sirupsen/logrus"
@@ -31,6 +32,12 @@ type BackupPlugin struct {
3132 validator validation.BackupValidator
3233 hcp * hyperv1.HostedControlPlane
3334 * plugtypes.BackupOptions
35+
36+ // Etcd backup orchestration
37+ etcdOrchestrator * etcdbackup.Orchestrator
38+ hoNamespace string
39+ etcdBackupMethod string
40+ etcdSnapshotURL string // populated after HCPEtcdBackup completes
3441}
3542
3643// NewBackupPlugin instantiates BackupPlugin.
@@ -71,12 +78,27 @@ func NewBackupPlugin(logger logrus.FieldLogger) (*BackupPlugin, error) {
7178 Client : client ,
7279 }
7380
81+ hoNamespace := common .DefaultHONamespace
82+ if v , ok := pluginConfig .Data [common .ConfigKeyHONamespace ]; ok && v != "" {
83+ hoNamespace = v
84+ }
85+
86+ etcdBackupMethod := common .EtcdBackupMethodVolume
87+ if v , ok := pluginConfig .Data [common .ConfigKeyEtcdBackupMethod ]; ok && v != "" {
88+ etcdBackupMethod = v
89+ }
90+ if etcdBackupMethod != common .EtcdBackupMethodVolume && etcdBackupMethod != common .EtcdBackupMethodEtcdSnapshot {
91+ return nil , fmt .Errorf ("invalid etcdBackupMethod %q: must be %q or %q" , etcdBackupMethod , common .EtcdBackupMethodVolume , common .EtcdBackupMethodEtcdSnapshot )
92+ }
93+
7494 bp := & BackupPlugin {
75- log : logger ,
76- client : client ,
77- config : pluginConfig .Data ,
78- ctx : ctx ,
79- validator : validator ,
95+ log : logger ,
96+ client : client ,
97+ config : pluginConfig .Data ,
98+ ctx : ctx ,
99+ validator : validator ,
100+ hoNamespace : hoNamespace ,
101+ etcdBackupMethod : etcdBackupMethod ,
80102 }
81103
82104 if bp .BackupOptions , err = bp .validator .ValidatePluginConfig (bp .config ); err != nil {
@@ -119,7 +141,6 @@ func (p *BackupPlugin) Execute(item runtime.Unstructured, backup *velerov1.Backu
119141 }
120142 return nil , nil , fmt .Errorf ("error getting HCP namespace: %v" , err )
121143 }
122-
123144 }
124145
125146 kind := item .GetObjectKind ().GroupVersionKind ().Kind
@@ -134,6 +155,16 @@ func (p *BackupPlugin) Execute(item runtime.Unstructured, backup *velerov1.Backu
134155 return nil , nil , fmt .Errorf ("error checking platform configuration: %v" , err )
135156 }
136157
158+ // Etcd backup: create after validation, wait for completion
159+ if p .etcdBackupMethod == common .EtcdBackupMethodEtcdSnapshot {
160+ if err := p .createEtcdBackup (ctx , backup ); err != nil {
161+ return nil , nil , fmt .Errorf ("error creating HCPEtcdBackup: %v" , err )
162+ }
163+ }
164+ if err := p .waitForEtcdBackupCompletion (ctx ); err != nil {
165+ return nil , nil , err
166+ }
167+
137168 case kind == common .HostedClusterKind :
138169 metadata , err := meta .Accessor (item )
139170 if err != nil {
@@ -142,16 +173,53 @@ func (p *BackupPlugin) Execute(item runtime.Unstructured, backup *velerov1.Backu
142173 common .AddAnnotation (metadata , common .HostedClusterRestoredFromBackupAnnotation , "" )
143174 p .log .Infof ("Added restore annotation to HostedCluster %s" , metadata .GetName ())
144175
145- case kind == "Pod" :
146- // In case of FSBackup, we need to add the label to the pod
147- if backup .Spec .DefaultVolumesToFsBackup != nil && ! * backup .Spec .DefaultVolumesToFsBackup {
148- metadata , err := meta .Accessor (item )
149- if err != nil {
150- return nil , nil , fmt .Errorf ("error getting metadata accessor: %v" , err )
176+ // Etcd backup: create if not yet created (HC may arrive before HCP),
177+ // wait for completion, and inject snapshotURL into the HC item.
178+ // Velero captures the item as-is from the API server before the HCPEtcdBackup
179+ // controller updates the HC status with lastSuccessfulEtcdBackupURL.
180+ // We must inject it here so the backed-up HC contains the URL for restore.
181+ if p .etcdBackupMethod == common .EtcdBackupMethodEtcdSnapshot {
182+ if err := p .createEtcdBackup (ctx , backup ); err != nil {
183+ return nil , nil , fmt .Errorf ("error creating HCPEtcdBackup: %v" , err )
151184 }
185+ }
186+ if err := p .waitForEtcdBackupCompletion (ctx ); err != nil {
187+ return nil , nil , err
188+ }
189+ if p .etcdSnapshotURL != "" {
190+ // Persist as annotation so the restore plugin can read it
191+ // (Velero strips status from items during restore)
192+ common .AddAnnotation (metadata , common .EtcdSnapshotURLAnnotation , p .etcdSnapshotURL )
193+ p .log .Infof ("Added etcd snapshot URL annotation to HostedCluster %s: %s" , metadata .GetName (), p .etcdSnapshotURL )
152194
153- if strings .Contains (metadata .GetName (), "etcd-" ) {
154- common .AddLabel (metadata , common .FSBackupLabelName , "true" )
195+ unstructuredContent := item .UnstructuredContent ()
196+ status , ok := unstructuredContent ["status" ].(map [string ]interface {})
197+ if ! ok {
198+ status = map [string ]interface {}{}
199+ unstructuredContent ["status" ] = status
200+ }
201+ status ["lastSuccessfulEtcdBackupURL" ] = p .etcdSnapshotURL
202+ item .SetUnstructuredContent (unstructuredContent )
203+ p .log .Infof ("Injected lastSuccessfulEtcdBackupURL into HostedCluster %s: %s" , metadata .GetName (), p .etcdSnapshotURL )
204+ }
205+
206+ case kind == "Pod" :
207+ metadata , err := meta .Accessor (item )
208+ if err != nil {
209+ return nil , nil , fmt .Errorf ("error getting metadata accessor: %v" , err )
210+ }
211+
212+ if strings .Contains (metadata .GetName (), "etcd-" ) {
213+ switch p .etcdBackupMethod {
214+ case common .EtcdBackupMethodEtcdSnapshot :
215+ // Skip etcd pods entirely, snapshot is handled by HCPEtcdBackup.
216+ // This prevents both FSBackup and CSI VolumeSnapshots of etcd volumes.
217+ p .log .Infof ("Skipping etcd pod %s from backup (using etcdSnapshot method)" , metadata .GetName ())
218+ return nil , nil , nil
219+ case common .EtcdBackupMethodVolume :
220+ if backup .Spec .DefaultVolumesToFsBackup != nil && ! * backup .Spec .DefaultVolumesToFsBackup {
221+ common .AddLabel (metadata , common .FSBackupLabelName , "true" )
222+ }
155223 }
156224 }
157225
@@ -172,7 +240,91 @@ func (p *BackupPlugin) Execute(item runtime.Unstructured, backup *velerov1.Backu
172240 if _ , exists := labels [common .KubevirtRHCOSLabel ]; exists {
173241 return nil , nil , nil
174242 }
243+
244+ // Exclude etcd data PVCs when using etcdSnapshot method.
245+ // PVC names follow the StatefulSet pattern: data-etcd-{index}
246+ if kind == common .PersistentVolumeClaimKind &&
247+ strings .HasPrefix (metadata .GetName (), common .EtcdPVCPrefix ) &&
248+ p .etcdBackupMethod == common .EtcdBackupMethodEtcdSnapshot {
249+ p .log .Infof ("Excluding etcd PVC %s from backup (using etcdSnapshot method)" , metadata .GetName ())
250+ return nil , nil , nil
251+ }
175252 }
176253
177254 return item , nil , nil
178255}
256+
257+ // createEtcdBackup creates an HCPEtcdBackup CR in the HCP namespace.
258+ // It is idempotent: if the orchestrator already created a backup, it returns immediately.
259+ // Requires the HCPEtcdBackup CRD to exist in the cluster (safenet check).
260+ func (p * BackupPlugin ) createEtcdBackup (ctx context.Context , backup * velerov1.Backup ) error {
261+ // Already created by a previous Execute() call
262+ if p .etcdOrchestrator != nil && p .etcdOrchestrator .IsCreated () {
263+ return nil
264+ }
265+
266+ crdExists , err := common .CRDExists (ctx , "hcpetcdbackups.hypershift.openshift.io" , p .client )
267+ if err != nil {
268+ return fmt .Errorf ("failed to check for HCPEtcdBackup CRD: %w" , err )
269+ }
270+ if ! crdExists {
271+ return fmt .Errorf ("etcdBackupMethod is %q but HCPEtcdBackup CRD not found in the cluster" , common .EtcdBackupMethodEtcdSnapshot )
272+ }
273+
274+ oadpNS , err := common .GetCurrentNamespace ()
275+ if err != nil {
276+ return fmt .Errorf ("failed to get OADP namespace: %w" , err )
277+ }
278+
279+ p .etcdOrchestrator = etcdbackup .NewOrchestrator (p .log , p .client , p .hoNamespace , oadpNS )
280+
281+ // Fetch the HostedCluster for encryption config
282+ hc , err := common .GetHostedCluster (ctx , p .client , backup .Spec .IncludedNamespaces , p .hcp .Namespace )
283+ if err != nil {
284+ p .log .Warnf ("Could not find HostedCluster for encryption config: %v" , err )
285+ }
286+
287+ if err := p .etcdOrchestrator .CreateEtcdBackup (ctx , backup , p .hcp .Namespace , hc ); err != nil {
288+ if cleanupErr := p .etcdOrchestrator .CleanupCredentialSecret (ctx ); cleanupErr != nil {
289+ p .log .Warnf ("Failed to cleanup credential Secret after create error: %v" , cleanupErr )
290+ }
291+ return err
292+ }
293+
294+ if err := p .etcdOrchestrator .VerifyInProgress (ctx ); err != nil {
295+ if cleanupErr := p .etcdOrchestrator .CleanupCredentialSecret (ctx ); cleanupErr != nil {
296+ p .log .Warnf ("Failed to cleanup credential Secret after verify error: %v" , cleanupErr )
297+ }
298+ return err
299+ }
300+
301+ return nil
302+ }
303+
304+ // waitForEtcdBackupCompletion waits for the HCPEtcdBackup to finish and cleans up
305+ // the copied credential Secret. Caches the snapshotURL on the plugin struct so it
306+ // is available regardless of item processing order (HC before HCP or vice versa).
307+ // It is a no-op if no etcd backup was created.
308+ func (p * BackupPlugin ) waitForEtcdBackupCompletion (ctx context.Context ) error {
309+ if p .etcdOrchestrator == nil || ! p .etcdOrchestrator .IsCreated () {
310+ return nil
311+ }
312+
313+ // Already completed in a previous Execute() call
314+ if p .etcdSnapshotURL != "" {
315+ return nil
316+ }
317+
318+ snapshotURL , err := p .etcdOrchestrator .WaitForCompletion (ctx )
319+ if err != nil {
320+ return fmt .Errorf ("HCPEtcdBackup failed: %v" , err )
321+ }
322+ p .etcdSnapshotURL = snapshotURL
323+ p .log .Infof ("HCPEtcdBackup completed, snapshotURL: %s" , snapshotURL )
324+
325+ if cleanupErr := p .etcdOrchestrator .CleanupCredentialSecret (ctx ); cleanupErr != nil {
326+ p .log .Warnf ("Failed to cleanup etcd backup credential Secret: %v" , cleanupErr )
327+ }
328+
329+ return nil
330+ }
0 commit comments