diff --git a/cmd/bucket-lifecycle-handlers.go b/cmd/bucket-lifecycle-handlers.go
index bcc32cc96..a1673324b 100644
--- a/cmd/bucket-lifecycle-handlers.go
+++ b/cmd/bucket-lifecycle-handlers.go
@@ -24,7 +24,6 @@ import (
"github.com/gorilla/mux"
"github.com/minio/minio/internal/bucket/lifecycle"
- "github.com/minio/minio/internal/bucket/object/lock"
xhttp "github.com/minio/minio/internal/http"
"github.com/minio/minio/internal/logger"
"github.com/minio/pkg/bucket/policy"
@@ -80,17 +79,6 @@ func (api objectAPIHandlers) PutBucketLifecycleHandler(w http.ResponseWriter, r
return
}
- // Disallow MaxNoncurrentVersions if bucket has object locking enabled
- var rCfg lock.Retention
- if rCfg, err = globalBucketObjectLockSys.Get(bucket); err != nil {
- writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
- return
- }
- if rCfg.LockEnabled && bucketLifecycle.HasMaxNoncurrentVersions() {
- writeErrorResponse(ctx, w, errorCodes.ToAPIErr(ErrInvalidLifecycleWithObjectLock), r.URL)
- return
- }
-
// Validate the transition storage ARNs
if err = validateTransitionTier(bucketLifecycle); err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
diff --git a/cmd/bucket-lifecycle.go b/cmd/bucket-lifecycle.go
index 4e4e13c8a..d6eee67ea 100644
--- a/cmd/bucket-lifecycle.go
+++ b/cmd/bucket-lifecycle.go
@@ -81,21 +81,21 @@ type expiryTask struct {
}
type expiryState struct {
- once sync.Once
- byDaysCh chan expiryTask
- byMaxNoncurrentCh chan maxNoncurrentTask
+ once sync.Once
+ byDaysCh chan expiryTask
+ byNewerNoncurrentCh chan newerNoncurrentTask
}
// PendingTasks returns the number of pending ILM expiry tasks.
func (es *expiryState) PendingTasks() int {
- return len(es.byDaysCh) + len(es.byMaxNoncurrentCh)
+ return len(es.byDaysCh) + len(es.byNewerNoncurrentCh)
}
// close closes work channels exactly once.
func (es *expiryState) close() {
es.once.Do(func() {
close(es.byDaysCh)
- close(es.byMaxNoncurrentCh)
+ close(es.byNewerNoncurrentCh)
})
}
@@ -109,13 +109,13 @@ func (es *expiryState) enqueueByDays(oi ObjectInfo, restoredObject bool, rmVersi
}
}
-// enqueueByMaxNoncurrent enqueues object versions expired by
-// MaxNoncurrentVersions limit for expiry.
-func (es *expiryState) enqueueByMaxNoncurrent(bucket string, versions []ObjectToDelete) {
+// enqueueByNewerNoncurrent enqueues object versions expired by
+// NewerNoncurrentVersions limit for expiry.
+func (es *expiryState) enqueueByNewerNoncurrent(bucket string, versions []ObjectToDelete) {
select {
case <-GlobalContext.Done():
es.close()
- case es.byMaxNoncurrentCh <- maxNoncurrentTask{bucket: bucket, versions: versions}:
+ case es.byNewerNoncurrentCh <- newerNoncurrentTask{bucket: bucket, versions: versions}:
default:
}
}
@@ -124,8 +124,8 @@ var globalExpiryState *expiryState
func newExpiryState() *expiryState {
return &expiryState{
- byDaysCh: make(chan expiryTask, 10000),
- byMaxNoncurrentCh: make(chan maxNoncurrentTask, 10000),
+ byDaysCh: make(chan expiryTask, 10000),
+ byNewerNoncurrentCh: make(chan newerNoncurrentTask, 10000),
}
}
@@ -141,15 +141,15 @@ func initBackgroundExpiry(ctx context.Context, objectAPI ObjectLayer) {
}
}()
go func() {
- for t := range globalExpiryState.byMaxNoncurrentCh {
+ for t := range globalExpiryState.byNewerNoncurrentCh {
deleteObjectVersions(ctx, objectAPI, t.bucket, t.versions)
}
}()
}
-// maxNoncurrentTask encapsulates arguments required by worker to expire objects
-// by MaxNoncurrentVersions
-type maxNoncurrentTask struct {
+// newerNoncurrentTask encapsulates arguments required by worker to expire objects
+// by NewerNoncurrentVersions
+type newerNoncurrentTask struct {
bucket string
versions []ObjectToDelete
}
diff --git a/cmd/data-scanner.go b/cmd/data-scanner.go
index 519fb5228..e77539b1a 100644
--- a/cmd/data-scanner.go
+++ b/cmd/data-scanner.go
@@ -972,14 +972,14 @@ func (i *scannerItem) applyTierObjSweep(ctx context.Context, o ObjectLayer, oi O
}
-// applyMaxNoncurrentVersionLimit removes noncurrent versions older than the most recent MaxNoncurrentVersions configured.
+// applyNewerNoncurrentVersionLimit removes noncurrent versions older than the most recent NewerNoncurrentVersions configured.
// Note: This function doesn't update sizeSummary since it always removes versions that it doesn't return.
-func (i *scannerItem) applyMaxNoncurrentVersionLimit(ctx context.Context, o ObjectLayer, fivs []FileInfo) ([]FileInfo, error) {
+func (i *scannerItem) applyNewerNoncurrentVersionLimit(ctx context.Context, _ ObjectLayer, fivs []FileInfo) ([]FileInfo, error) {
if i.lifeCycle == nil {
return fivs, nil
}
- lim := i.lifeCycle.NoncurrentVersionsExpirationLimit(lifecycle.ObjectOpts{Name: i.objectPath()})
+ _, days, lim := i.lifeCycle.NoncurrentVersionsExpirationLimit(lifecycle.ObjectOpts{Name: i.objectPath()})
if lim == 0 || len(fivs) <= lim+1 { // fewer than lim _noncurrent_ versions
return fivs, nil
}
@@ -992,6 +992,7 @@ func (i *scannerItem) applyMaxNoncurrentVersionLimit(ctx context.Context, o Obje
toDel := make([]ObjectToDelete, 0, len(overflowVersions))
for _, fi := range overflowVersions {
obj := fi.ToObjectInfo(i.bucket, i.objectPath())
+ // skip versions with object locking enabled
if rcfg.LockEnabled && enforceRetentionForDeletion(ctx, obj) {
if i.debug {
if obj.VersionID != "" {
@@ -1000,22 +1001,34 @@ func (i *scannerItem) applyMaxNoncurrentVersionLimit(ctx context.Context, o Obje
console.Debugf(applyVersionActionsLogPrefix+" lifecycle: %s is locked, not deleting\n", obj.Name)
}
}
+ // add this version back to remaining versions for
+ // subsequent lifecycle policy applications
+ fivs = append(fivs, fi)
continue
}
+
+ // NoncurrentDays not passed yet.
+ if time.Now().UTC().Before(lifecycle.ExpectedExpiryTime(obj.SuccessorModTime, days)) {
+ // add this version back to remaining versions for
+ // subsequent lifecycle policy applications
+ fivs = append(fivs, fi)
+ continue
+ }
+
toDel = append(toDel, ObjectToDelete{
ObjectName: fi.Name,
VersionID: fi.VersionID,
})
}
- globalExpiryState.enqueueByMaxNoncurrent(i.bucket, toDel)
+ globalExpiryState.enqueueByNewerNoncurrent(i.bucket, toDel)
return fivs, nil
}
// applyVersionActions will apply lifecycle checks on all versions of a scanned item. Returns versions that remain
// after applying lifecycle checks configured.
func (i *scannerItem) applyVersionActions(ctx context.Context, o ObjectLayer, fivs []FileInfo) ([]FileInfo, error) {
- return i.applyMaxNoncurrentVersionLimit(ctx, o, fivs)
+ return i.applyNewerNoncurrentVersionLimit(ctx, o, fivs)
}
// applyActions will apply lifecycle checks on to a scanned item.
diff --git a/internal/bucket/lifecycle/lifecycle.go b/internal/bucket/lifecycle/lifecycle.go
index c622430f3..02bd93140 100644
--- a/internal/bucket/lifecycle/lifecycle.go
+++ b/internal/bucket/lifecycle/lifecycle.go
@@ -29,11 +29,10 @@ import (
)
var (
- errLifecycleTooManyRules = Errorf("Lifecycle configuration allows a maximum of 1000 rules")
- errLifecycleNoRule = Errorf("Lifecycle configuration should have at least one rule")
- errLifecycleDuplicateID = Errorf("Lifecycle configuration has rule with the same ID. Rule ID must be unique.")
- errXMLNotWellFormed = Errorf("The XML you provided was not well-formed or did not validate against our published schema")
- errLifecycleInvalidNoncurrentExpiration = Errorf("Exactly one of NoncurrentDays (positive integer) or MaxNoncurrentVersions should be specified in a NoncurrentExpiration rule.")
+ errLifecycleTooManyRules = Errorf("Lifecycle configuration allows a maximum of 1000 rules")
+ errLifecycleNoRule = Errorf("Lifecycle configuration should have at least one rule")
+ errLifecycleDuplicateID = Errorf("Lifecycle configuration has rule with the same ID. Rule ID must be unique.")
+ errXMLNotWellFormed = Errorf("The XML you provided was not well-formed or did not validate against our published schema")
)
const (
@@ -141,7 +140,7 @@ func (lc Lifecycle) HasActiveRules(prefix string, recursive bool) bool {
if rule.NoncurrentVersionExpiration.NoncurrentDays > 0 {
return true
}
- if rule.NoncurrentVersionExpiration.MaxNoncurrentVersions > 0 {
+ if rule.NoncurrentVersionExpiration.NewerNoncurrentVersions > 0 {
return true
}
if !rule.NoncurrentVersionTransition.IsNull() {
@@ -150,13 +149,13 @@ func (lc Lifecycle) HasActiveRules(prefix string, recursive bool) bool {
if rule.Expiration.IsNull() && rule.Transition.IsNull() {
continue
}
- if !rule.Expiration.IsDateNull() && rule.Expiration.Date.Before(time.Now()) {
+ if !rule.Expiration.IsDateNull() && rule.Expiration.Date.Before(time.Now().UTC()) {
return true
}
if !rule.Expiration.IsDaysNull() {
return true
}
- if !rule.Transition.IsDateNull() && rule.Transition.Date.Before(time.Now()) {
+ if !rule.Transition.IsDateNull() && rule.Transition.Date.Before(time.Now().UTC()) {
return true
}
if !rule.Transition.IsNull() { // this allows for Transition.Days to be zero.
@@ -238,7 +237,7 @@ func (lc Lifecycle) FilterActionableRules(obj ObjectOpts) []Rule {
rules = append(rules, rule)
continue
}
- if rule.NoncurrentVersionExpiration.MaxNoncurrentVersions > 0 {
+ if rule.NoncurrentVersionExpiration.NewerNoncurrentVersions > 0 {
rules = append(rules, rule)
continue
}
@@ -304,17 +303,24 @@ func (lc Lifecycle) ComputeAction(obj ObjectOpts) Action {
// Specifying the Days tag will automatically perform ExpiredObjectDeleteMarker cleanup
// once delete markers are old enough to satisfy the age criteria.
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/lifecycle-configuration-examples.html
- if time.Now().After(ExpectedExpiryTime(obj.ModTime, int(rule.Expiration.Days))) {
+ if time.Now().UTC().After(ExpectedExpiryTime(obj.ModTime, int(rule.Expiration.Days))) {
return DeleteVersionAction
}
}
}
if !rule.NoncurrentVersionExpiration.IsDaysNull() {
+ // Skip rules with newer noncurrent versions specified.
+ // These rules are not handled at an individual version
+ // level. ComputeAction applies only to a specific
+ // version.
+ if !obj.IsLatest && rule.NoncurrentVersionExpiration.NewerNoncurrentVersions > 0 {
+ continue
+ }
if obj.VersionID != "" && !obj.IsLatest && !obj.SuccessorModTime.IsZero() {
// Non current versions should be deleted if their age exceeds non current days configuration
// https://docs.aws.amazon.com/AmazonS3/latest/dev/intro-lifecycle-rules.html#intro-lifecycle-rules-actions
- if time.Now().After(ExpectedExpiryTime(obj.SuccessorModTime, int(rule.NoncurrentVersionExpiration.NoncurrentDays))) {
+ if time.Now().UTC().After(ExpectedExpiryTime(obj.SuccessorModTime, int(rule.NoncurrentVersionExpiration.NoncurrentDays))) {
return DeleteVersionAction
}
}
@@ -350,7 +356,7 @@ func (lc Lifecycle) ComputeAction(obj ObjectOpts) Action {
}
}
- if !obj.RestoreExpires.IsZero() && time.Now().After(obj.RestoreExpires) {
+ if !obj.RestoreExpires.IsZero() && time.Now().UTC().After(obj.RestoreExpires) {
if obj.VersionID != "" {
action = DeleteRestoredVersionAction
} else {
@@ -358,7 +364,7 @@ func (lc Lifecycle) ComputeAction(obj ObjectOpts) Action {
}
}
}
- if !obj.RestoreExpires.IsZero() && time.Now().After(obj.RestoreExpires) {
+ if !obj.RestoreExpires.IsZero() && time.Now().UTC().After(obj.RestoreExpires) {
if obj.VersionID != "" {
action = DeleteRestoredVersionAction
} else {
@@ -377,6 +383,9 @@ func (lc Lifecycle) ComputeAction(obj ObjectOpts) Action {
// e.g. If the object modtime is `Thu May 21 13:42:50 GMT 2020` and the object should
// transition in 1 day, then the expected transition time is `Fri, 23 May 2020 00:00:00 GMT`
func ExpectedExpiryTime(modTime time.Time, days int) time.Time {
+ if days == 0 {
+ return modTime
+ }
t := modTime.UTC().Add(time.Duration(days+1) * 24 * time.Hour)
return t.Truncate(24 * time.Hour)
}
@@ -395,6 +404,11 @@ func (lc Lifecycle) PredictExpiryTime(obj ObjectOpts) (string, time.Time) {
// Iterate over all actionable rules and find the earliest
// expiration date and its associated rule ID.
for _, rule := range lc.FilterActionableRules(obj) {
+ // We don't know the index of this version and hence can't
+ // reliably compute expected expiry time.
+ if !obj.IsLatest && rule.NoncurrentVersionExpiration.NewerNoncurrentVersions > 0 {
+ continue
+ }
if !rule.NoncurrentVersionExpiration.IsDaysNull() && !obj.IsLatest && obj.VersionID != "" {
return rule.ID, ExpectedExpiryTime(obj.SuccessorModTime, int(rule.NoncurrentVersionExpiration.NoncurrentDays))
}
@@ -477,31 +491,28 @@ func (lc Lifecycle) TransitionTier(obj ObjectOpts) string {
return ""
}
-// NoncurrentVersionsExpirationLimit returns the minimum limit on number of
+// NoncurrentVersionsExpirationLimit returns the maximum limit on number of
// noncurrent versions across rules.
-func (lc Lifecycle) NoncurrentVersionsExpirationLimit(obj ObjectOpts) int {
+func (lc Lifecycle) NoncurrentVersionsExpirationLimit(obj ObjectOpts) (string, int, int) {
var lim int
+ var days int
+ var ruleID string
for _, rule := range lc.FilterActionableRules(obj) {
- if rule.NoncurrentVersionExpiration.MaxNoncurrentVersions == 0 {
+ if rule.NoncurrentVersionExpiration.NewerNoncurrentVersions == 0 {
continue
}
- if lim == 0 || lim > rule.NoncurrentVersionExpiration.MaxNoncurrentVersions {
- lim = rule.NoncurrentVersionExpiration.MaxNoncurrentVersions
+ // Pick the highest number of NewerNoncurrentVersions value
+ // among overlapping rules.
+ if lim == 0 || lim < rule.NoncurrentVersionExpiration.NewerNoncurrentVersions {
+ lim = rule.NoncurrentVersionExpiration.NewerNoncurrentVersions
+ }
+ // Pick the earliest applicable NoncurrentDays among overlapping
+ // rules. Note: ruleID is that of the rule which determines the
+ // time of expiry.
+ if ndays := int(rule.NoncurrentVersionExpiration.NoncurrentDays); days == 0 || days > ndays {
+ days = ndays
+ ruleID = rule.ID
}
}
- return lim
-}
-
-// HasMaxNoncurrentVersions returns true if there exists a rule with
-// MaxNoncurrentVersions limit set.
-func (lc Lifecycle) HasMaxNoncurrentVersions() bool {
- for _, rule := range lc.Rules {
- if rule.Status == Disabled {
- continue
- }
- if rule.NoncurrentVersionExpiration.MaxNoncurrentVersions > 0 {
- return true
- }
- }
- return false
+ return ruleID, days, lim
}
diff --git a/internal/bucket/lifecycle/lifecycle_test.go b/internal/bucket/lifecycle/lifecycle_test.go
index 6b4133ff3..9519e645a 100644
--- a/internal/bucket/lifecycle/lifecycle_test.go
+++ b/internal/bucket/lifecycle/lifecycle_test.go
@@ -114,7 +114,7 @@ func TestParseAndValidateLifecycleConfig(t *testing.T) {
},
// Lifecycle with max noncurrent versions
{
- inputConfig: `rule>Enabled5`,
+ inputConfig: `rule>Enabled5`,
expectedParsingErr: nil,
expectedValidationErr: nil,
},
@@ -414,6 +414,24 @@ func TestComputeActions(t *testing.T) {
objectSuccessorModTime: time.Now().Add(-1 * time.Nanosecond).UTC(),
versionID: uuid.New().String(),
},
+ // Lifecycle rules with NewerNoncurrentVersions specified must return NoneAction.
+ {
+ inputConfig: `foodir/Enabled5`,
+ objectName: "foodir/fooobject",
+ versionID: uuid.NewString(),
+ objectModTime: time.Now().UTC().Add(-10 * 24 * time.Hour), // Created 10 days ago
+ expectedAction: NoneAction,
+ },
+ // Disabled rules with NewerNoncurrentVersions shouldn't affect outcome.
+ {
+ inputConfig: `foodir/Enabled5foodir/Disabled5`,
+ objectName: "foodir/fooobject",
+ versionID: uuid.NewString(),
+ objectModTime: time.Now().UTC().Add(-10 * 24 * time.Hour), // Created 10 days ago
+ objectSuccessorModTime: time.Now().UTC().Add(-10 * 24 * time.Hour), // Created 10 days ago
+ isNoncurrent: true,
+ expectedAction: DeleteVersionAction,
+ },
}
for _, tc := range testCases {
@@ -636,14 +654,55 @@ func TestNoncurrentVersionsLimit(t *testing.T) {
ID: strconv.Itoa(i),
Status: "Enabled",
NoncurrentVersionExpiration: NoncurrentVersionExpiration{
- MaxNoncurrentVersions: i,
+ NewerNoncurrentVersions: i,
+ NoncurrentDays: ExpirationDays(i),
},
})
}
lc := Lifecycle{
Rules: rules,
}
- if lim := lc.NoncurrentVersionsExpirationLimit(ObjectOpts{Name: "obj"}); lim != 1 {
- t.Fatalf("Expected max noncurrent versions limit to be 1 but got %d", lim)
+ if ruleID, days, lim := lc.NoncurrentVersionsExpirationLimit(ObjectOpts{Name: "obj"}); ruleID != "1" || days != 1 || lim != 10 {
+ t.Fatalf("Expected (ruleID, days, lim) to be (\"1\", 1, 10) but got (%s, %d, %d)", ruleID, days, lim)
+ }
+}
+
+func TestMaxNoncurrentBackwardCompat(t *testing.T) {
+ testCases := []struct {
+ xml string
+ expected NoncurrentVersionExpiration
+ }{
+ {
+ xml: `13`,
+ expected: NoncurrentVersionExpiration{
+ XMLName: xml.Name{
+ Local: "NoncurrentVersionExpiration",
+ },
+ NoncurrentDays: 1,
+ NewerNoncurrentVersions: 3,
+ set: true,
+ },
+ },
+ {
+ xml: `24`,
+ expected: NoncurrentVersionExpiration{
+ XMLName: xml.Name{
+ Local: "NoncurrentVersionExpiration",
+ },
+ NoncurrentDays: 2,
+ NewerNoncurrentVersions: 4,
+ set: true,
+ },
+ },
+ }
+ for i, tc := range testCases {
+ var got NoncurrentVersionExpiration
+ dec := xml.NewDecoder(strings.NewReader(tc.xml))
+ if err := dec.Decode(&got); err != nil || got != tc.expected {
+ if err != nil {
+ t.Fatalf("%d: Failed to unmarshal xml %v", i+1, err)
+ }
+ t.Fatalf("%d: Expected %v but got %v", i+1, tc.expected, got)
+ }
}
}
diff --git a/internal/bucket/lifecycle/noncurrentversion.go b/internal/bucket/lifecycle/noncurrentversion.go
index cf9ff6a04..23d03c845 100644
--- a/internal/bucket/lifecycle/noncurrentversion.go
+++ b/internal/bucket/lifecycle/noncurrentversion.go
@@ -24,10 +24,10 @@ import (
// NoncurrentVersionExpiration - an action for lifecycle configuration rule.
type NoncurrentVersionExpiration struct {
- XMLName xml.Name `xml:"NoncurrentVersionExpiration"`
- NoncurrentDays ExpirationDays `xml:"NoncurrentDays,omitempty"`
- MaxNoncurrentVersions int `xml:"MaxNoncurrentVersions,omitempty"`
- set bool
+ XMLName xml.Name `xml:"NoncurrentVersionExpiration"`
+ NoncurrentDays ExpirationDays `xml:"NoncurrentDays,omitempty"`
+ NewerNoncurrentVersions int `xml:"NewerNoncurrentVersions,omitempty"`
+ set bool
}
// MarshalXML if non-current days not set to non zero value
@@ -41,20 +41,35 @@ func (n NoncurrentVersionExpiration) MarshalXML(e *xml.Encoder, start xml.StartE
// UnmarshalXML decodes NoncurrentVersionExpiration
func (n *NoncurrentVersionExpiration) UnmarshalXML(d *xml.Decoder, startElement xml.StartElement) error {
- type noncurrentVersionExpirationWrapper NoncurrentVersionExpiration
- var val noncurrentVersionExpirationWrapper
+ // To handle xml with MaxNoncurrentVersions from older MinIO releases.
+ // note: only one of MaxNoncurrentVersions or NewerNoncurrentVersions would be present.
+ type noncurrentExpiration struct {
+ XMLName xml.Name `xml:"NoncurrentVersionExpiration"`
+ NoncurrentDays ExpirationDays `xml:"NoncurrentDays,omitempty"`
+ NewerNoncurrentVersions int `xml:"NewerNoncurrentVersions,omitempty"`
+ MaxNoncurrentVersions int `xml:"MaxNoncurrentVersions,omitempty"`
+ }
+
+ var val noncurrentExpiration
err := d.DecodeElement(&val, &startElement)
if err != nil {
return err
}
- *n = NoncurrentVersionExpiration(val)
+ if val.MaxNoncurrentVersions > 0 {
+ val.NewerNoncurrentVersions = val.MaxNoncurrentVersions
+ }
+ *n = NoncurrentVersionExpiration{
+ XMLName: val.XMLName,
+ NoncurrentDays: val.NoncurrentDays,
+ NewerNoncurrentVersions: val.NewerNoncurrentVersions,
+ }
n.set = true
return nil
}
// IsNull returns if both NoncurrentDays and NoncurrentVersions are empty
func (n NoncurrentVersionExpiration) IsNull() bool {
- return n.IsDaysNull() && n.MaxNoncurrentVersions == 0
+ return n.IsDaysNull() && n.NewerNoncurrentVersions == 0
}
// IsDaysNull returns true if days field is null
@@ -69,16 +84,13 @@ func (n NoncurrentVersionExpiration) Validate() error {
}
val := int(n.NoncurrentDays)
switch {
- case val == 0 && n.MaxNoncurrentVersions == 0:
+ case val == 0 && n.NewerNoncurrentVersions == 0:
// both fields can't be zero
return errXMLNotWellFormed
- case val > 0 && n.MaxNoncurrentVersions > 0:
- // both tags can't be non-zero simultaneously
- return errLifecycleInvalidNoncurrentExpiration
-
- case val < 0, n.MaxNoncurrentVersions < 0:
+ case val < 0, n.NewerNoncurrentVersions < 0:
// negative values are not supported
+ return errXMLNotWellFormed
}
return nil
}
diff --git a/internal/bucket/lifecycle/noncurrentversion_test.go b/internal/bucket/lifecycle/noncurrentversion_test.go
new file mode 100644
index 000000000..fc6d1b343
--- /dev/null
+++ b/internal/bucket/lifecycle/noncurrentversion_test.go
@@ -0,0 +1,82 @@
+// Copyright (c) 2015-2021 MinIO, Inc.
+//
+// This file is part of MinIO Object Storage stack
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package lifecycle
+
+import "testing"
+
+func Test_NoncurrentVersionsExpiration_Validation(t *testing.T) {
+ testcases := []struct {
+ n NoncurrentVersionExpiration
+ err error
+ }{
+ {
+ n: NoncurrentVersionExpiration{
+ NoncurrentDays: 0,
+ NewerNoncurrentVersions: 0,
+ set: true,
+ },
+ err: errXMLNotWellFormed,
+ },
+ {
+ n: NoncurrentVersionExpiration{
+ NoncurrentDays: 90,
+ NewerNoncurrentVersions: 0,
+ set: true,
+ },
+ err: nil,
+ },
+ {
+ n: NoncurrentVersionExpiration{
+ NoncurrentDays: 90,
+ NewerNoncurrentVersions: 2,
+ set: true,
+ },
+ err: nil,
+ },
+ {
+ n: NoncurrentVersionExpiration{
+ NoncurrentDays: -1,
+ set: true,
+ },
+ err: errXMLNotWellFormed,
+ },
+ {
+ n: NoncurrentVersionExpiration{
+ NoncurrentDays: 90,
+ NewerNoncurrentVersions: -2,
+ set: true,
+ },
+ err: errXMLNotWellFormed,
+ },
+ // MinIO extension: supports zero NoncurrentDays when NewerNoncurrentVersions > 0
+ {
+ n: NoncurrentVersionExpiration{
+ NoncurrentDays: 0,
+ NewerNoncurrentVersions: 5,
+ set: true,
+ },
+ err: nil,
+ },
+ }
+
+ for i, tc := range testcases {
+ if got := tc.n.Validate(); got != tc.err {
+ t.Fatalf("%d: expected %v but got %v", i+1, tc.err, got)
+ }
+ }
+}