Skip to content
Open
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
1 change: 1 addition & 0 deletions changelogs/unreleased/9345-itrooz
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Make TTL accept day/week/month/year units
4 changes: 3 additions & 1 deletion config/crd/v1/bases/velero.io_backups.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -495,8 +495,10 @@ spec:
type: string
ttl:
description: |-
TTL is a time.Duration-parseable string describing how long
TTL is a time.Duration-compatible string describing how long
the Backup should be retained for.
Supports time.Duration units + day (d), week (w), month (mo), and year (y).
Note that days, months, and years are static durations: 1d = 24h, 1mo = 31d, 1y = 366d.
type: string
uploaderConfig:
description: UploaderConfig specifies the configuration for the uploader.
Expand Down
4 changes: 3 additions & 1 deletion config/crd/v1/bases/velero.io_schedules.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -536,8 +536,10 @@ spec:
type: string
ttl:
description: |-
TTL is a time.Duration-parseable string describing how long
TTL is a time.Duration-compatible string describing how long
the Backup should be retained for.
Supports time.Duration units + day (d), week (w), month (mo), and year (y).
Note that days, months, and years are static durations: 1d = 24h, 1mo = 31d, 1y = 366d.
type: string
uploaderConfig:
description: UploaderConfig specifies the configuration for the
Expand Down
4 changes: 2 additions & 2 deletions config/crd/v1/crds/crds.go

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion pkg/apis/velero/v1/backup_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,10 @@ type BackupSpec struct {
// +nullable
SnapshotVolumes *bool `json:"snapshotVolumes,omitempty"`

// TTL is a time.Duration-parseable string describing how long
// TTL is a time.Duration-compatible string describing how long
// the Backup should be retained for.
// Supports time.Duration units + day (d), week (w), month (mo), and year (y).
// Note that days, months, and years are static durations: 1d = 24h, 1mo = 31d, 1y = 366d.
// +optional
TTL metav1.Duration `json:"ttl,omitempty"`

Expand Down
6 changes: 3 additions & 3 deletions pkg/cmd/cli/backup/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func NewCreateCommand(f client.Factory, use string) *cobra.Command {

type CreateOptions struct {
Name string
TTL time.Duration
TTL flag.Duration
SnapshotVolumes flag.OptionalBool
SnapshotMoveData flag.OptionalBool
DataMover string
Expand Down Expand Up @@ -121,7 +121,7 @@ func NewCreateOptions() *CreateOptions {
}

func (o *CreateOptions) BindFlags(flags *pflag.FlagSet) {
flags.DurationVar(&o.TTL, "ttl", o.TTL, "How long before the backup can be garbage collected.")
flags.Var(&o.TTL, "ttl", "How long before the backup can be garbage collected.")
flags.Var(&o.IncludeNamespaces, "include-namespaces", "Namespaces to include in the backup (use '*' for all namespaces).")
flags.Var(&o.ExcludeNamespaces, "exclude-namespaces", "Namespaces to exclude from the backup.")
flags.Var(&o.IncludeResources, "include-resources", "Resources to include in the backup, formatted as resource.group, such as storageclasses.storage.k8s.io (use '*' for all resources). Cannot work with include-cluster-scoped-resources, exclude-cluster-scoped-resources, include-namespace-scoped-resources and exclude-namespace-scoped-resources.")
Expand Down Expand Up @@ -388,7 +388,7 @@ func (o *CreateOptions) BuildBackup(namespace string) (*velerov1api.Backup, erro
ExcludedNamespaceScopedResources(o.ExcludeNamespaceScopedResources...).
LabelSelector(o.Selector.LabelSelector).
OrLabelSelector(o.OrSelector.OrLabelSelectors).
TTL(o.TTL).
TTL(o.TTL.Duration).
StorageLocation(o.StorageLocation).
VolumeSnapshotLocations(o.SnapshotLocations...).
CSISnapshotTimeout(o.CSISnapshotTimeout).
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/cli/backup/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func TestCreateOptions_BuildBackup(t *testing.T) {
require.NoError(t, err)

assert.Equal(t, velerov1api.BackupSpec{
TTL: metav1.Duration{Duration: o.TTL},
TTL: metav1.Duration{Duration: o.TTL.Duration},
IncludedNamespaces: []string(o.IncludeNamespaces),
SnapshotVolumes: o.SnapshotVolumes.Value,
IncludeClusterResources: o.IncludeClusterResources.Value,
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/cli/schedule/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ func (o *CreateOptions) Run(c *cobra.Command, f client.Factory) error {
LabelSelector: o.BackupOptions.Selector.LabelSelector,
OrLabelSelectors: o.BackupOptions.OrSelector.OrLabelSelectors,
SnapshotVolumes: o.BackupOptions.SnapshotVolumes.Value,
TTL: metav1.Duration{Duration: o.BackupOptions.TTL},
TTL: metav1.Duration{Duration: o.BackupOptions.TTL.Duration},
StorageLocation: o.BackupOptions.StorageLocation,
VolumeSnapshotLocations: o.BackupOptions.SnapshotLocations,
DefaultVolumesToFsBackup: o.BackupOptions.DefaultVolumesToFsBackup.Value,
Expand Down
105 changes: 105 additions & 0 deletions pkg/cmd/util/flag/duration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package flag

import (
"fmt"
"strconv"
"strings"
"time"
"unicode"

"k8s.io/apimachinery/pkg/util/duration"
)

// Wrapper around time.Duration with a parser that accepts days, months, years as valid units.
type Duration struct {
time.Duration
}

// unit map: symbol -> seconds
var unitMap = map[string]uint64{
"ns": uint64(time.Nanosecond),
"us": uint64(time.Microsecond),
"µs": uint64(time.Microsecond), // U+00B5 = micro symbol
"μs": uint64(time.Microsecond), // U+03BC = Greek letter mu
"ms": uint64(time.Millisecond),
"s": uint64(time.Second),
"m": uint64(time.Minute),
"h": uint64(time.Hour),
"d": uint64(24 * time.Hour),
"w": uint64(7 * 24 * time.Hour),
"mo": uint64(31 * 24 * time.Hour),
"y": uint64(366 * 24 * time.Hour),
}

// ParseDuration parses strings like "2d5h10.5m"
// it does not support negative durations.
// units are static and over-provisioned: 1d=24h, 1mo=31d, 1y=366d
func ParseDuration(s string) (Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return Duration{Duration: 0}, nil
}

var total float64
i := 0
n := len(s)

for i < n {
// Get number (including decimal point)
j := i
hasDot := false
for j < n && (unicode.IsDigit(rune(s[j])) || (s[j] == '.' && !hasDot)) {
if s[j] == '.' {
hasDot = true
}
j++
}
if j == i {
return Duration{}, fmt.Errorf("expected number at pos %d", i)
}
numStr := s[i:j]
num, err := strconv.ParseFloat(numStr, 64)
if err != nil {
return Duration{}, err
}

// Get unit
k := j
for k < n && unicode.IsLetter(rune(s[k])) {
k++
}
if k == j {
return Duration{}, fmt.Errorf("missing unit after number at pos %d", j)
}
unit := strings.ToLower(s[j:k])

// Query value for unit
val, ok := unitMap[unit]
if !ok {
return Duration{}, fmt.Errorf("unknown unit %q", unit)
}
// Add to total
total += num * float64(val)

i = k
for i < n && s[i] == ' ' {
i++
}
}

// Convert to time.Duration (nanoseconds)
return Duration{Duration: time.Duration(total)}, nil
}

func (d *Duration) String() string { return duration.ShortHumanDuration(d.Duration) }

func (d *Duration) Set(s string) error {
parsed, err := ParseDuration(s)
if err != nil {
return err
}
*d = parsed
return nil
}

func (d *Duration) Type() string { return "duration" }
193 changes: 193 additions & 0 deletions pkg/cmd/util/flag/duration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package flag

import (
"testing"
"time"
)

func TestParseDuration(t *testing.T) {
tests := []struct {
name string
input string
expected time.Duration
wantErr bool
}{
// Valid cases
{
name: "empty string",
input: "",
expected: 0,
wantErr: false,
},
{
name: "whitespace only",
input: " ",
expected: 0,
wantErr: false,
},
{
name: "seconds only",
input: "30s",
expected: 30 * time.Second,
wantErr: false,
},
{
name: "hours only",
input: "2h",
expected: 2 * time.Hour,
wantErr: false,
},
{
name: "days only",
input: "3d",
expected: 3 * 24 * time.Hour,
wantErr: false,
},
{
name: "weeks only",
input: "1w",
expected: 7 * 24 * time.Hour,
wantErr: false,
},
{
name: "months only",
input: "2mo",
expected: 2 * 31 * 24 * time.Hour,
wantErr: false,
},
{
name: "years only",
input: "1y",
expected: 366 * 24 * time.Hour,
wantErr: false,
},
{
name: "combined units",
input: "2d5h10m30s",
expected: 2*24*time.Hour + 5*time.Hour + 10*time.Minute + 30*time.Second,
wantErr: false,
},
{
name: "combined with spaces",
input: "1d 12h 30m",
expected: 24*time.Hour + 12*time.Hour + 30*time.Minute,
wantErr: false,
},
{
name: "mixed case units",
input: "1D 2h 3M 4s",
expected: 24*time.Hour + 2*time.Hour + 3*time.Minute + 4*time.Second,
wantErr: false,
},
{
name: "zero values",
input: "0d0h0m0s",
expected: 0,
wantErr: false,
},
{
name: "large numbers",
input: "99999999999ms",
expected: 99999999999 * time.Millisecond,
wantErr: false,
},

// Fractional values
{
name: "basic fraction",
input: "5.5s",
expected: time.Duration(5.5 * float64(time.Second)),
wantErr: false,
},
{
name: "fraction with no decimal part",
input: "5.s",
expected: time.Duration(5 * float64(time.Second)),
wantErr: false,
},
{
name: "fractional with multiple decimals",
input: "1.25h30.5m",
expected: time.Duration(1.25*float64(time.Hour) + 30.5*float64(time.Minute)),
wantErr: false,
},
{
name: "fractional zero",
input: "0.0s",
expected: 0,
wantErr: false,
},
{
name: "mixed integer and fractional",
input: "2h1.5m30s",
expected: 2*time.Hour + time.Duration(1.5*float64(time.Minute)) + 30*time.Second,
wantErr: false,
},

// Error cases
{
name: "invalid characters",
input: "abc",
expected: 0,
wantErr: true,
},
{
name: "unit without number",
input: "s",
expected: 0,
wantErr: true,
},
{
name: "number without unit",
input: "123",
expected: 0,
wantErr: true,
},
{
name: "invalid unit",
input: "5x",
expected: 0,
wantErr: true,
},
{
name: "number with invalid character",
input: "5a5s",
expected: 0,
wantErr: true,
},
{
name: "negative number",
input: "-5s",
expected: 0,
wantErr: true,
},
{
name: "multiple decimal points",
input: "5.5.5s",
expected: 0,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseDuration(tt.input)

if tt.wantErr {
if err == nil {
t.Errorf("ParseDuration(%q) expected error, got nil", tt.input)
}
return
}

if err != nil {
t.Errorf("ParseDuration(%q) unexpected error: %v", tt.input, err)
return
}

if result.Duration != tt.expected {
t.Errorf("ParseDuration(%q) = %v, want %v", tt.input, result.Duration, tt.expected)
}
})
}
}
2 changes: 1 addition & 1 deletion site/content/docs/main/how-velero-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ When you create a backup, you can specify a TTL (time to live) by adding the fla
* All PersistentVolume snapshots
* All associated Restores

The TTL flag allows the user to specify the backup retention period with the value specified in hours, minutes and seconds in the form `--ttl 24h0m0s`. If not specified, a default TTL value of 30 days will be applied.
The TTL flag allows the user to specify the backup retention period with the value in the form `--ttl 1d0h0m0s`. If not specified, a default TTL value of 30 days will be applied.

The effects of expiration are not applied immediately, they are applied when the gc-controller runs its reconciliation loop every hour by default. If needed, you can adjust the frequency of the reconciliation loop using the `--garbage-collection-frequency
<DURATION>` flag.
Expand Down
Loading