diff --git a/.github/actions/build-custom-image-with-apko/action.yml b/.github/actions/build-custom-image-with-apko/action.yml index d31e09cd5f..3c8eb47947 100644 --- a/.github/actions/build-custom-image-with-apko/action.yml +++ b/.github/actions/build-custom-image-with-apko/action.yml @@ -47,8 +47,10 @@ runs: export GIT_TAG=${{ inputs.git-tag }} envsubst '${GIT_TAG}' < ${{ inputs.context }}/apko.yaml.tmpl > ${{ inputs.context }}/apko.yaml - - uses: chainguard-images/actions/apko-publish@main + # pin to work around https://github.com/chainguard-images/actions/issues/160 + - uses: chainguard-images/actions/apko-publish@49e3bc2feb790da6c3a7f749b38c769174c4ad54 with: + apko-image: ghcr.io/wolfi-dev/sdk@sha256:b3c690e2aff7a6e6259632047b5d6133169204f7011b9936731cf3f403d5a8c1 config: ${{ inputs.context }}/apko.yaml archs: amd64,arm64 tag: ${{ inputs.image-name }} diff --git a/.github/actions/build-dep-image-with-apko/action.yml b/.github/actions/build-dep-image-with-apko/action.yml index 7f234ca742..b10126806f 100644 --- a/.github/actions/build-dep-image-with-apko/action.yml +++ b/.github/actions/build-dep-image-with-apko/action.yml @@ -40,9 +40,11 @@ runs: echo "image-exists=false" >> "$GITHUB_OUTPUT" fi - - uses: chainguard-images/actions/apko-publish@main + # pin to work around https://github.com/chainguard-images/actions/issues/160 + - uses: chainguard-images/actions/apko-publish@49e3bc2feb790da6c3a7f749b38c769174c4ad54 if: ${{ inputs.overwrite == 'true' || steps.check-image-exists.outputs.image-exists == 'false' }} with: + apko-image: ghcr.io/wolfi-dev/sdk@sha256:b3c690e2aff7a6e6259632047b5d6133169204f7011b9936731cf3f403d5a8c1 config: ${{ inputs.apko-config }} archs: amd64,arm64 tag: ${{ inputs.image-name }} diff --git a/pkg/handlers/backup.go b/pkg/handlers/backup.go index 26ca52925d..8a39ae2976 100644 --- a/pkg/handlers/backup.go +++ b/pkg/handlers/backup.go @@ -130,22 +130,22 @@ func (h *Handler) ListInstanceBackups(w http.ResponseWriter, r *http.Request) { } type GetBackupResponse struct { - BackupDetail *snapshottypes.BackupDetail `json:"backupDetail"` - Success bool `json:"success"` - Error string `json:"error,omitempty"` + BackupDetails []snapshottypes.BackupDetail `json:"backupDetails"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` } func (h *Handler) GetBackup(w http.ResponseWriter, r *http.Request) { getBackupResponse := GetBackupResponse{} - backup, err := snapshot.GetBackupDetail(r.Context(), util.PodNamespace, mux.Vars(r)["snapshotName"]) + backups, err := snapshot.GetBackupDetail(r.Context(), util.PodNamespace, mux.Vars(r)["snapshotName"]) if err != nil { logger.Error(err) getBackupResponse.Error = "failed to get backup detail" JSON(w, 500, getBackupResponse) return } - getBackupResponse.BackupDetail = backup + getBackupResponse.BackupDetails = backups getBackupResponse.Success = true diff --git a/pkg/kotsadmsnapshot/backup.go b/pkg/kotsadmsnapshot/backup.go index a5736af297..6329d806a2 100644 --- a/pkg/kotsadmsnapshot/backup.go +++ b/pkg/kotsadmsnapshot/backup.go @@ -1172,7 +1172,7 @@ func HasUnfinishedInstanceBackup(ctx context.Context, kotsadmNamespace string) ( return false, nil } -func GetBackupDetail(ctx context.Context, kotsadmNamespace string, backupID string) (*types.BackupDetail, error) { +func GetBackupDetail(ctx context.Context, kotsadmNamespace string, backupName string) ([]types.BackupDetail, error) { cfg, err := k8sutil.GetClusterConfig() if err != nil { return nil, errors.Wrap(err, "failed to get cluster config") @@ -1195,20 +1195,59 @@ func GetBackupDetail(ctx context.Context, kotsadmNamespace string, backupID stri veleroNamespace := backendStorageLocation.Namespace - backup, err := veleroClient.Backups(veleroNamespace).Get(ctx, backupID, metav1.GetOptions{}) + backups, err := listBackupsByName(ctx, veleroClient, veleroNamespace, backupName) if err != nil { return nil, errors.Wrap(err, "failed to get backup") } + results := []types.BackupDetail{} + + for _, backup := range backups { + result, err := getBackupDetailForBackup(ctx, veleroClient, veleroNamespace, backup) + if err != nil { + return nil, fmt.Errorf("failed to get backup detail for backup %s: %w", backup.Name, err) + } + + results = append(results, *result) + } + + return results, nil +} + +// listBackupsByName returns a list of backups for the specified backup name. First it tries to get +// the backup by the replicated.com/backup-name label, and if that fails, it tries to get the +// backup by the metadata name. +func listBackupsByName(ctx context.Context, veleroClient veleroclientv1.VeleroV1Interface, veleroNamespace string, backupName string) ([]velerov1.Backup, error) { + // first try to get the backup from the backup-name label + backupList, err := veleroClient.Backups(veleroNamespace).List(ctx, metav1.ListOptions{ + LabelSelector: fmt.Sprintf("%s=%s", types.InstanceBackupNameLabel, velerolabel.GetValidName(backupName)), + }) + if err != nil { + return nil, fmt.Errorf("failed to list backups by label: %w", err) + } + if len(backupList.Items) > 0 { + return backupList.Items, nil + } + backup, err := veleroClient.Backups(veleroNamespace).Get(ctx, backupName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get backup by name: %w", err) + } + + return []velerov1.Backup{*backup}, nil +} + +// getBackupDetailForBackup returns a BackupDetail object for the specified backup. +func getBackupDetailForBackup(ctx context.Context, veleroClient veleroclientv1.VeleroV1Interface, veleroNamespace string, backup velerov1.Backup) (*types.BackupDetail, error) { backupVolumes, err := veleroClient.PodVolumeBackups(veleroNamespace).List(ctx, metav1.ListOptions{ - LabelSelector: fmt.Sprintf("velero.io/backup-name=%s", velerolabel.GetValidName(backupID)), + LabelSelector: fmt.Sprintf("velero.io/backup-name=%s", velerolabel.GetValidName(backup.Name)), }) if err != nil { return nil, errors.Wrap(err, "failed to list volumes") } - result := &types.BackupDetail{ + result := types.BackupDetail{ Name: backup.Name, + Type: types.GetInstanceBackupType(backup), Status: string(backup.Status.Phase), Namespaces: backup.Spec.IncludedNamespaces, Volumes: listBackupVolumes(backupVolumes.Items), @@ -1221,7 +1260,7 @@ func GetBackupDetail(ctx context.Context, kotsadmNamespace string, backupID stri result.VolumeSizeHuman = units.HumanSize(float64(totalBytesDone)) // TODO: should this be TotalBytes rather than BytesDone? if backup.Status.Phase == velerov1.BackupPhaseCompleted || backup.Status.Phase == velerov1.BackupPhasePartiallyFailed || backup.Status.Phase == velerov1.BackupPhaseFailed { - errs, warnings, execs, err := downloadBackupLogs(ctx, veleroNamespace, backupID) + errs, warnings, execs, err := downloadBackupLogs(ctx, veleroNamespace, backup.Name) result.Errors = errs result.Warnings = warnings result.Hooks = execs @@ -1231,7 +1270,7 @@ func GetBackupDetail(ctx context.Context, kotsadmNamespace string, backupID stri } } - return result, nil + return &result, nil } func listBackupVolumes(backupVolumes []velerov1.PodVolumeBackup) []types.SnapshotVolume { diff --git a/pkg/kotsadmsnapshot/backup_test.go b/pkg/kotsadmsnapshot/backup_test.go index 86928a5477..485cc9289e 100644 --- a/pkg/kotsadmsnapshot/backup_test.go +++ b/pkg/kotsadmsnapshot/backup_test.go @@ -3673,3 +3673,191 @@ func TestDeleteBackup(t *testing.T) { }) } } + +func TestGetBackupDetail(t *testing.T) { + scheme := runtime.NewScheme() + corev1.AddToScheme(scheme) + velerov1.AddToScheme(scheme) + + objects := []runtime.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kotsadm-velero-namespace", + }, + Data: map[string]string{ + "veleroNamespace": "velero", + }, + }, + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "velero", + Namespace: "velero", + }, + }, + } + + veleroObjects := []runtime.Object{ + &velerov1.BackupStorageLocation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + Namespace: "velero", + }, + Spec: velerov1.BackupStorageLocationSpec{ + Provider: "aws", + Default: true, + }, + }, + &velerov1.Backup{ + TypeMeta: metav1.TypeMeta{ + Kind: "Backup", + APIVersion: "velero.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "instance-abcd", + Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "app-slug-abcd", + }, + Annotations: map[string]string{ + types.BackupIsECAnnotation: "true", + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeInfra, + types.InstanceBackupCountAnnotation: "2", + }, + CreationTimestamp: metav1.Time{Time: time.Date(2022, 1, 3, 0, 0, 0, 0, time.Local)}, + }, + Spec: velerov1.BackupSpec{ + StorageLocation: "default", + IncludedNamespaces: []string{"*"}, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseInProgress, + }, + }, + &velerov1.Backup{ + TypeMeta: metav1.TypeMeta{ + Kind: "Backup", + APIVersion: "velero.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "application-abcd", + Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "app-slug-abcd", + }, + Annotations: map[string]string{ + types.BackupIsECAnnotation: "true", + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeApp, + types.InstanceBackupCountAnnotation: "2", + }, + CreationTimestamp: metav1.Time{Time: time.Date(2022, 1, 4, 0, 0, 0, 0, time.Local)}, + }, + Spec: velerov1.BackupSpec{ + StorageLocation: "default", + IncludedNamespaces: []string{"*"}, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseInProgress, + }, + }, + &velerov1.Backup{ + TypeMeta: metav1.TypeMeta{ + Kind: "Backup", + APIVersion: "velero.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "instance-efgh", + Namespace: "velero", + Annotations: map[string]string{ + types.BackupIsECAnnotation: "true", + types.InstanceBackupAnnotation: "true", + // legacy backups do not have the InstanceBackupTypeAnnotation + }, + CreationTimestamp: metav1.Time{Time: time.Date(2022, 1, 2, 0, 0, 0, 0, time.Local)}, + }, + Spec: velerov1.BackupSpec{ + StorageLocation: "default", + IncludedNamespaces: []string{"*"}, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseInProgress, + }, + }, + } + + type args struct { + backupName string + } + tests := []struct { + name string + args args + want []types.BackupDetail + wantErr bool + }{ + { + name: "legacy backup by name should return a single backup from metadata.name", + args: args{ + backupName: "instance-efgh", + }, + want: []types.BackupDetail{ + { + Name: "instance-efgh", + Type: types.InstanceBackupTypeLegacy, + Status: "InProgress", + Namespaces: []string{"*"}, + VolumeSizeHuman: "0B", + Volumes: []types.SnapshotVolume{}, + }, + }, + }, + { + name: "new backup by name label should return multiple backups", + args: args{ + backupName: "app-slug-abcd", + }, + want: []types.BackupDetail{ + { + Name: "application-abcd", + Type: types.InstanceBackupTypeApp, + Status: "InProgress", + Namespaces: []string{"*"}, + VolumeSizeHuman: "0B", + Volumes: []types.SnapshotVolume{}, + }, + { + Name: "instance-abcd", + Type: types.InstanceBackupTypeInfra, + Status: "InProgress", + Namespaces: []string{"*"}, + VolumeSizeHuman: "0B", + Volumes: []types.SnapshotVolume{}, + }, + }, + }, + { + name: "not found should return an error", + args: args{ + backupName: "not-exists", + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + k8sclient.SetBuilder(&k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset(objects...), + }) + veleroclient.SetBuilder(&veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset(veleroObjects...).VeleroV1(), + }) + got, err := GetBackupDetail(context.Background(), "velero", tt.args.backupName) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/kotsadmsnapshot/types/types.go b/pkg/kotsadmsnapshot/types/types.go index c5c44c771a..e51be8ba58 100644 --- a/pkg/kotsadmsnapshot/types/types.go +++ b/pkg/kotsadmsnapshot/types/types.go @@ -8,6 +8,9 @@ import ( ) const ( + // BackupIsECAnnotation is the annotation used to store if the backup is from an EC install. + BackupIsECAnnotation = "kots.io/embedded-cluster" + // InstanceBackupNameLabel is the label used to store the name of the backup for an instance // backup. InstanceBackupNameLabel = "replicated.com/backup-name" @@ -76,6 +79,7 @@ type Backup struct { type BackupDetail struct { Name string `json:"name"` + Type string `json:"type"` Status string `json:"status"` VolumeSizeHuman string `json:"volumeSizeHuman"` Namespaces []string `json:"namespaces"` diff --git a/web/src/components/RestoreSnapshotRow.jsx b/web/src/components/RestoreSnapshotRow.jsx index 845f6bc5c6..8177440350 100644 --- a/web/src/components/RestoreSnapshotRow.jsx +++ b/web/src/components/RestoreSnapshotRow.jsx @@ -32,7 +32,7 @@ class RestoreSnapshotRow extends Component { if (result.success) { this.setState({ isLoadingBackupInfo: false, - backupInfo: result.backupDetail, + backupInfo: result.backupDetails?.[0], backupInfoMsg: "", }); } else { diff --git a/web/src/components/snapshots/SnapshotDetails.jsx b/web/src/components/snapshots/SnapshotDetails.jsx index 96c922242c..cc30f34029 100644 --- a/web/src/components/snapshots/SnapshotDetails.jsx +++ b/web/src/components/snapshots/SnapshotDetails.jsx @@ -158,7 +158,7 @@ class SnapshotDetails extends Component { } const response = await res.json(); - const snapshotDetails = response.backupDetail; + const snapshotDetails = response.backupDetails?.[0]; let series = []; if (!isEmpty(snapshotDetails?.volumes)) {