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
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,9 @@ public ActionType getActionType() {
* - Days must be a positive number greater than zero if set
* - Either days or date should be specified, but not both or neither
* - The date value must conform to the ISO 8601 format
* - The date value must be in the future
* - The date value must be at midnight UTC (00:00:00Z)
*
* @param creationTime The creation time of the lifecycle configuration in milliseconds since epoch
* @param creationTime unused, kept for interface compatibility
* @throws OMException if the validation fails
*/
@Override
Expand All @@ -107,32 +106,24 @@ public void valid(long creationTime) throws OMException {
daysInMilli = TimeUnit.DAYS.toMillis(days);
}
if (hasDate) {
validateExpirationDate(date, creationTime);
validateExpirationDate(date);
}
}

/**
* Validates that the expiration date is:
* - In the ISO 8601 format
* - Includes both time and time zone (neither can be omitted)
* - In the future
* - Represents midnight UTC (00:00:00Z) when converted to UTC.
*
* @param expirationDate The date string to validate
* @param creationTime The creation time to compare against in milliseconds since epoch
* @throws OMException if the date is invalid
*/
private void validateExpirationDate(String expirationDate, long creationTime) throws OMException {
private void validateExpirationDate(String expirationDate) throws OMException {
try {
ZonedDateTime parsedDate = ZonedDateTime.parse(expirationDate, DateTimeFormatter.ISO_DATE_TIME);
// Convert to UTC for validation
ZonedDateTime dateInUTC = parsedDate.withZoneSameInstant(ZoneOffset.UTC);
// The date value must conform to the ISO 8601 format, be in the future.
ZonedDateTime createDate = ZonedDateTime.ofInstant(Instant.ofEpochMilli(creationTime), ZoneOffset.UTC);
if (dateInUTC.isBefore(createDate)) {
throw new OMException("Invalid lifecycle configuration: 'Date' must be in the future " + createDate + "," +
dateInUTC, OMException.ResultCodes.INVALID_REQUEST);
}

// Verify that the time is midnight UTC (00:00:00Z)
if (!test && (dateInUTC.getHour() != 0 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,10 @@ public OmLCFilter getFilter() {
return filter;
}

public List<OmLCAction> getActions() {
return actions;
}

public OmLCRule build() {
return new OmLCRule(this);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ public Builder setCreationTime(long creationTime) {
return this;
}

public List<OmLCRule> getRules() {
return rules;
}

public Builder addRule(OmLCRule rule) {
this.rules.add(rule);
return this;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,22 +123,21 @@ public void testCreateInValidOmLCExpiration() {
assertOMException(() -> exp7.build().valid(currentTime), INVALID_REQUEST,
"'Date' must be in ISO 8601 format");

// Testing for date in the past with creation time
// Past dates are valid per S3 spec (they immediately expire objects)
OmLCExpiration.Builder exp8 = new OmLCExpiration.Builder()
.setDate(getFutureDateString(-1));
assertOMException(() -> exp8.build().valid(currentTime), INVALID_REQUEST,
"'Date' must be in the future");
assertDoesNotThrow(() -> exp8.build().valid(currentTime));

OmLCExpiration.Builder exp9 = new OmLCExpiration.Builder()
.setDays(0);
assertOMException(() -> exp9.build().valid(currentTime), INVALID_REQUEST,
"'Days' for Expiration action must be a positive integer");

// 1 minute ago with creation time
// A date that is not midnight UTC is rejected regardless of being past or future
OmLCExpiration.Builder exp10 = new OmLCExpiration.Builder()
.setDate(getFutureDateString(0, 0, -1));
assertOMException(() -> exp10.build().valid(currentTime), INVALID_REQUEST,
"'Date' must be in the future");
"'Date' must represent midnight UTC");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,9 @@ public Response putBucketLifecycleConfiguration(S3RequestContext context, String
null, null, null, null, body);
OmLifecycleConfiguration lcc =
s3LifecycleConfiguration.toOmLifecycleConfiguration(ozoneBucket);
ozoneBucket.setLifecycleConfiguration(lcc);
if (lcc != null) {
ozoneBucket.setLifecycleConfiguration(lcc);
}
} catch (WebApplicationException ex) {
throw S3ErrorTable.newError(S3ErrorTable.MALFORMED_XML, bucketName);
} catch (OMException ex) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ public static class Rule {
@XmlElement(name = "Filter")
private Filter filter;

@XmlElement(name = "NoncurrentVersionExpiration")
private NoncurrentVersionExpiration noncurrentVersionExpiration;

public String getId() {
return id;
}
Expand Down Expand Up @@ -126,6 +129,14 @@ public Filter getFilter() {
public void setFilter(Filter filter) {
this.filter = filter;
}

public NoncurrentVersionExpiration getNoncurrentVersionExpiration() {
return noncurrentVersionExpiration;
}

public void setNoncurrentVersionExpiration(NoncurrentVersionExpiration noncurrentVersionExpiration) {
this.noncurrentVersionExpiration = noncurrentVersionExpiration;
}
}

/**
Expand All @@ -140,6 +151,9 @@ public static class Expiration {
@XmlElement(name = "Date")
private String date;

@XmlElement(name = "ExpiredObjectDeleteMarker")
private Boolean expiredObjectDeleteMarker;

public Integer getDays() {
return days;
}
Expand All @@ -155,6 +169,36 @@ public String getDate() {
public void setDate(String date) {
this.date = date;
}

public Boolean getExpiredObjectDeleteMarker() {
return expiredObjectDeleteMarker;
}

public void setExpiredObjectDeleteMarker(Boolean expiredObjectDeleteMarker) {
this.expiredObjectDeleteMarker = expiredObjectDeleteMarker;
}

public boolean isUnsupported() {
return Boolean.TRUE.equals(expiredObjectDeleteMarker) && days == null && date == null;
}
}

/**
* NoncurrentVersionExpiration entity for lifecycle rule (accepted but not yet enforced).
*/
@XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "NoncurrentVersionExpiration")
public static class NoncurrentVersionExpiration {
@XmlElement(name = "NoncurrentDays")
private Integer noncurrentDays;

public Integer getNoncurrentDays() {
return noncurrentDays;
}

public void setNoncurrentDays(Integer noncurrentDays) {
this.noncurrentDays = noncurrentDays;
}
}

/**
Expand Down Expand Up @@ -291,7 +335,15 @@ public OmLifecycleConfiguration toOmLifecycleConfiguration(OzoneBucket ozoneBuck
.setBucket(ozoneBucket.getName());

for (Rule rule : getRules()) {
builder.addRule(convertToOmRule(rule));
OmLCRule omRule = convertToOmRule(rule);
if (omRule != null) {
builder.addRule(omRule);
}
}

if (builder.getRules().isEmpty()) {
// All rules used unsupported-but-valid S3 actions; accept the request without persisting.
return null;
}

return builder.build();
Expand All @@ -307,31 +359,46 @@ public OmLifecycleConfiguration toOmLifecycleConfiguration(OzoneBucket ozoneBuck

/**
* Converts a single S3 lifecycle rule to Ozone internal rule representation.
* Returns null when the rule contains only S3 elements not yet supported by Ozone
* (e.g. NoncurrentVersionExpiration, ExpiredObjectDeleteMarker).
*
* @param rule the S3 lifecycle rule
* @return OmLCRule internal rule representation
* @return OmLCRule internal rule representation, or null if the rule uses only unsupported actions
*/
private OmLCRule convertToOmRule(Rule rule) throws OMException, OS3Exception {
if (rule.getStatus() == null || rule.getStatus().isEmpty()) {
throw S3ErrorTable.newError(S3ErrorTable.MALFORMED_XML,
"The Status element is required in LifecycleConfiguration");
}

boolean hasUnsupportedAction = false;

OmLCRule.Builder builder = new OmLCRule.Builder()
.setEnabled("Enabled".equals(rule.getStatus()))
.setId(rule.getId())
.setPrefix(rule.getPrefix());

if (rule.getExpiration() != null) {
builder.addAction(convertToOmExpiration(rule.getExpiration()));
if (rule.getExpiration().isUnsupported()) {
hasUnsupportedAction = true;
} else {
builder.addAction(convertToOmExpiration(rule.getExpiration()));
}
}
if (rule.getAbortIncompleteMultipartUpload() != null) {
builder.addAction(convertToOmAbortIncompleteMultipartUpload(rule.getAbortIncompleteMultipartUpload()));
}
if (rule.getNoncurrentVersionExpiration() != null) {
hasUnsupportedAction = true;
}
if (rule.getFilter() != null) {
builder.setFilter(convertToOmFilter(rule.getFilter()));
}

if (builder.getActions().isEmpty() && hasUnsupportedAction) {
return null;
}

return builder.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,62 @@ public void testPutLifecycleWithBothExpirationAndAbort() throws Exception {
assertEquals(HTTP_OK, bucketEndpoint.put("bucket1", withBothExpirationAndAbort()).getStatus());
}

@Test
public void testPutLifecycleWithPastExpirationDate() throws Exception {
// S3 accepts past dates; they mean objects expired immediately
String xml = "<LifecycleConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">" +
"<Rule><ID>rule1</ID><Prefix>test1/</Prefix><Status>Enabled</Status>" +
"<Expiration><Date>2017-09-27T00:00:00+00:00</Date></Expiration>" +
"</Rule></LifecycleConfiguration>";
assertEquals(HTTP_OK,
bucketEndpoint.put("bucket1", new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))).getStatus());
}

@Test
public void testPutLifecycleWithExpiredObjectDeleteMarker() throws Exception {
// ExpiredObjectDeleteMarker is a valid S3 action; Ozone accepts but does not enforce it yet
String xml = "<LifecycleConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">" +
"<Rule><ID>rule1</ID><Prefix>test1/</Prefix><Status>Enabled</Status>" +
"<Expiration><ExpiredObjectDeleteMarker>true</ExpiredObjectDeleteMarker></Expiration>" +
"</Rule></LifecycleConfiguration>";
assertEquals(HTTP_OK,
bucketEndpoint.put("bucket1", new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))).getStatus());
}

@Test
public void testPutLifecycleWithExpiredObjectDeleteMarkerAndFilter() throws Exception {
String xml = "<LifecycleConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">" +
"<Rule><ID>rule1</ID><Filter><Prefix>foo</Prefix></Filter><Status>Enabled</Status>" +
"<Expiration><ExpiredObjectDeleteMarker>true</ExpiredObjectDeleteMarker></Expiration>" +
"</Rule></LifecycleConfiguration>";
assertEquals(HTTP_OK,
bucketEndpoint.put("bucket1", new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))).getStatus());
}

@Test
public void testPutLifecycleWithExpiredObjectDeleteMarkerAndEmptyFilter() throws Exception {
String xml = "<LifecycleConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">" +
"<Rule><ID>rule1</ID><Filter></Filter><Status>Enabled</Status>" +
"<Expiration><ExpiredObjectDeleteMarker>true</ExpiredObjectDeleteMarker></Expiration>" +
"</Rule></LifecycleConfiguration>";
assertEquals(HTTP_OK,
bucketEndpoint.put("bucket1", new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))).getStatus());
}

@Test
public void testPutLifecycleWithNoncurrentVersionExpiration() throws Exception {
// NoncurrentVersionExpiration is a valid S3 action; Ozone accepts but does not enforce it yet
String xml = "<LifecycleConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">" +
"<Rule><ID>rule1</ID><Prefix>past/</Prefix><Status>Enabled</Status>" +
"<NoncurrentVersionExpiration><NoncurrentDays>2</NoncurrentDays></NoncurrentVersionExpiration>" +
"</Rule>" +
"<Rule><ID>rule2</ID><Prefix>future/</Prefix><Status>Enabled</Status>" +
"<NoncurrentVersionExpiration><NoncurrentDays>3</NoncurrentDays></NoncurrentVersionExpiration>" +
"</Rule></LifecycleConfiguration>";
assertEquals(HTTP_OK,
bucketEndpoint.put("bucket1", new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))).getStatus());
}

@Test
public void testPutInvalidAbortIncompleteMultipartUploadConfig() throws Exception {
// Test with zero days - should fail
Expand Down
Loading