Print n/a for unset timestamps in backup and restore get (#10206)

velero backup get prints <nil> in CREATED for a backup that never
started, and velero restore get prints it in both STARTED and COMPLETED.
The timestamps are *metav1.Time and are appended to the row unformatted,
so a nil pointer reaches the user as Go's nil literal.

This is reachable in ordinary use. A backup that fails validation never
starts, so StartTimestamp is never set, and a restore that fails
validation gets neither timestamp.

formatTimestamp returns n/a for an unset value, matching
humanReadableTimeFromNow, which already handles a zero expiration in the
same row. A set timestamp is unchanged.

Adds tests for both printers, which had no row-level coverage.

Signed-off-by: saral <ilovegojo2580@gmail.com>
This commit is contained in:
Ralthos
2026-08-12 15:42:53 +08:00
committed by GitHub
parent 9a1d2e6eb0
commit 8d275e69cc
5 changed files with 148 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
Show n/a instead of <nil> for unset timestamps in velero backup get and velero restore get
+1 -1
View File
@@ -107,7 +107,7 @@ func printBackup(backup *velerov1api.Backup) []metav1.TableRow {
status,
backup.Status.Errors,
backup.Status.Warnings,
backup.Status.StartTimestamp,
formatTimestamp(backup.Status.StartTimestamp),
humanReadableTimeFromNow(expiration),
backup.Spec.StorageLocation,
queuePosition(backup.Status.QueuePosition),
+14
View File
@@ -248,3 +248,17 @@ func NewPrinter(cmd *cobra.Command) (printers.ResourcePrinter, error) {
return printer, nil
}
// formatTimestamp renders an optional timestamp for a table cell.
//
// Appending a nil *metav1.Time to a row prints "<nil>", which reaches the user
// for any object that has not reached the phase that sets the field: a backup
// that failed validation never gets a start time, and a restore that failed
// validation gets neither a start nor a completion time. An unset timestamp
// shows as "n/a" instead, matching humanReadableTimeFromNow in the same row.
func formatTimestamp(t *metav1.Time) string {
if t == nil || t.IsZero() {
return "n/a"
}
return t.String()
}
@@ -0,0 +1,130 @@
/*
Copyright the Velero contributors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package output
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
)
func TestFormatTimestamp(t *testing.T) {
set := metav1.NewTime(time.Date(2026, 8, 8, 21, 6, 28, 0, time.UTC))
tests := []struct {
name string
input *metav1.Time
want string
}{
{
name: "nil renders as n/a",
input: nil,
want: "n/a",
},
{
name: "zero value renders as n/a",
input: &metav1.Time{},
want: "n/a",
},
{
name: "a set timestamp is unchanged",
input: &set,
want: set.String(),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, formatTimestamp(tc.input))
})
}
}
// A backup that fails validation never starts, so StartTimestamp stays nil.
func TestPrintBackupWithoutStartTimestamp(t *testing.T) {
backup := &velerov1api.Backup{
ObjectMeta: metav1.ObjectMeta{Name: "failed-validation"},
Status: velerov1api.BackupStatus{
Phase: velerov1api.BackupPhaseFailedValidation,
},
}
rows := printBackup(backup)
require.Len(t, rows, 1)
// Name, Status, Errors, Warnings, Created, ...
assert.Equal(t, "n/a", rows[0].Cells[4], "unset start time should not print as <nil>")
assert.Equal(t, string(velerov1api.BackupPhaseFailedValidation), rows[0].Cells[1])
}
func TestPrintBackupWithStartTimestamp(t *testing.T) {
started := metav1.NewTime(time.Date(2026, 8, 8, 21, 6, 28, 0, time.UTC))
backup := &velerov1api.Backup{
ObjectMeta: metav1.ObjectMeta{Name: "completed"},
Status: velerov1api.BackupStatus{
Phase: velerov1api.BackupPhaseCompleted,
StartTimestamp: &started,
},
}
rows := printBackup(backup)
require.Len(t, rows, 1)
assert.Equal(t, started.String(), rows[0].Cells[4])
}
// A restore that fails validation gets neither timestamp.
func TestPrintRestoreWithoutTimestamps(t *testing.T) {
restore := &velerov1api.Restore{
ObjectMeta: metav1.ObjectMeta{Name: "failed-validation"},
Spec: velerov1api.RestoreSpec{BackupName: "does-not-exist"},
Status: velerov1api.RestoreStatus{
Phase: velerov1api.RestorePhaseFailedValidation,
},
}
rows := printRestore(restore)
require.Len(t, rows, 1)
// Name, Backup, Status, Started, Completed, ...
assert.Equal(t, "n/a", rows[0].Cells[3], "unset start time should not print as <nil>")
assert.Equal(t, "n/a", rows[0].Cells[4], "unset completion time should not print as <nil>")
}
func TestPrintRestoreWithTimestamps(t *testing.T) {
started := metav1.NewTime(time.Date(2026, 8, 8, 21, 9, 40, 0, time.UTC))
completed := metav1.NewTime(time.Date(2026, 8, 8, 21, 9, 41, 0, time.UTC))
restore := &velerov1api.Restore{
ObjectMeta: metav1.ObjectMeta{Name: "completed"},
Spec: velerov1api.RestoreSpec{BackupName: "nightly-1"},
Status: velerov1api.RestoreStatus{
Phase: velerov1api.RestorePhaseCompleted,
StartTimestamp: &started,
CompletionTimestamp: &completed,
},
}
rows := printRestore(restore)
require.Len(t, rows, 1)
assert.Equal(t, started.String(), rows[0].Cells[3])
assert.Equal(t, completed.String(), rows[0].Cells[4])
}
+2 -2
View File
@@ -62,8 +62,8 @@ func printRestore(restore *v1.Restore) []metav1.TableRow {
restore.Name,
restore.Spec.BackupName,
status,
restore.Status.StartTimestamp,
restore.Status.CompletionTimestamp,
formatTimestamp(restore.Status.StartTimestamp),
formatTimestamp(restore.Status.CompletionTimestamp),
restore.Status.Errors,
restore.Status.Warnings,
restore.CreationTimestamp.Time,