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/deploy/melange.yaml.tmpl b/deploy/melange.yaml.tmpl index 055a7bfddd..aef53f9010 100644 --- a/deploy/melange.yaml.tmpl +++ b/deploy/melange.yaml.tmpl @@ -1,6 +1,6 @@ package: name: kotsadm-head - version: ${GIT_TAG} + version: "0.0.1" # our versioning is not compatible with apk epoch: 0 description: Kotsadm package copyright: @@ -41,8 +41,8 @@ pipeline: mv deploy/assets/postgres "${DESTDIR}/postgres" # kotsadm and kots binaries - export VERSION=${{package.version}} - export GIT_TAG=${{package.version}} + export VERSION=${GIT_TAG} + export GIT_TAG=${GIT_TAG} # Set environment variables from repository source .image.env diff --git a/go.mod b/go.mod index 5d43d5b24f..d56ec654ba 100644 --- a/go.mod +++ b/go.mod @@ -70,7 +70,7 @@ require ( github.com/vmware-tanzu/velero v1.14.1 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.29.0 + golang.org/x/crypto v0.31.0 golang.org/x/oauth2 v0.23.0 golang.org/x/sync v0.10.0 google.golang.org/api v0.200.0 @@ -366,10 +366,10 @@ require ( go.opentelemetry.io/otel/trace v1.31.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/mod v0.22.0 // indirect - golang.org/x/net v0.31.0 // indirect + golang.org/x/mod v0.21.0 // indirect + golang.org/x/net v0.30.0 // indirect golang.org/x/sys v0.28.0 // indirect - golang.org/x/term v0.26.0 // indirect + golang.org/x/term v0.27.0 // indirect golang.org/x/text v0.21.0 // indirect golang.org/x/time v0.7.0 // indirect golang.org/x/tools v0.26.0 // indirect diff --git a/go.sum b/go.sum index c79b48d787..19d3e579a6 100644 --- a/go.sum +++ b/go.sum @@ -1706,8 +1706,8 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= -golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= -golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1985,8 +1985,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= -golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= -golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915090833-1cbadb444a80/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/kurl_proxy/deploy/melange.yaml.tmpl b/kurl_proxy/deploy/melange.yaml.tmpl index 2f84f134e3..cbc1808c79 100644 --- a/kurl_proxy/deploy/melange.yaml.tmpl +++ b/kurl_proxy/deploy/melange.yaml.tmpl @@ -1,6 +1,6 @@ package: name: kurl-proxy-head - version: ${GIT_TAG} + version: "0.0.1" # our versioning is not compatible with apk epoch: 0 description: kurl-proxy package copyright: diff --git a/migrations/deploy/melange.yaml.tmpl b/migrations/deploy/melange.yaml.tmpl index 79b87d40cc..e8ee1ebbbb 100644 --- a/migrations/deploy/melange.yaml.tmpl +++ b/migrations/deploy/melange.yaml.tmpl @@ -1,6 +1,6 @@ package: name: kotsadm-migrations-head - version: ${GIT_TAG} + version: "0.0.1" # our versioning is not compatible with apk epoch: 0 description: kotsadm-migrations package copyright: diff --git a/pkg/appstate/ingress.go b/pkg/appstate/ingress.go index 891eab3d86..a4b2428d7b 100644 --- a/pkg/appstate/ingress.go +++ b/pkg/appstate/ingress.go @@ -7,6 +7,7 @@ import ( "github.com/replicatedhq/kots/pkg/appstate/types" "github.com/replicatedhq/kots/pkg/k8sutil" "github.com/replicatedhq/kots/pkg/logger" + v1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -118,6 +119,7 @@ func makeIngressResourceState(r *networkingv1.Ingress, state types.State) types. } func CalculateIngressState(clientset kubernetes.Interface, r *networkingv1.Ingress) types.State { + ctx := context.TODO() ns := r.Namespace backend := r.Spec.DefaultBackend @@ -139,30 +141,43 @@ func CalculateIngressState(clientset kubernetes.Interface, r *networkingv1.Ingre ns = metav1.NamespaceSystem } - var states []types.State + services := []*v1.Service{} // includes nils which are mapped to unavailable if backend != nil { - states = append(states, ingressGetStateFromBackend(clientset, ns, *backend)) + service, _ := clientset.CoreV1().Services(ns).Get(ctx, backend.Service.Name, metav1.GetOptions{}) + services = append(services, service) } for _, rules := range r.Spec.Rules { for _, path := range rules.HTTP.Paths { - states = append(states, ingressGetStateFromBackend(clientset, r.Namespace, path.Backend)) + service, _ := clientset.CoreV1().Services(r.Namespace).Get(ctx, path.Backend.Service.Name, metav1.GetOptions{}) + services = append(services, service) } } - // https://github.com/kubernetes/kubernetes/blob/badcd4af3f592376ce891b7c1b7a43ed6a18a348/pkg/printers/internalversion/printers.go#L1067 - states = append(states, ingressGetStateFromExternalIP(r)) - return types.MinState(states...) -} -func ingressGetStateFromBackend(clientset kubernetes.Interface, namespace string, backend networkingv1.IngressBackend) (minState types.State) { - if backend.Service == nil { - return types.StateUnavailable + hasLoadBalancer := false + for _, service := range services { + if service != nil && service.Spec.Type == v1.ServiceTypeLoadBalancer { + hasLoadBalancer = true + break + } } - service, _ := clientset.CoreV1().Services(namespace).Get(context.TODO(), backend.Service.Name, metav1.GetOptions{}) - if service == nil { - return types.StateUnavailable + + var states []types.State + for _, service := range services { + if service == nil { + states = append(states, types.StateUnavailable) + } else { + states = append(states, serviceGetStateFromEndpoints(clientset, service)) + } + } + + // An ingress will have an IP associated with it if it's type is LoadBalancer. + if hasLoadBalancer { + // https://github.com/kubernetes/kubernetes/blob/badcd4af3f592376ce891b7c1b7a43ed6a18a348/pkg/printers/internalversion/printers.go#L1067 + states = append(states, ingressGetStateFromExternalIP(r)) } - return serviceGetStateFromEndpoints(clientset, service) + + return types.MinState(states...) } func ingressGetStateFromExternalIP(ing *networkingv1.Ingress) types.State { diff --git a/pkg/appstate/ingress_test.go b/pkg/appstate/ingress_test.go index 17073acc17..b826fc4858 100644 --- a/pkg/appstate/ingress_test.go +++ b/pkg/appstate/ingress_test.go @@ -16,7 +16,7 @@ import ( func mockClientsetK8sVersion(expectedMajor string, expectedMinor string) kubernetes.Interface { clientset := fake.NewSimpleClientset( - // add a service + // Defaul backend service and endpoint &v1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "default-http-backend", @@ -52,6 +52,82 @@ func mockClientsetK8sVersion(expectedMajor string, expectedMinor string) kuberne }, }, }, + + // LoadBalancer service and endpoint + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-lb", + Namespace: "", + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeLoadBalancer, + Ports: []v1.ServicePort{ + { + Name: "http", + Port: 8080, + }, + }, + }, + }, + &v1.Endpoints{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-lb", + Namespace: "", + }, + Subsets: []v1.EndpointSubset{ + { + Ports: []v1.EndpointPort{ + { + Name: "http", + Port: 8080, + }, + }, + Addresses: []v1.EndpointAddress{ + { + IP: "172.0.0.2", + }, + }, + }, + }, + }, + + // NodePort service and endpoint + &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-nodeport", + Namespace: "", + }, + Spec: v1.ServiceSpec{ + Type: v1.ServiceTypeNodePort, + Ports: []v1.ServicePort{ + { + Name: "http", + Port: 8080, + }, + }, + }, + }, + &v1.Endpoints{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-nodeport", + Namespace: "", + }, + Subsets: []v1.EndpointSubset{ + { + Ports: []v1.EndpointPort{ + { + Name: "http", + Port: 8080, + }, + }, + Addresses: []v1.EndpointAddress{ + { + IP: "172.0.0.2", + }, + }, + }, + }, + }, ) clientset.Discovery().(*discoveryfake.FakeDiscovery).FakedServerVersion = &version.Info{ Major: expectedMajor, @@ -88,22 +164,30 @@ func TestCalculateIngressState(t *testing.T) { }, }, want: types.StateReady, - }, - { - name: "expect unavailable state when ingress with k8s version > 1.22 and no default backend", - args: args{ - clientset: mockClientsetK8sVersion("1", "23"), - r: &networkingv1.Ingress{ - Spec: networkingv1.IngressSpec{}, - }, - }, - want: types.StateUnavailable, }, { - name: "expect ready state when ingress with k8s version > 1.22 and no default backend and with load balancer status", + name: "expect ready state when there is a load balancer and an IP address", args: args{ clientset: mockClientsetK8sVersion("1", "23"), r: &networkingv1.Ingress{ - Spec: networkingv1.IngressSpec{}, + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "app-lb", + }, + }, + }, + }, + }, + }, + }, + }, + }, Status: networkingv1.IngressStatus{ LoadBalancer: networkingv1.IngressLoadBalancerStatus{ Ingress: []networkingv1.IngressLoadBalancerIngress{ @@ -116,6 +200,61 @@ func TestCalculateIngressState(t *testing.T) { }, }, want: types.StateReady, + }, { + name: "expect ready state when there is no LoadBalancer and no address is assigned", + args: args{ + clientset: mockClientsetK8sVersion("1", "23"), + r: &networkingv1.Ingress{ + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "app-nodeport", + }, + }, + }, + }, + }, + }, + }, + }, + }, + Status: networkingv1.IngressStatus{}, + }, + }, + want: types.StateReady, + }, { + name: "expect unavailable state when there is a LoadBalancer but no address is assigned", + args: args{ + clientset: mockClientsetK8sVersion("1", "23"), + r: &networkingv1.Ingress{ + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + { + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "app-lb", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: types.StateUnavailable, }, } for _, tt := range tests { diff --git a/pkg/handlers/backup.go b/pkg/handlers/backup.go index 26ca52925d..9a56fafee4 100644 --- a/pkg/handlers/backup.go +++ b/pkg/handlers/backup.go @@ -110,8 +110,8 @@ func (h *Handler) ListBackups(w http.ResponseWriter, r *http.Request) { } type ListInstanceBackupsResponse struct { - Error string `json:"error,omitempty"` - Backups []*snapshottypes.ReplicatedBackup `json:"backups"` + Error string `json:"error,omitempty"` + Backups []*snapshottypes.Backup `json:"backups"` } func (h *Handler) ListInstanceBackups(w http.ResponseWriter, r *http.Request) { @@ -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..502e43a811 100644 --- a/pkg/kotsadmsnapshot/backup.go +++ b/pkg/kotsadmsnapshot/backup.go @@ -143,9 +143,9 @@ func CreateApplicationBackup(ctx context.Context, a *apptypes.App, isScheduled b veleroBackup.Spec.IncludedNamespaces = prepareIncludedNamespaces(includedNamespaces) - snapshotTrigger := "manual" + snapshotTrigger := types.BackupTriggerManual if isScheduled { - snapshotTrigger = "schedule" + snapshotTrigger = types.BackupTriggerSchedule } veleroBackup.Name = "" @@ -156,7 +156,7 @@ func CreateApplicationBackup(ctx context.Context, a *apptypes.App, isScheduled b if veleroBackup.Annotations == nil { veleroBackup.Annotations = make(map[string]string, 0) } - veleroBackup.Annotations["kots.io/snapshot-trigger"] = snapshotTrigger + veleroBackup.Annotations[types.BackupTriggerAnnotation] = snapshotTrigger veleroBackup.Annotations["kots.io/app-id"] = a.ID veleroBackup.Annotations["kots.io/app-sequence"] = strconv.FormatInt(parentSequence, 10) veleroBackup.Annotations["kots.io/snapshot-requested"] = time.Now().UTC().Format(time.RFC3339) @@ -659,9 +659,9 @@ func appendCommonAnnotations(k8sClient kubernetes.Interface, annotations map[str return nil, errors.Wrap(err, "failed to find kotsadm image") } - snapshotTrigger := "manual" + snapshotTrigger := types.BackupTriggerManual if metadata.isScheduled { - snapshotTrigger = "schedule" + snapshotTrigger = types.BackupTriggerSchedule } appSequences := map[string]int64{} @@ -689,11 +689,11 @@ func appendCommonAnnotations(k8sClient kubernetes.Interface, annotations map[str if annotations == nil { annotations = make(map[string]string, 0) } - annotations["kots.io/snapshot-trigger"] = snapshotTrigger + annotations[types.BackupTriggerAnnotation] = snapshotTrigger annotations["kots.io/snapshot-requested"] = metadata.backupReqestedAt.Format(time.RFC3339) annotations["kots.io/kotsadm-image"] = kotsadmImage annotations["kots.io/kotsadm-deploy-namespace"] = metadata.kotsadmNamespace - annotations["kots.io/apps-sequences"] = marshalledAppSequences + annotations[types.BackupAppsSequencesAnnotation] = marshalledAppSequences annotations["kots.io/apps-versions"] = marshalledAppVersions annotations["kots.io/is-airgap"] = strconv.FormatBool(kotsadm.IsAirgap()) embeddedRegistryHost, _, _ := kotsutil.GetEmbeddedRegistryCreds(k8sClient) @@ -747,7 +747,7 @@ func ListBackupsForApp(ctx context.Context, kotsadmNamespace string, appID strin backup := types.Backup{ Name: veleroBackup.Name, - Status: string(veleroBackup.Status.Phase), + Status: types.GetStatusFromBackupPhase(veleroBackup.Status.Phase), AppID: appID, } @@ -770,10 +770,10 @@ func ListBackupsForApp(ctx context.Context, kotsadmNamespace string, appID strin backup.Sequence = s } if backup.Status == "" { - backup.Status = "New" + backup.Status = types.BackupStatusInProgress } - trigger, ok := veleroBackup.Annotations["kots.io/snapshot-trigger"] + trigger, ok := veleroBackup.Annotations[types.BackupTriggerAnnotation] if ok { backup.Trigger = trigger } @@ -783,56 +783,16 @@ func ListBackupsForApp(ctx context.Context, kotsadmNamespace string, appID strin backup.SupportBundleID = supportBundleID } - volumeCount, volumeCountOk := veleroBackup.Annotations["kots.io/snapshot-volume-count"] - if volumeCountOk { - i, err := strconv.Atoi(volumeCount) + if backup.Status != types.BackupStatusInProgress { + volumeSummary, err := getSnapshotVolumeSummary(ctx, &veleroBackup) if err != nil { - return nil, errors.Wrap(err, "failed to convert volume-count") + return nil, errors.Wrap(err, "failed to get volume summary") } - backup.VolumeCount = i - } - - volumeSuccessCount, volumeSuccessCountOk := veleroBackup.Annotations["kots.io/snapshot-volume-success-count"] - if volumeSuccessCountOk { - i, err := strconv.Atoi(volumeSuccessCount) - if err != nil { - return nil, errors.Wrap(err, "failed to convert volume-success-count") - } - backup.VolumeSuccessCount = i - } - - volumeBytes, volumeBytesOk := veleroBackup.Annotations["kots.io/snapshot-volume-bytes"] - if volumeBytesOk { - i, err := strconv.ParseInt(volumeBytes, 10, 64) - if err != nil { - return nil, errors.Wrap(err, "failed to convert volume-bytes") - } - backup.VolumeBytes = i - backup.VolumeSizeHuman = units.HumanSize(float64(i)) - } - - if backup.Status != "New" && backup.Status != "InProgress" { - if !volumeBytesOk || !volumeSuccessCountOk { - // save computed summary as annotations if snapshot is finished - volumeSummary, err := getSnapshotVolumeSummary(ctx, &veleroBackup) - if err != nil { - return nil, errors.Wrap(err, "failed to get volume summary") - } - backup.VolumeCount = volumeSummary.VolumeCount - backup.VolumeSuccessCount = volumeSummary.VolumeSuccessCount - backup.VolumeBytes = volumeSummary.VolumeBytes - backup.VolumeSizeHuman = volumeSummary.VolumeSizeHuman - - // This is failing with "the server could not find the requested resource (put backups.velero.io scheduled-1586536961)" - // veleroBackup.Annotations["kots.io/snapshot-volume-count"] = strconv.Itoa(backup.VolumeCount) - // veleroBackup.Annotations["kots.io/snapshot-volume-success-count"] = strconv.Itoa(backup.VolumeSuccessCount) - // veleroBackup.Annotations["kots.io/snapshot-volume-bytes"] = strconv.FormatInt(backup.VolumeBytes, 10) - - // if _, err = veleroClient.Backups(backendStorageLocation.Namespace).UpdateStatus(&veleroBackup); err != nil { - // return nil, errors.Wrap(err, "failed to update velero backup") - // } - } + backup.VolumeCount = volumeSummary.VolumeCount + backup.VolumeSuccessCount = volumeSummary.VolumeSuccessCount + backup.VolumeBytes = volumeSummary.VolumeBytes + backup.VolumeSizeHuman = volumeSummary.VolumeSizeHuman } backups = append(backups, &backup) @@ -841,7 +801,7 @@ func ListBackupsForApp(ctx context.Context, kotsadmNamespace string, appID strin return backups, nil } -func ListInstanceBackups(ctx context.Context, kotsadmNamespace string) ([]*types.ReplicatedBackup, error) { +func ListInstanceBackups(ctx context.Context, kotsadmNamespace string) ([]*types.Backup, error) { cfg, err := k8sutil.GetClusterConfig() if err != nil { return nil, errors.Wrap(err, "failed to get cluster config") @@ -871,124 +831,113 @@ func ListInstanceBackups(ctx context.Context, kotsadmNamespace string) ([]*types return nil, errors.Wrap(err, "failed to list velero backups") } - replicatedBackupsMap := map[string]*types.ReplicatedBackup{} + return getBackupsFromVeleroBackups(ctx, veleroBackups.Items) +} - for _, veleroBackup := range veleroBackups.Items { - // TODO: Enforce version? +// getBackupsFromVeleroBackups returns an array of `Backup` structs, consisting of Replicated's representation of a backup +// from an array of Velero backups `VolumeSummary`'s and . +func getBackupsFromVeleroBackups(ctx context.Context, veleroBackups []velerov1.Backup) ([]*types.Backup, error) { + result := make(map[string]*types.Backup, 0) + + for _, veleroBackup := range veleroBackups { + // filter out non instance backups if !types.IsInstanceBackup(veleroBackup) { continue } - - backup := types.Backup{ - Name: veleroBackup.Name, - Status: string(veleroBackup.Status.Phase), - IncludedApps: make([]types.App, 0), - } - - if veleroBackup.Status.StartTimestamp != nil { - backup.StartedAt = &veleroBackup.Status.StartTimestamp.Time - } - if veleroBackup.Status.CompletionTimestamp != nil { - backup.FinishedAt = &veleroBackup.Status.CompletionTimestamp.Time - } - if veleroBackup.Status.Expiration != nil { - backup.ExpiresAt = &veleroBackup.Status.Expiration.Time - } - if backup.Status == "" { - backup.Status = "New" - } - - trigger, ok := veleroBackup.Annotations["kots.io/snapshot-trigger"] - if ok { - backup.Trigger = trigger + veleroStatus := veleroBackup.Status + backupName := types.GetBackupName(veleroBackup) + if _, ok := result[backupName]; !ok { + result[backupName] = &types.Backup{ + Name: backupName, + Status: types.GetStatusFromBackupPhase(veleroStatus.Phase), + Trigger: types.GetBackupTrigger(veleroBackup), + ExpectedBackupCount: types.GetInstanceBackupCount(veleroBackup), + IncludedApps: []types.App{}, + } } - - volumeCount, volumeCountOk := veleroBackup.Annotations["kots.io/snapshot-volume-count"] - if volumeCountOk { - i, err := strconv.Atoi(volumeCount) - if err != nil { - return nil, errors.Wrap(err, "failed to convert volume-count") + backup := result[backupName] + backup.BackupCount++ + // backup uses the oldest velero backup start time as its start time + if veleroStatus.StartTimestamp != nil { + if backup.StartedAt == nil || veleroStatus.StartTimestamp.Time.Before(*backup.StartedAt) { + backup.StartedAt = &veleroStatus.StartTimestamp.Time } - backup.VolumeCount = i } - volumeSuccessCount, volumeSuccessCountOk := veleroBackup.Annotations["kots.io/snapshot-volume-success-count"] - if volumeSuccessCountOk { - i, err := strconv.Atoi(volumeSuccessCount) - if err != nil { - return nil, errors.Wrap(err, "failed to convert volume-success-count") + // backup uses the first expiration date as its expiration timestamp + if veleroStatus.Expiration != nil { + if backup.ExpiresAt == nil || veleroStatus.Expiration.Time.Before(*backup.ExpiresAt) { + backup.ExpiresAt = &veleroStatus.Expiration.Time } - backup.VolumeSuccessCount = i } - volumeBytes, volumeBytesOk := veleroBackup.Annotations["kots.io/snapshot-volume-bytes"] - if volumeBytesOk { - i, err := strconv.ParseInt(volumeBytes, 10, 64) - if err != nil { - return nil, errors.Wrap(err, "failed to convert volume-bytes") + // backup uses the most recent completion date as its completion timestamp + if veleroStatus.CompletionTimestamp != nil { + if backup.FinishedAt == nil || veleroStatus.CompletionTimestamp.Time.After(*backup.FinishedAt) { + backup.FinishedAt = &veleroStatus.CompletionTimestamp.Time } - backup.VolumeBytes = i - backup.VolumeSizeHuman = units.HumanSize(float64(i)) } - appAnnotationStr, _ := veleroBackup.Annotations["kots.io/apps-sequences"] - if len(appAnnotationStr) > 0 { - var apps map[string]int64 - if err := json.Unmarshal([]byte(appAnnotationStr), &apps); err != nil { - return nil, errors.Wrap(err, "failed to unmarshal apps sequences") - } - for slug, sequence := range apps { - a, err := store.GetStore().GetAppFromSlug(slug) - if err != nil { - if store.GetStore().IsNotFound(err) { - // app might not exist in current installation - continue - } - return nil, errors.Wrap(err, "failed to get app from slug") - } + backup.Status = types.RollupStatus([]types.BackupStatus{backup.Status, types.GetStatusFromBackupPhase(veleroStatus.Phase)}) - backup.IncludedApps = append(backup.IncludedApps, types.App{ - Slug: slug, - Sequence: sequence, - Name: a.Name, - AppIconURI: a.IconURI, - }) - } + // get volume information + volumeSummary, err := getSnapshotVolumeSummary(ctx, &veleroBackup) + if err != nil { + return nil, fmt.Errorf("failed to get volume summary for backup %s: %w", backupName, err) } - if backup.Status != "New" && backup.Status != "InProgress" { - if !volumeBytesOk || !volumeSuccessCountOk { - // save computed summary as annotations if snapshot is finished - volumeSummary, err := getSnapshotVolumeSummary(ctx, &veleroBackup) - if err != nil { - return nil, errors.Wrap(err, "failed to get volume summary") - } + backup.VolumeCount += volumeSummary.VolumeCount + backup.VolumeSuccessCount += volumeSummary.VolumeSuccessCount + backup.VolumeBytes += volumeSummary.VolumeBytes + backup.VolumeSizeHuman = units.HumanSize(float64(backup.VolumeBytes)) - backup.VolumeCount = volumeSummary.VolumeCount - backup.VolumeSuccessCount = volumeSummary.VolumeSuccessCount - backup.VolumeBytes = volumeSummary.VolumeBytes - backup.VolumeSizeHuman = volumeSummary.VolumeSizeHuman - } + apps, err := getAppsFromAppSequences(veleroBackup) + if err != nil { + return nil, fmt.Errorf("failed to get apps from app sequences for backup %s: %w", backupName, err) } + backup.IncludedApps = append(backup.IncludedApps, apps...) + } - // group the velero backups by the name we present to the user - backupName := types.GetBackupName(veleroBackup) - if _, ok := replicatedBackupsMap[backupName]; !ok { - replicatedBackupsMap[backupName] = &types.ReplicatedBackup{ - Name: backupName, - Backups: []types.Backup{}, - ExpectedBackupCount: types.GetInstanceBackupCount(veleroBackup), - } + backups := []*types.Backup{} + for _, backup := range result { + // we consider a backup to have failed if the number of backups that actually exist is less than the expected number + if backup.ExpectedBackupCount != backup.BackupCount { + backup.Status = types.BackupStatusFailed } - replicatedBackupsMap[backupName].Backups = append(replicatedBackupsMap[backupName].Backups, backup) + backups = append(backups, backup) } - replicatedBackups := []*types.ReplicatedBackup{} - for _, rb := range replicatedBackupsMap { - replicatedBackups = append(replicatedBackups, rb) - } + return backups, nil +} - return replicatedBackups, nil +// getAppsFromAppSequences returns a list of `App` structs from the backup sequence annotation. +func getAppsFromAppSequences(veleroBackup velerov1.Backup) ([]types.App, error) { + apps := []types.App{} + appAnnotationStr, _ := veleroBackup.Annotations[types.BackupAppsSequencesAnnotation] + if len(appAnnotationStr) > 0 { + var appsSequences map[string]int64 + if err := json.Unmarshal([]byte(appAnnotationStr), &appsSequences); err != nil { + return nil, fmt.Errorf("failed to unmarshal apps sequences: %w", err) + } + for slug, sequence := range appsSequences { + a, err := store.GetStore().GetAppFromSlug(slug) + if err != nil { + if store.GetStore().IsNotFound(err) { + // app might not exist in current installation + continue + } + return nil, fmt.Errorf("failed to get app from slug: %w", err) + } + + apps = append(apps, types.App{ + Slug: slug, + Sequence: sequence, + Name: a.Name, + AppIconURI: a.IconURI, + }) + } + } + return apps, nil } func getSnapshotVolumeSummary(ctx context.Context, veleroBackup *velerov1.Backup) (*types.VolumeSummary, error) { @@ -1147,7 +1096,7 @@ func HasUnfinishedApplicationBackup(ctx context.Context, kotsadmNamespace string } for _, backup := range backups { - if backup.Status == "New" || backup.Status == "InProgress" { + if backup.Status == types.BackupStatusInProgress { return true, nil } } @@ -1156,23 +1105,21 @@ func HasUnfinishedApplicationBackup(ctx context.Context, kotsadmNamespace string } func HasUnfinishedInstanceBackup(ctx context.Context, kotsadmNamespace string) (bool, error) { - replicatedBackups, err := ListInstanceBackups(ctx, kotsadmNamespace) + backups, err := ListInstanceBackups(ctx, kotsadmNamespace) if err != nil { return false, errors.Wrap(err, "failed to list backups") } - for _, replicatedBackup := range replicatedBackups { - for _, backup := range replicatedBackup.Backups { - if backup.Status == "New" || backup.Status == "InProgress" { - return true, nil - } + for _, backup := range backups { + if backup.Status == types.BackupStatusInProgress { + return true, nil } } 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 +1142,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 +1207,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 +1217,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..8c4889f9e5 100644 --- a/pkg/kotsadmsnapshot/backup_test.go +++ b/pkg/kotsadmsnapshot/backup_test.go @@ -3014,6 +3014,286 @@ func Test_getBackupNameFromPrefix(t *testing.T) { } } +func TestListBackupsForApp(t *testing.T) { + scheme := runtime.NewScheme() + corev1.AddToScheme(scheme) + embeddedclusterv1beta1.AddToScheme(scheme) + + // setup timestamps + startTs := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + completionTs := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC) + expirationTs := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC) + + // setup common mock objects + kotsadmNamespace := "kotsadm-test" + testBsl := &velerov1.BackupStorageLocation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + Namespace: "velero", + }, + Spec: velerov1.BackupStorageLocationSpec{ + Provider: "aws", + Default: true, + }, + } + veleroNamespaceConfigmap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kotsadm-velero-namespace", + Namespace: kotsadmNamespace, + }, + Data: map[string]string{ + "veleroNamespace": "velero", + }, + } + veleroDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "velero", + Namespace: "velero", + }, + } + + tests := []struct { + name string + appID string + veleroClientBuilder veleroclient.VeleroClientBuilder + k8sClientBuilder k8sclient.K8sClientsetBuilder + expectedBackups []*types.Backup + wantErr string + }{ + { + name: "fails to create k8s clientset", + appID: "app-1", + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: nil, + Err: fmt.Errorf("error creating k8s clientset"), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset().VeleroV1(), + }, + wantErr: "failed to create clientset", + }, + { + name: "fails to create velero client", + appID: "app-1", + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset(), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: nil, + Err: fmt.Errorf("error creating velero client"), + }, + wantErr: "failed to create velero clientset", + }, + { + name: "fails to find backup storage location", + appID: "app-1", + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset(), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset().VeleroV1(), + }, + wantErr: "no backup store location found", + }, + { + name: "empty backup list", + appID: "app-1", + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset( + veleroNamespaceConfigmap, + veleroDeployment, + ), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset( + testBsl, + ).VeleroV1(), + }, + expectedBackups: []*types.Backup{}, + }, + { + name: "backups not matching the app id are excluded", + appID: "app-1", + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset( + veleroNamespaceConfigmap, + veleroDeployment, + ), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset( + testBsl, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-backup-app-1", + Namespace: "velero", + Annotations: map[string]string{ + "kots.io/app-id": "app-1", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-backup-app-2", + Namespace: "velero", + Annotations: map[string]string{ + "kots.io/app-id": "app-2", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "instance-backup", + Namespace: "velero", + Annotations: map[string]string{ + types.InstanceBackupAnnotation: "true", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + ).VeleroV1(), + }, + expectedBackups: []*types.Backup{ + { + AppID: "app-1", + Name: "app-backup-app-1", + Status: "Completed", + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "0B", + }, + }, + }, + }, + { + name: "timestamps are populated", + appID: "app-1", + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset( + veleroNamespaceConfigmap, + veleroDeployment, + ), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset( + testBsl, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-backup-app-1", + Namespace: "velero", + Annotations: map[string]string{ + "kots.io/app-id": "app-1", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + StartTimestamp: &metav1.Time{Time: startTs}, + CompletionTimestamp: &metav1.Time{Time: completionTs}, + Expiration: &metav1.Time{Time: expirationTs}, + }, + }, + ).VeleroV1(), + }, + expectedBackups: []*types.Backup{ + { + AppID: "app-1", + Name: "app-backup-app-1", + Status: "Completed", + StartedAt: &startTs, + FinishedAt: &completionTs, + ExpiresAt: &expirationTs, + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "0B", + }, + }, + }, + }, + { + name: "volume info is populated from pod volume backups", + appID: "app-1", + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset( + veleroNamespaceConfigmap, + veleroDeployment, + ), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset( + testBsl, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-backup-app-1", + Namespace: "velero", + Annotations: map[string]string{ + "kots.io/snapshot-trigger": "schedule", + "kots.io/app-id": "app-1", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + &velerov1.PodVolumeBackup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-backup-app-1-pod-volume-backup", + Namespace: "velero", + Labels: map[string]string{ + "velero.io/backup-name": "app-backup-app-1", + }, + }, + Status: velerov1.PodVolumeBackupStatus{ + Phase: velerov1.PodVolumeBackupPhaseCompleted, + Progress: velerov1.PodVolumeOperationProgress{ + BytesDone: 2000, + }, + }, + }, + ).VeleroV1(), + }, + expectedBackups: []*types.Backup{ + { + AppID: "app-1", + Name: "app-backup-app-1", + Status: "Completed", + Trigger: "schedule", + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "2kB", + VolumeBytes: 2000, + VolumeSuccessCount: 1, + VolumeCount: 1, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + asrt := assert.New(t) + // setup mock clients + k8sclient.SetBuilder(test.k8sClientBuilder) + veleroclient.SetBuilder(test.veleroClientBuilder) + + backups, err := ListBackupsForApp(context.Background(), kotsadmNamespace, test.appID) + + if test.wantErr != "" { + asrt.Error(err) + asrt.Contains(err.Error(), test.wantErr) + } else { + asrt.NoError(err) + } + asrt.Equal(test.expectedBackups, backups) + }) + } +} + func TestListInstanceBackups(t *testing.T) { scheme := runtime.NewScheme() corev1.AddToScheme(scheme) @@ -3057,7 +3337,7 @@ func TestListInstanceBackups(t *testing.T) { setup func(mockStore *mock_store.MockStore) veleroClientBuilder veleroclient.VeleroClientBuilder k8sClientBuilder k8sclient.K8sClientsetBuilder - expectedBackups []*types.ReplicatedBackup + expectedBackups []*types.Backup wantErr string }{ { @@ -3105,10 +3385,532 @@ func TestListInstanceBackups(t *testing.T) { testBsl, ).VeleroV1(), }, - expectedBackups: []*types.ReplicatedBackup{}, + expectedBackups: []*types.Backup{}, + }, + { + name: "non instance backups are excluded", + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset( + veleroNamespaceConfigmap, + veleroDeployment, + ), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset( + testBsl, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-backup", + Namespace: "velero", + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "instance-backup", + Namespace: "velero", + Annotations: map[string]string{ + types.InstanceBackupAnnotation: "true", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + ).VeleroV1(), + }, + expectedBackups: []*types.Backup{ + { + Name: "instance-backup", + ExpectedBackupCount: 1, + BackupCount: 1, + Status: "Completed", + IncludedApps: []types.App{}, + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "0B", + }, + }, + }, + }, + { + name: "new improved dr backups are part of the same replicated backup", + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset( + veleroNamespaceConfigmap, + veleroDeployment, + ), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset( + testBsl, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "infra-backup", + Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "aggregated-repl-backup", + }, + Annotations: map[string]string{ + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupAnnotation: "true", + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeInfra, + types.InstanceBackupCountAnnotation: "2", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-backup", + Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "aggregated-repl-backup", + }, + Annotations: map[string]string{ + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupAnnotation: "true", + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeApp, + types.InstanceBackupCountAnnotation: "2", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + ).VeleroV1(), + }, + expectedBackups: []*types.Backup{ + { + Name: "aggregated-repl-backup", + ExpectedBackupCount: 2, + BackupCount: 2, + Status: "Completed", + IncludedApps: []types.App{}, + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "0B", + }, + }, + }, + }, + { + name: "included apps are populated", + setup: func(mockStore *mock_store.MockStore) { + mockStore.EXPECT().GetAppFromSlug("app-1").Times(1).Return(&apptypes.App{ + ID: "1", + Name: "App 1", + Slug: "app-1", + IconURI: "https://some-url.com/icon.png", + }, nil) + }, + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset( + veleroNamespaceConfigmap, + veleroDeployment, + ), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset( + testBsl, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-backup", + Namespace: "velero", + Annotations: map[string]string{ + types.InstanceBackupAnnotation: "true", + "kots.io/apps-sequences": "{\"app-1\":1}", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + ).VeleroV1(), + }, + expectedBackups: []*types.Backup{ + { + Name: "some-backup", + Status: "Completed", + ExpectedBackupCount: 1, + BackupCount: 1, + IncludedApps: []types.App{ + { + Slug: "app-1", + Sequence: 1, + Name: "App 1", + AppIconURI: "https://some-url.com/icon.png", + }, + }, + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "0B", + }, + }, + }, + }, + { + name: "if improved dr, included apps are populated from the included backups", + setup: func(mockStore *mock_store.MockStore) { + mockStore.EXPECT().GetAppFromSlug("app-1").Times(1).Return(&apptypes.App{ + ID: "1", + Name: "App 1", + Slug: "app-1", + IconURI: "https://some-url.com/icon.png", + }, nil) + mockStore.EXPECT().GetAppFromSlug("app-2").Times(1).Return(&apptypes.App{ + ID: "2", + Name: "App 2", + Slug: "app-2", + IconURI: "https://some-url.com/icon.png", + }, nil) + }, + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset( + veleroNamespaceConfigmap, + veleroDeployment, + ), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset( + testBsl, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "infra-backup", + Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "aggregated-repl-backup", + }, + Annotations: map[string]string{ + types.BackupAppsSequencesAnnotation: "{\"app-1\":1}", + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupAnnotation: "true", + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeInfra, + types.InstanceBackupCountAnnotation: "2", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-backup", + Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "aggregated-repl-backup", + }, + Annotations: map[string]string{ + types.BackupAppsSequencesAnnotation: "{\"app-2\":1}", + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupAnnotation: "true", + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeApp, + types.InstanceBackupCountAnnotation: "2", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + ).VeleroV1(), + }, + expectedBackups: []*types.Backup{ + { + Name: "aggregated-repl-backup", + Status: "Completed", + ExpectedBackupCount: 2, + BackupCount: 2, + IncludedApps: []types.App{ + { + Slug: "app-2", + Sequence: 1, + Name: "App 2", + AppIconURI: "https://some-url.com/icon.png", + }, + { + Slug: "app-1", + Sequence: 1, + Name: "App 1", + AppIconURI: "https://some-url.com/icon.png", + }, + }, + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "0B", + }, + }, + }, + }, + { + name: "timestamps are populated", + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset( + veleroNamespaceConfigmap, + veleroDeployment, + ), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset( + testBsl, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-backup", + Namespace: "velero", + Annotations: map[string]string{ + types.InstanceBackupAnnotation: "true", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + StartTimestamp: &metav1.Time{Time: startTs}, + CompletionTimestamp: &metav1.Time{Time: completionTs}, + Expiration: &metav1.Time{Time: expirationTs}, + }, + }, + ).VeleroV1(), + }, + expectedBackups: []*types.Backup{ + { + Name: "some-backup", + ExpectedBackupCount: 1, + BackupCount: 1, + Status: "Completed", + StartedAt: &startTs, + FinishedAt: &completionTs, + ExpiresAt: &expirationTs, + IncludedApps: []types.App{}, + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "0B", + }, + }, + }, + }, + { + name: "if improved dr, timestamps are populated based on the timestamps of the included backups", + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset( + veleroNamespaceConfigmap, + veleroDeployment, + ), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset( + testBsl, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "infra-backup", + Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "aggregated-repl-backup", + }, + Annotations: map[string]string{ + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupAnnotation: "true", + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeInfra, + types.InstanceBackupCountAnnotation: "2", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + StartTimestamp: &metav1.Time{Time: startTs}, + CompletionTimestamp: &metav1.Time{Time: completionTs.Add(-1 * time.Minute)}, + Expiration: &metav1.Time{Time: expirationTs.Add(1 * time.Minute)}, + }, + }, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-backup", + Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "aggregated-repl-backup", + }, + Annotations: map[string]string{ + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupAnnotation: "true", + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeApp, + types.InstanceBackupCountAnnotation: "2", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + StartTimestamp: &metav1.Time{Time: startTs.Add(1 * time.Minute)}, + CompletionTimestamp: &metav1.Time{Time: completionTs}, + Expiration: &metav1.Time{Time: expirationTs}, + }, + }, + ).VeleroV1(), + }, + expectedBackups: []*types.Backup{ + { + Name: "aggregated-repl-backup", + ExpectedBackupCount: 2, + BackupCount: 2, + Status: "Completed", + StartedAt: &startTs, + FinishedAt: &completionTs, + ExpiresAt: &expirationTs, + IncludedApps: []types.App{}, + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "0B", + }, + }, + }, + }, + { + name: "volume info is populated from pod volume backups", + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset( + veleroNamespaceConfigmap, + veleroDeployment, + ), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset( + testBsl, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-backup-with-volumes", + Namespace: "velero", + Annotations: map[string]string{ + types.InstanceBackupAnnotation: "true", + "kots.io/snapshot-trigger": "manual", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + &velerov1.PodVolumeBackup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-backup-with-volumes-pod-volume-backup", + Namespace: "velero", + Labels: map[string]string{ + "velero.io/backup-name": "some-backup-with-volumes", + }, + }, + Status: velerov1.PodVolumeBackupStatus{ + Phase: velerov1.PodVolumeBackupPhaseCompleted, + Progress: velerov1.PodVolumeOperationProgress{ + BytesDone: 2000, + }, + }, + }, + ).VeleroV1(), + }, + expectedBackups: []*types.Backup{ + { + Name: "some-backup-with-volumes", + ExpectedBackupCount: 1, + BackupCount: 1, + Status: "Completed", + Trigger: "manual", + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "2kB", + VolumeBytes: 2000, + VolumeSuccessCount: 1, + VolumeCount: 1, + }, + IncludedApps: []types.App{}, + }, + }, + }, + { + name: "if improved dr, volume info is populated from pod volume backups from both backups", + k8sClientBuilder: &k8sclient.MockBuilder{ + Client: fake.NewSimpleClientset( + veleroNamespaceConfigmap, + veleroDeployment, + ), + }, + veleroClientBuilder: &veleroclient.MockBuilder{ + Client: velerofake.NewSimpleClientset( + testBsl, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "infra-backup", + Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "aggregated-repl-backup", + }, + Annotations: map[string]string{ + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupAnnotation: "true", + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeInfra, + types.InstanceBackupCountAnnotation: "2", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-backup", + Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "aggregated-repl-backup", + }, + Annotations: map[string]string{ + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupAnnotation: "true", + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeApp, + types.InstanceBackupCountAnnotation: "2", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseCompleted, + }, + }, + &velerov1.PodVolumeBackup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-backup-pod-volume-backup", + Namespace: "velero", + Labels: map[string]string{ + "velero.io/backup-name": "app-backup", + }, + }, + Status: velerov1.PodVolumeBackupStatus{ + Phase: velerov1.PodVolumeBackupPhaseCompleted, + Progress: velerov1.PodVolumeOperationProgress{ + BytesDone: 2000, + }, + }, + }, + &velerov1.PodVolumeBackup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "infra-backup-pod-volume-backup", + Namespace: "velero", + Labels: map[string]string{ + "velero.io/backup-name": "infra-backup", + }, + }, + Status: velerov1.PodVolumeBackupStatus{ + Phase: velerov1.PodVolumeBackupPhaseCompleted, + Progress: velerov1.PodVolumeOperationProgress{ + BytesDone: 3000, + }, + }, + }, + ).VeleroV1(), + }, + expectedBackups: []*types.Backup{ + { + Name: "aggregated-repl-backup", + ExpectedBackupCount: 2, + BackupCount: 2, + Status: "Completed", + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "5kB", + VolumeBytes: 5000, + VolumeSuccessCount: 2, + VolumeCount: 2, + }, + IncludedApps: []types.App{}, + }, + }, }, { - name: "non instance backups are excluded", + name: "if expected backup count is not equal to actual backup count, it is marked as failed", k8sClientBuilder: &k8sclient.MockBuilder{ Client: fake.NewSimpleClientset( veleroNamespaceConfigmap, @@ -3122,17 +3924,14 @@ func TestListInstanceBackups(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "app-backup", Namespace: "velero", - }, - Status: velerov1.BackupStatus{ - Phase: velerov1.BackupPhaseCompleted, - }, - }, - &velerov1.Backup{ - ObjectMeta: metav1.ObjectMeta{ - Name: "instance-backup", - Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "aggregated-repl-backup", + }, Annotations: map[string]string{ - types.InstanceBackupAnnotation: "true", + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupAnnotation: "true", + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeApp, + types.InstanceBackupCountAnnotation: "2", }, }, Status: velerov1.BackupStatus{ @@ -3141,23 +3940,21 @@ func TestListInstanceBackups(t *testing.T) { }, ).VeleroV1(), }, - expectedBackups: []*types.ReplicatedBackup{ + expectedBackups: []*types.Backup{ { - Name: "instance-backup", - ExpectedBackupCount: 1, - Backups: []types.Backup{ - { - Name: "instance-backup", - Status: "Completed", - IncludedApps: []types.App{}, - VolumeSizeHuman: "0B", - }, + Name: "aggregated-repl-backup", + ExpectedBackupCount: 2, + BackupCount: 1, + Status: "Failed", + IncludedApps: []types.App{}, + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "0B", }, }, }, }, { - name: "new improved dr backups are part of the same replicated backup", + name: "status is in progress if any of the backups are in progress", k8sClientBuilder: &k8sclient.MockBuilder{ Client: fake.NewSimpleClientset( veleroNamespaceConfigmap, @@ -3169,7 +3966,7 @@ func TestListInstanceBackups(t *testing.T) { testBsl, &velerov1.Backup{ ObjectMeta: metav1.ObjectMeta{ - Name: "infra-backup", + Name: "app-backup", Namespace: "velero", Labels: map[string]string{ types.InstanceBackupNameLabel: "aggregated-repl-backup", @@ -3177,7 +3974,7 @@ func TestListInstanceBackups(t *testing.T) { Annotations: map[string]string{ types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, types.InstanceBackupAnnotation: "true", - types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeInfra, + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeApp, types.InstanceBackupCountAnnotation: "2", }, }, @@ -3187,7 +3984,7 @@ func TestListInstanceBackups(t *testing.T) { }, &velerov1.Backup{ ObjectMeta: metav1.ObjectMeta{ - Name: "app-backup", + Name: "infra-backup", Namespace: "velero", Labels: map[string]string{ types.InstanceBackupNameLabel: "aggregated-repl-backup", @@ -3195,47 +3992,31 @@ func TestListInstanceBackups(t *testing.T) { Annotations: map[string]string{ types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, types.InstanceBackupAnnotation: "true", - types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeApp, + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeInfra, types.InstanceBackupCountAnnotation: "2", }, }, Status: velerov1.BackupStatus{ - Phase: velerov1.BackupPhaseCompleted, + Phase: velerov1.BackupPhaseInProgress, }, }, ).VeleroV1(), }, - expectedBackups: []*types.ReplicatedBackup{ + expectedBackups: []*types.Backup{ { Name: "aggregated-repl-backup", ExpectedBackupCount: 2, - Backups: []types.Backup{ - { - Name: "app-backup", - Status: "Completed", - IncludedApps: []types.App{}, - VolumeSizeHuman: "0B", - }, - { - Name: "infra-backup", - Status: "Completed", - IncludedApps: []types.App{}, - VolumeSizeHuman: "0B", - }, + BackupCount: 2, + Status: "InProgress", + IncludedApps: []types.App{}, + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "0B", }, }, }, }, { - name: "included apps are populated ", - setup: func(mockStore *mock_store.MockStore) { - mockStore.EXPECT().GetAppFromSlug("app-1").Times(1).Return(&apptypes.App{ - ID: "1", - Name: "App 1", - Slug: "app-1", - IconURI: "https://some-url.com/icon.png", - }, nil) - }, + name: "status is deleting if any of the backups are deleting and none is in progress", k8sClientBuilder: &k8sclient.MockBuilder{ Client: fake.NewSimpleClientset( veleroNamespaceConfigmap, @@ -3247,89 +4028,57 @@ func TestListInstanceBackups(t *testing.T) { testBsl, &velerov1.Backup{ ObjectMeta: metav1.ObjectMeta{ - Name: "some-backup", + Name: "app-backup", Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "aggregated-repl-backup", + }, Annotations: map[string]string{ - types.InstanceBackupAnnotation: "true", - "kots.io/apps-sequences": "{\"app-1\":1}", + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupAnnotation: "true", + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeApp, + types.InstanceBackupCountAnnotation: "2", }, }, Status: velerov1.BackupStatus{ Phase: velerov1.BackupPhaseCompleted, }, }, - ).VeleroV1(), - }, - expectedBackups: []*types.ReplicatedBackup{ - { - Name: "some-backup", - ExpectedBackupCount: 1, - Backups: []types.Backup{ - { - Name: "some-backup", - Status: "Completed", - IncludedApps: []types.App{ - { - Slug: "app-1", - Sequence: 1, - Name: "App 1", - AppIconURI: "https://some-url.com/icon.png", - }, - }, - VolumeSizeHuman: "0B", - }, - }, - }, - }, - }, - { - name: "timestamps are populated", - k8sClientBuilder: &k8sclient.MockBuilder{ - Client: fake.NewSimpleClientset( - veleroNamespaceConfigmap, - veleroDeployment, - ), - }, - veleroClientBuilder: &veleroclient.MockBuilder{ - Client: velerofake.NewSimpleClientset( - testBsl, &velerov1.Backup{ ObjectMeta: metav1.ObjectMeta{ - Name: "some-backup", + Name: "infra-backup", Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "aggregated-repl-backup", + }, Annotations: map[string]string{ - types.InstanceBackupAnnotation: "true", + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupAnnotation: "true", + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeInfra, + types.InstanceBackupCountAnnotation: "2", }, }, Status: velerov1.BackupStatus{ - Phase: velerov1.BackupPhaseCompleted, - StartTimestamp: &metav1.Time{Time: startTs}, - CompletionTimestamp: &metav1.Time{Time: completionTs}, - Expiration: &metav1.Time{Time: expirationTs}, + Phase: velerov1.BackupPhaseDeleting, }, }, ).VeleroV1(), }, - expectedBackups: []*types.ReplicatedBackup{ + expectedBackups: []*types.Backup{ { - Name: "some-backup", - ExpectedBackupCount: 1, - Backups: []types.Backup{ - { - Name: "some-backup", - Status: "Completed", - StartedAt: &startTs, - FinishedAt: &completionTs, - ExpiresAt: &expirationTs, - IncludedApps: []types.App{}, - VolumeSizeHuman: "0B", - }, + Name: "aggregated-repl-backup", + ExpectedBackupCount: 2, + BackupCount: 2, + Status: "Deleting", + IncludedApps: []types.App{}, + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "0B", }, }, }, }, { - name: "volume info is populated", + name: "status is failed if one backup is failed and the other completed", k8sClientBuilder: &k8sclient.MockBuilder{ Client: fake.NewSimpleClientset( veleroNamespaceConfigmap, @@ -3341,37 +4090,51 @@ func TestListInstanceBackups(t *testing.T) { testBsl, &velerov1.Backup{ ObjectMeta: metav1.ObjectMeta{ - Name: "some-backup-with-volumes", + Name: "app-backup", Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "aggregated-repl-backup", + }, Annotations: map[string]string{ - types.InstanceBackupAnnotation: "true", - "kots.io/snapshot-trigger": "manual", - "kots.io/snapshot-volume-count": "2", - "kots.io/snapshot-volume-success-count": "1", - "kots.io/snapshot-volume-bytes": "1000", + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupAnnotation: "true", + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeApp, + types.InstanceBackupCountAnnotation: "2", }, }, Status: velerov1.BackupStatus{ Phase: velerov1.BackupPhaseCompleted, }, }, + &velerov1.Backup{ + ObjectMeta: metav1.ObjectMeta{ + Name: "infra-backup", + Namespace: "velero", + Labels: map[string]string{ + types.InstanceBackupNameLabel: "aggregated-repl-backup", + }, + Annotations: map[string]string{ + types.InstanceBackupVersionAnnotation: types.InstanceBackupVersionCurrent, + types.InstanceBackupAnnotation: "true", + types.InstanceBackupTypeAnnotation: types.InstanceBackupTypeInfra, + types.InstanceBackupCountAnnotation: "2", + }, + }, + Status: velerov1.BackupStatus{ + Phase: velerov1.BackupPhaseFailed, + }, + }, ).VeleroV1(), }, - expectedBackups: []*types.ReplicatedBackup{ + expectedBackups: []*types.Backup{ { - Name: "some-backup-with-volumes", - ExpectedBackupCount: 1, - Backups: []types.Backup{ - { - Name: "some-backup-with-volumes", - Status: "Completed", - Trigger: "manual", - VolumeSizeHuman: "1kB", - VolumeBytes: 1000, - VolumeSuccessCount: 1, - VolumeCount: 2, - IncludedApps: []types.App{}, - }, + Name: "aggregated-repl-backup", + ExpectedBackupCount: 2, + BackupCount: 2, + Status: "Failed", + IncludedApps: []types.App{}, + VolumeSummary: types.VolumeSummary{ + VolumeSizeHuman: "0B", }, }, }, @@ -3673,3 +4436,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..be97cda5e3 100644 --- a/pkg/kotsadmsnapshot/types/types.go +++ b/pkg/kotsadmsnapshot/types/types.go @@ -2,12 +2,16 @@ package types import ( "strconv" + "strings" "time" velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" ) 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" @@ -40,6 +44,13 @@ const ( // InstanceBackupVersionCurrent is the current backup version. When future breaking changes are // introduced, we can increment this number on backup creation. InstanceBackupVersionCurrent = InstanceBackupVersion1 + // BackupTriggerAnnotation is the annotation used to store the trigger of the backup. + BackupTriggerAnnotation = "kots.io/snapshot-trigger" + // BackupTriggerManual indicates that the backup was triggered manually. + BackupTriggerManual = "manual" + // BackupTriggerSchedule indicates that the backup was triggered by a schedule. + BackupTriggerSchedule = "schedule" + BackupAppsSequencesAnnotation = "kots.io/apps-sequences" ) type App struct { @@ -49,33 +60,43 @@ type App struct { AppIconURI string `json:"iconUri"` } -// ReplicatedBackup holds both the infrastructure and app backups for an EC cluster -type ReplicatedBackup struct { - Name string `json:"name"` - // number of backups expected to exist for the ReplicatedBackup to be considered complete - ExpectedBackupCount int `json:"expectedBackupCount"` - Backups []Backup `json:"backups"` -} +// BackupStatus represents the status of a backup +type BackupStatus string + +const ( + // BackupStatusInProgress indicates that the backup is currently in progress + BackupStatusInProgress BackupStatus = "InProgress" + // BackupStatusCompleted indicates that the backup has been completed successfully + BackupStatusCompleted BackupStatus = "Completed" + // BackupStatusFailed indicates that the backup has failed + BackupStatusFailed BackupStatus = "Failed" + // BackupStatusDeleting indicates that the backup is being deleted + BackupStatusDeleting BackupStatus = "Deleting" +) +// Backup represnts a replicated backup working as an abstraction layer between Replicated and +// Velero backups. These can be either infrastructure/instance, app backups or both. type Backup struct { - Name string `json:"name"` - Status string `json:"status"` - Trigger string `json:"trigger"` - AppID string `json:"appID"` // TODO: remove with app backups - Sequence int64 `json:"sequence"` // TODO: remove with app backups - StartedAt *time.Time `json:"startedAt,omitempty"` - FinishedAt *time.Time `json:"finishedAt,omitempty"` - ExpiresAt *time.Time `json:"expiresAt,omitempty"` - VolumeCount int `json:"volumeCount"` - VolumeSuccessCount int `json:"volumeSuccessCount"` - VolumeBytes int64 `json:"volumeBytes"` - VolumeSizeHuman string `json:"volumeSizeHuman"` - SupportBundleID string `json:"supportBundleId,omitempty"` - IncludedApps []App `json:"includedApps,omitempty"` + Name string `json:"name"` + Status BackupStatus `json:"status"` + Trigger string `json:"trigger"` + AppID string `json:"appID"` // TODO: remove with app backups + Sequence int64 `json:"sequence"` // TODO: remove with app backups + StartedAt *time.Time `json:"startedAt,omitempty"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + SupportBundleID string `json:"supportBundleId,omitempty"` + IncludedApps []App `json:"includedApps,omitempty"` + // number of velero backups expected to exist for the Backup to be considered done + ExpectedBackupCount int `json:"expectedBackupCount"` + // number of velero backups that actually exist + BackupCount int `json:"backupCount"` + VolumeSummary } type BackupDetail struct { Name string `json:"name"` + Type string `json:"type"` Status string `json:"status"` VolumeSizeHuman string `json:"volumeSizeHuman"` Namespaces []string `json:"namespaces"` @@ -227,3 +248,45 @@ func GetInstanceBackupCount(veleroBackup velerov1.Backup) int { } return 1 } + +// GetBackupTrigger returns the trigger of the backup from the velero backup object annotation. +func GetBackupTrigger(veleroBackup velerov1.Backup) string { + if val, ok := veleroBackup.GetAnnotations()[BackupTriggerAnnotation]; ok { + return val + } + return "" +} + +// GetStatusFromBackupPhase returns our backup status from the velero backup phase. +func GetStatusFromBackupPhase(phase velerov1.BackupPhase) BackupStatus { + switch { + case phase == velerov1.BackupPhaseNew || phase == velerov1.BackupPhaseInProgress: + return BackupStatusInProgress + case phase == velerov1.BackupPhaseCompleted: + return BackupStatusCompleted + case strings.Contains(strings.ToLower(string(phase)), "fail"): + return BackupStatusFailed + case phase == velerov1.BackupPhaseDeleting: + return BackupStatusDeleting + default: + return BackupStatusInProgress + } +} + +// RollupStatus returns the overall status of a list of backup statuses. This is particularly useful when we have multiple +// velero backups for a single Replicated backup. +func RollupStatus(backupStatuses []BackupStatus) BackupStatus { + result := BackupStatusCompleted + + for _, backupStatus := range backupStatuses { + switch { + case backupStatus == BackupStatusInProgress: + return BackupStatusInProgress + case backupStatus == BackupStatusDeleting: + result = BackupStatusDeleting + case backupStatus == BackupStatusFailed && result != BackupStatusDeleting: + result = BackupStatusFailed + } + } + return result +} diff --git a/pkg/kotsadmsnapshot/types/types_test.go b/pkg/kotsadmsnapshot/types/types_test.go new file mode 100644 index 0000000000..6c14bfb1b8 --- /dev/null +++ b/pkg/kotsadmsnapshot/types/types_test.go @@ -0,0 +1,144 @@ +package types + +import ( + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" +) + +func TestRollupStatus(t *testing.T) { + tests := []struct { + backupStatuses []BackupStatus + expected BackupStatus + }{ + { + backupStatuses: []BackupStatus{ + BackupStatusInProgress, + BackupStatusInProgress, + }, + expected: BackupStatusInProgress, + }, + { + backupStatuses: []BackupStatus{ + BackupStatusInProgress, + BackupStatusFailed, + }, + expected: BackupStatusInProgress, + }, + { + backupStatuses: []BackupStatus{ + BackupStatusInProgress, + BackupStatusCompleted, + }, + expected: BackupStatusInProgress, + }, + { + backupStatuses: []BackupStatus{ + BackupStatusInProgress, + BackupStatusDeleting, + }, + expected: BackupStatusInProgress, + }, + { + backupStatuses: []BackupStatus{ + BackupStatusDeleting, + BackupStatusDeleting, + }, + expected: BackupStatusDeleting, + }, + { + backupStatuses: []BackupStatus{ + BackupStatusDeleting, + BackupStatusFailed, + }, + expected: BackupStatusDeleting, + }, + { + backupStatuses: []BackupStatus{ + BackupStatusDeleting, + BackupStatusCompleted, + }, + expected: BackupStatusDeleting, + }, + { + backupStatuses: []BackupStatus{ + BackupStatusFailed, + BackupStatusFailed, + }, + expected: BackupStatusFailed, + }, + { + backupStatuses: []BackupStatus{ + BackupStatusFailed, + BackupStatusCompleted, + }, + expected: BackupStatusFailed, + }, + { + backupStatuses: []BackupStatus{ + BackupStatusCompleted, + BackupStatusCompleted, + }, + expected: BackupStatusCompleted, + }, + } + + for _, test := range tests { + name := "" + for _, status := range test.backupStatuses { + name += string(status) + "-" + } + name = strings.TrimSuffix(name, "-") + t.Run(name, func(t *testing.T) { + assert.Equal(t, test.expected, RollupStatus(test.backupStatuses)) + // Reverse the order of the statuses and check if the result is the same + slices.Reverse(test.backupStatuses) + assert.Equal(t, test.expected, RollupStatus(test.backupStatuses)) + }) + } +} + +func TestGetStatusFromBackupPhase(t *testing.T) { + tests := []struct { + phase string + expected BackupStatus + }{ + { + phase: string(velerov1.BackupPhaseNew), + expected: BackupStatusInProgress, + }, + { + phase: string(velerov1.BackupPhaseInProgress), + expected: BackupStatusInProgress, + }, + { + phase: string(velerov1.BackupPhaseCompleted), + expected: BackupStatusCompleted, + }, + { + phase: string(velerov1.BackupPhaseFailed), + expected: BackupStatusFailed, + }, + { + phase: "SomeNewFailState", + expected: BackupStatusFailed, + }, + { + phase: string(velerov1.BackupPhaseDeleting), + expected: BackupStatusDeleting, + }, + { + phase: "SomeUnknownNewState", + expected: BackupStatusInProgress, + }, + } + + for _, test := range tests { + t.Run(test.phase, func(t *testing.T) { + assert.Equal(t, test.expected, GetStatusFromBackupPhase(velerov1.BackupPhase(test.phase))) + }) + } +} 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)) { diff --git a/web/src/components/snapshots/Snapshots.jsx b/web/src/components/snapshots/Snapshots.jsx index d21f064fee..a2607c68f3 100644 --- a/web/src/components/snapshots/Snapshots.jsx +++ b/web/src/components/snapshots/Snapshots.jsx @@ -108,12 +108,8 @@ class Snapshots extends Component { } const response = await res.json(); - const snapshots = response.backups?.flatMap( - (replBackup) => replBackup?.backups - ); - this.setState({ - snapshots: snapshots?.sort((a, b) => + snapshots: response.backups?.sort((a, b) => b.startedAt ? new Date(b.startedAt) - new Date(a.startedAt) : -99999999