Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 98 additions & 4 deletions backup/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"net/url"
"os"
"path/filepath"
"strings"
"time"

"github.com/golang/glog"
Expand Down Expand Up @@ -49,7 +50,11 @@ var opt struct {
destination string
format string
verbose bool
upgrade bool // used by export backup command.
upgrade bool // used by export backup command
sinceDate string // date-range filter lower bound (YYYY-MM-DD or RFC3339)
untilDate string // date-range filter upper bound
lastNDays int // shorthand: last N calendar days
summary bool // print summary stats after the listing
}

func init() {
Expand Down Expand Up @@ -158,7 +163,15 @@ func initBackupLs() {
flag.StringVarP(&opt.location, "location", "l", "",
"Sets the source location URI (required).")
flag.BoolVar(&opt.verbose, "verbose", false,
"Outputs additional info in backup list.")
"Show groups and drop operations. Reads the full manifest.json instead of the lightweight summary.")
flag.StringVar(&opt.sinceDate, "since-date", "",
"Only show backups on or after this date (YYYY-MM-DD or RFC3339).")
flag.StringVar(&opt.untilDate, "until-date", "",
"Only show backups on or before this date (YYYY-MM-DD or RFC3339).")
flag.IntVar(&opt.lastNDays, "last-n-days", 0,
"Only show backups from the last N calendar days.")
flag.BoolVar(&opt.summary, "summary", false,
"Print summary statistics after the backup listing.")
_ = LsBackup.Cmd.MarkFlagRequired("location")
}

Expand Down Expand Up @@ -247,11 +260,70 @@ func runRestoreCmd() error {
return nil
}

// parseFilterDate parses a date string in YYYY-MM-DD or RFC3339 format.
func parseFilterDate(s string) (time.Time, error) {
if s == "" {
return time.Time{}, fmt.Errorf("date string must not be empty")
}
if t, err := time.Parse("2006-01-02", s); err == nil {
return t.UTC(), nil
}
t, err := time.Parse(time.RFC3339, s)
return t.UTC(), err
}

// buildLsFilter constructs a BackupDateFilter from lsbackup flag values.
// It validates flag combinations and returns an error for invalid inputs.
func buildLsFilter(sinceDate, untilDate string, lastNDays int) (worker.BackupDateFilter, error) {
filter := worker.BackupDateFilter{}
if lastNDays < 0 {
return filter, fmt.Errorf("--last-n-days must be a positive integer, got %d", lastNDays)
}
if lastNDays > 0 && sinceDate != "" {
return filter, fmt.Errorf("--last-n-days and --since-date are mutually exclusive")
}
if lastNDays > 0 {
since := time.Now().UTC().AddDate(0, 0, -lastNDays).Truncate(24 * time.Hour)
filter.Since = &since
}
if sinceDate != "" {
t, err := parseFilterDate(sinceDate)
if err != nil {
return filter, fmt.Errorf("invalid --since-date value %q: %w", sinceDate, err)
}
filter.Since = &t
}
if untilDate != "" {
t, err := parseFilterDate(untilDate)
if err != nil {
return filter, fmt.Errorf("invalid --until-date value %q: %w", untilDate, err)
}
var end time.Time
if strings.Contains(untilDate, "T") {
// RFC3339 datetime: user gave an exact timestamp, respect it.
end = t
} else {
// Plain date (YYYY-MM-DD): extend to end of that calendar day.
end = t.Add(24*time.Hour - time.Millisecond)
}
filter.Until = &end
}
return filter, nil
}

func runLsbackupCmd() error {
manifests, err := worker.ListBackupManifests(opt.location, nil)
// Validate and build the date filter before doing any I/O.
filter, err := buildLsFilter(opt.sinceDate, opt.untilDate, opt.lastNDays)
if err != nil {
return err
}

// --verbose reads the full manifest.json to include groups and drop-ops.
manifests, err := worker.ListBackupManifests(opt.location, nil, opt.verbose)
if err != nil {
return fmt.Errorf("while listing manifests: %w", err)
}
manifests = worker.FilterManifestsByDate(manifests, filter)

type backupEntry struct {
Path string `json:"path"`
Expand All @@ -269,7 +341,6 @@ func runLsbackupCmd() error {

var output backupOutput
for _, manifest := range manifests {

be := backupEntry{
Path: manifest.Path,
Since: manifest.SinceTsDeprecated,
Expand All @@ -291,6 +362,29 @@ func runLsbackupCmd() error {
}
_, _ = os.Stdout.Write(b)
fmt.Println()

if opt.summary {
stats := worker.ComputeBackupListStats(manifests)
if opt.sinceDate != "" || opt.untilDate != "" || opt.lastNDays > 0 {
fmt.Fprintf(os.Stderr, "\n--- Backup Summary (filtered) ---\n")
} else {
fmt.Fprintf(os.Stderr, "\n--- Backup Summary ---\n")
}
fmt.Fprintf(os.Stderr, "Total backups listed : %d\n", stats.Total)
fmt.Fprintf(os.Stderr, "Backup series : %d\n", stats.BackupSeriesCount)
if stats.OldestBackup != nil {
fmt.Fprintf(os.Stderr, "Oldest backup : %s\n", stats.OldestBackup.Format(time.RFC3339))
}
if stats.NewestBackup != nil {
fmt.Fprintf(os.Stderr, "Newest backup : %s\n", stats.NewestBackup.Format(time.RFC3339))
}
if stats.LastFullBackup != nil {
fmt.Fprintf(os.Stderr, "Last full backup : %s\n", stats.LastFullBackup.Format(time.RFC3339))
}
if stats.LastIncrBackup != nil {
fmt.Fprintf(os.Stderr, "Last incr backup : %s\n", stats.LastIncrBackup.Format(time.RFC3339))
}
}
return nil
}

Expand Down
203 changes: 203 additions & 0 deletions backup/run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/*
* SPDX-FileCopyrightText: © 2017-2025 Istari Digital, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

package backup

import (
"testing"
"time"

"github.com/stretchr/testify/require"
)

func TestParseFilterDate(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
wantYear int
wantMonth time.Month
wantDay int
wantHour int
wantMin int
}{
{
name: "YYYY-MM-DD",
input: "2026-04-15",
wantYear: 2026,
wantMonth: time.April,
wantDay: 15,
},
{
name: "RFC3339 UTC",
input: "2026-01-01T00:00:00Z",
wantYear: 2026,
wantMonth: time.January,
wantDay: 1,
},
{
name: "RFC3339 with positive offset normalised to UTC",
input: "2026-04-15T06:30:00+05:30", // 06:30 IST == 01:00 UTC
wantYear: 2026,
wantMonth: time.April,
wantDay: 15,
wantHour: 1,
wantMin: 0,
},
{
name: "RFC3339 with negative offset normalised to UTC",
input: "2026-04-15T00:00:00-05:00", // midnight EST == 05:00 UTC
wantYear: 2026,
wantMonth: time.April,
wantDay: 15,
wantHour: 5,
},
{
name: "empty string returns error",
input: "",
wantErr: true,
},
{
name: "invalid string",
input: "not-a-date",
wantErr: true,
},
{
name: "partial date without day",
input: "2026-04",
wantErr: true,
},
{
name: "RFC3339 without timezone is invalid",
input: "2026-04-15T10:00:00",
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := parseFilterDate(tc.input)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, time.UTC, got.Location(), "result must be UTC")
require.Equal(t, tc.wantYear, got.Year())
require.Equal(t, tc.wantMonth, got.Month())
require.Equal(t, tc.wantDay, got.Day())
require.Equal(t, tc.wantHour, got.Hour())
require.Equal(t, tc.wantMin, got.Minute())
})
}
}

func TestBuildLsFilter(t *testing.T) {
t.Run("negative lastNDays returns error", func(t *testing.T) {
_, err := buildLsFilter("", "", -1)
require.Error(t, err)
require.Contains(t, err.Error(), "--last-n-days")
require.Contains(t, err.Error(), "-1")
})

t.Run("lastNDays and sinceDate together return error", func(t *testing.T) {
_, err := buildLsFilter("2026-01-01", "", 7)
require.Error(t, err)
require.Contains(t, err.Error(), "--last-n-days")
require.Contains(t, err.Error(), "--since-date")
})

t.Run("invalid sinceDate returns error naming the flag", func(t *testing.T) {
_, err := buildLsFilter("not-a-date", "", 0)
require.Error(t, err)
require.Contains(t, err.Error(), "--since-date")
})

t.Run("invalid untilDate returns error naming the flag", func(t *testing.T) {
_, err := buildLsFilter("", "not-a-date", 0)
require.Error(t, err)
require.Contains(t, err.Error(), "--until-date")
})

t.Run("all empty returns filter with nil Since and Until", func(t *testing.T) {
f, err := buildLsFilter("", "", 0)
require.NoError(t, err)
require.Nil(t, f.Since)
require.Nil(t, f.Until)
})

t.Run("lastNDays=0 does not set Since", func(t *testing.T) {
f, err := buildLsFilter("", "", 0)
require.NoError(t, err)
require.Nil(t, f.Since)
})

t.Run("lastNDays=7 sets Since to 7 days ago midnight UTC", func(t *testing.T) {
expected := time.Now().UTC().AddDate(0, 0, -7).Truncate(24 * time.Hour)
f, err := buildLsFilter("", "", 7)
require.NoError(t, err)
require.NotNil(t, f.Since)
require.Nil(t, f.Until)
require.WithinDuration(t, expected, *f.Since, time.Second)
require.Equal(t, 0, f.Since.Hour(), "Since must be at midnight")
})

t.Run("sinceDate YYYY-MM-DD sets Since to midnight UTC of that date", func(t *testing.T) {
f, err := buildLsFilter("2026-03-01", "", 0)
require.NoError(t, err)
require.NotNil(t, f.Since)
require.Nil(t, f.Until)
require.Equal(t, 2026, f.Since.Year())
require.Equal(t, time.March, f.Since.Month())
require.Equal(t, 1, f.Since.Day())
require.Equal(t, 0, f.Since.Hour())
})

t.Run("sinceDate RFC3339 uses exact time without midnight forcing", func(t *testing.T) {
f, err := buildLsFilter("2026-03-01T08:30:00Z", "", 0)
require.NoError(t, err)
require.NotNil(t, f.Since)
require.Equal(t, 8, f.Since.Hour())
require.Equal(t, 30, f.Since.Minute())
require.Equal(t, 0, f.Since.Second())
require.Equal(t, time.UTC, f.Since.Location())
})

t.Run("untilDate YYYY-MM-DD extends to end of that calendar day", func(t *testing.T) {
f, err := buildLsFilter("", "2026-03-31", 0)
require.NoError(t, err)
require.Nil(t, f.Since)
require.NotNil(t, f.Until)
// Should be 2026-03-31 23:59:59.999 UTC
require.Equal(t, 2026, f.Until.Year())
require.Equal(t, time.March, f.Until.Month())
require.Equal(t, 31, f.Until.Day())
require.Equal(t, 23, f.Until.Hour())
require.Equal(t, 59, f.Until.Minute())
require.Equal(t, 59, f.Until.Second())
})

t.Run("untilDate RFC3339 uses exact time without end-of-day extension", func(t *testing.T) {
f, err := buildLsFilter("", "2026-03-31T12:00:00Z", 0)
require.NoError(t, err)
require.NotNil(t, f.Until)
// Must be exactly 12:00:00, not extended to 23:59:59
require.Equal(t, 12, f.Until.Hour())
require.Equal(t, 0, f.Until.Minute())
require.Equal(t, 0, f.Until.Second())
})

t.Run("sinceDate and untilDate both set", func(t *testing.T) {
f, err := buildLsFilter("2026-01-01", "2026-12-31", 0)
require.NoError(t, err)
require.NotNil(t, f.Since)
require.NotNil(t, f.Until)
require.Equal(t, time.January, f.Since.Month())
require.Equal(t, 1, f.Since.Day())
require.Equal(t, time.December, f.Until.Month())
require.Equal(t, 31, f.Until.Day())
require.Equal(t, 23, f.Until.Hour())
})

}
21 changes: 21 additions & 0 deletions graphql/admin/endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,27 @@ const adminTypes = `
"""
anonymous: Boolean

"""
Only return backups taken on or after this date (YYYY-MM-DD or RFC3339).
"""
sinceDate: String

"""
Only return backups on or before this date. For YYYY-MM-DD the entire day is
included (up to 23:59:59 UTC). For RFC3339 the exact timestamp is used as the cutoff.
"""
untilDate: String

"""
Only return backups from the last N calendar days. Cannot be combined with sinceDate.
"""
lastNDays: Int

"""
When true, reads the full manifest.json and includes predicate groups and drop
operations. Defaults to false, which reads the lightweight manifest_summary.json.
"""
fullManifest: Boolean
}

type BackupGroup {
Expand Down
Loading
Loading