diff --git a/.github/workflows/s3-versioning-tests.yml b/.github/workflows/s3-versioning-tests.yml index a401a05c8..a34544b43 100644 --- a/.github/workflows/s3-versioning-tests.yml +++ b/.github/workflows/s3-versioning-tests.yml @@ -1,10 +1,10 @@ -name: "S3 Versioning Tests (Go)" +name: "S3 Versioning and Retention Tests (Go)" on: pull_request: concurrency: - group: ${{ github.head_ref }}/s3-versioning + group: ${{ github.head_ref }}/s3-versioning-retention cancel-in-progress: true permissions: @@ -130,6 +130,122 @@ jobs: path: test/s3/versioning/weed-test*.log retention-days: 3 + s3-retention-tests: + name: S3 Retention Tests + runs-on: ubuntu-22.04 + timeout-minutes: 30 + strategy: + matrix: + test-type: ["quick", "comprehensive"] + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + id: go + + - name: Install SeaweedFS + run: | + go install -buildvcs=false + + - name: Run S3 Retention Tests - ${{ matrix.test-type }} + timeout-minutes: 25 + working-directory: test/s3/retention + run: | + set -x + echo "=== System Information ===" + uname -a + free -h + df -h + echo "=== Starting Tests ===" + + # Run tests with automatic server management + # The test-with-server target handles server startup/shutdown automatically + if [ "${{ matrix.test-type }}" = "quick" ]; then + # Override TEST_PATTERN for quick tests only + make test-with-server TEST_PATTERN="TestBasicRetentionWorkflow|TestRetentionModeCompliance|TestLegalHoldWorkflow" + else + # Run all retention tests + make test-with-server + fi + + - name: Show server logs on failure + if: failure() + working-directory: test/s3/retention + run: | + echo "=== Server Logs ===" + if [ -f weed-test.log ]; then + echo "Last 100 lines of server logs:" + tail -100 weed-test.log + else + echo "No server log file found" + fi + + echo "=== Test Environment ===" + ps aux | grep -E "(weed|test)" || true + netstat -tlnp | grep -E "(8333|9333|8080)" || true + + - name: Upload test logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: s3-retention-test-logs-${{ matrix.test-type }} + path: test/s3/retention/weed-test*.log + retention-days: 3 + + s3-retention-worm: + name: S3 Retention WORM Integration Test + runs-on: ubuntu-22.04 + timeout-minutes: 20 + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + id: go + + - name: Install SeaweedFS + run: | + go install -buildvcs=false + + - name: Run WORM Integration Tests + timeout-minutes: 15 + working-directory: test/s3/retention + run: | + set -x + echo "=== System Information ===" + uname -a + free -h + + # Run the WORM integration tests with automatic server management + # The test-with-server target handles server startup/shutdown automatically + make test-with-server TEST_PATTERN="TestWORM|TestRetentionExtendedAttributes|TestRetentionConcurrentOperations" || { + echo "❌ WORM integration test failed, checking logs..." + if [ -f weed-test.log ]; then + echo "=== Server logs ===" + tail -100 weed-test.log + fi + echo "=== Process information ===" + ps aux | grep -E "(weed|test)" || true + exit 1 + } + + - name: Upload server logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: s3-retention-worm-logs + path: test/s3/retention/weed-test*.log + retention-days: 3 + s3-versioning-stress: name: S3 Versioning Stress Test runs-on: ubuntu-22.04 diff --git a/.gitignore b/.gitignore index 4c790ae6e..9efc7c66e 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,4 @@ bin/weed weed_binary /test/s3/copying/filerldb2 /filerldb2 +/test/s3/retention/test-volume-data diff --git a/test/s3/retention/Makefile b/test/s3/retention/Makefile new file mode 100644 index 000000000..092d2caac --- /dev/null +++ b/test/s3/retention/Makefile @@ -0,0 +1,360 @@ +# S3 API Retention Test Makefile +# This Makefile provides comprehensive targets for running S3 retention tests + +.PHONY: help build-weed setup-server start-server stop-server test-retention test-retention-quick test-retention-comprehensive test-retention-worm test-all clean logs check-deps + +# Configuration +WEED_BINARY := ../../../weed/weed_binary +S3_PORT := 8333 +MASTER_PORT := 9333 +VOLUME_PORT := 8080 +FILER_PORT := 8888 +TEST_TIMEOUT := 15m +TEST_PATTERN := TestRetention + +# Default target +help: + @echo "S3 API Retention Test Makefile" + @echo "" + @echo "Available targets:" + @echo " help - Show this help message" + @echo " build-weed - Build the SeaweedFS binary" + @echo " check-deps - Check dependencies and build binary if needed" + @echo " start-server - Start SeaweedFS server for testing" + @echo " start-server-simple - Start server without process cleanup (for CI)" + @echo " stop-server - Stop SeaweedFS server" + @echo " test-retention - Run all retention tests" + @echo " test-retention-quick - Run core retention tests only" + @echo " test-retention-simple - Run tests without server management" + @echo " test-retention-comprehensive - Run comprehensive retention tests" + @echo " test-retention-worm - Run WORM integration tests" + @echo " test-all - Run all S3 API retention tests" + @echo " test-with-server - Start server, run tests, stop server" + @echo " logs - Show server logs" + @echo " clean - Clean up test artifacts and stop server" + @echo " health-check - Check if server is accessible" + @echo "" + @echo "Configuration:" + @echo " S3_PORT=${S3_PORT}" + @echo " TEST_TIMEOUT=${TEST_TIMEOUT}" + +# Build the SeaweedFS binary +build-weed: + @echo "Building SeaweedFS binary..." + @cd ../../../weed && go build -o weed_binary . + @chmod +x $(WEED_BINARY) + @echo "✅ SeaweedFS binary built at $(WEED_BINARY)" + +check-deps: build-weed + @echo "Checking dependencies..." + @echo "🔍 DEBUG: Checking Go installation..." + @command -v go >/dev/null 2>&1 || (echo "Go is required but not installed" && exit 1) + @echo "🔍 DEBUG: Go version: $$(go version)" + @echo "🔍 DEBUG: Checking binary at $(WEED_BINARY)..." + @test -f $(WEED_BINARY) || (echo "SeaweedFS binary not found at $(WEED_BINARY)" && exit 1) + @echo "🔍 DEBUG: Binary size: $$(ls -lh $(WEED_BINARY) | awk '{print $$5}')" + @echo "🔍 DEBUG: Binary permissions: $$(ls -la $(WEED_BINARY) | awk '{print $$1}')" + @echo "🔍 DEBUG: Checking Go module dependencies..." + @go list -m github.com/aws/aws-sdk-go-v2 >/dev/null 2>&1 || (echo "AWS SDK Go v2 not found. Run 'go mod tidy'." && exit 1) + @go list -m github.com/stretchr/testify >/dev/null 2>&1 || (echo "Testify not found. Run 'go mod tidy'." && exit 1) + @echo "✅ All dependencies are available" + +# Start SeaweedFS server for testing +start-server: check-deps + @echo "Starting SeaweedFS server..." + @echo "🔍 DEBUG: Current working directory: $$(pwd)" + @echo "🔍 DEBUG: Checking for existing weed processes..." + @ps aux | grep weed | grep -v grep || echo "No existing weed processes found" + @echo "🔍 DEBUG: Cleaning up any existing PID file..." + @rm -f weed-server.pid + @echo "🔍 DEBUG: Checking for port conflicts..." + @if netstat -tlnp 2>/dev/null | grep $(S3_PORT) >/dev/null; then \ + echo "⚠️ Port $(S3_PORT) is already in use, trying to find the process..."; \ + netstat -tlnp 2>/dev/null | grep $(S3_PORT) || true; \ + else \ + echo "✅ Port $(S3_PORT) is available"; \ + fi + @echo "🔍 DEBUG: Checking binary at $(WEED_BINARY)" + @ls -la $(WEED_BINARY) || (echo "❌ Binary not found!" && exit 1) + @echo "🔍 DEBUG: Checking config file at ../../../docker/compose/s3.json" + @ls -la ../../../docker/compose/s3.json || echo "⚠️ Config file not found, continuing without it" + @echo "🔍 DEBUG: Creating volume directory..." + @mkdir -p ./test-volume-data + @echo "🔍 DEBUG: Launching SeaweedFS server in background..." + @echo "🔍 DEBUG: Command: $(WEED_BINARY) server -debug -s3 -s3.port=$(S3_PORT) -s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=true -s3.config=../../../docker/compose/s3.json -filer -filer.maxMB=64 -master.volumeSizeLimitMB=50 -volume.max=100 -dir=./test-volume-data -volume.preStopSeconds=1 -metricsPort=9324" + @$(WEED_BINARY) server \ + -debug \ + -s3 \ + -s3.port=$(S3_PORT) \ + -s3.allowEmptyFolder=false \ + -s3.allowDeleteBucketNotEmpty=true \ + -s3.config=../../../docker/compose/s3.json \ + -filer \ + -filer.maxMB=64 \ + -master.volumeSizeLimitMB=50 \ + -volume.max=100 \ + -dir=./test-volume-data \ + -volume.preStopSeconds=1 \ + -metricsPort=9324 \ + > weed-test.log 2>&1 & echo $$! > weed-server.pid + @echo "🔍 DEBUG: Server PID: $$(cat weed-server.pid 2>/dev/null || echo 'PID file not found')" + @echo "🔍 DEBUG: Checking if PID is still running..." + @sleep 2 + @if [ -f weed-server.pid ]; then \ + SERVER_PID=$$(cat weed-server.pid); \ + ps -p $$SERVER_PID || echo "⚠️ Server PID $$SERVER_PID not found after 2 seconds"; \ + else \ + echo "⚠️ PID file not found"; \ + fi + @echo "🔍 DEBUG: Waiting for server to start (up to 90 seconds)..." + @for i in $$(seq 1 90); do \ + echo "🔍 DEBUG: Attempt $$i/90 - checking port $(S3_PORT)"; \ + if curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1; then \ + echo "✅ SeaweedFS server started successfully on port $(S3_PORT) after $$i seconds"; \ + exit 0; \ + fi; \ + if [ $$i -eq 5 ]; then \ + echo "🔍 DEBUG: After 5 seconds, checking process and logs..."; \ + ps aux | grep weed | grep -v grep || echo "No weed processes found"; \ + if [ -f weed-test.log ]; then \ + echo "=== First server logs ==="; \ + head -20 weed-test.log; \ + fi; \ + fi; \ + if [ $$i -eq 15 ]; then \ + echo "🔍 DEBUG: After 15 seconds, checking port bindings..."; \ + netstat -tlnp 2>/dev/null | grep $(S3_PORT) || echo "Port $(S3_PORT) not bound"; \ + netstat -tlnp 2>/dev/null | grep 9333 || echo "Port 9333 not bound"; \ + netstat -tlnp 2>/dev/null | grep 8080 || echo "Port 8080 not bound"; \ + fi; \ + if [ $$i -eq 30 ]; then \ + echo "⚠️ Server taking longer than expected (30s), checking logs..."; \ + if [ -f weed-test.log ]; then \ + echo "=== Recent server logs ==="; \ + tail -20 weed-test.log; \ + fi; \ + fi; \ + sleep 1; \ + done; \ + echo "❌ Server failed to start within 90 seconds"; \ + echo "🔍 DEBUG: Final process check:"; \ + ps aux | grep weed | grep -v grep || echo "No weed processes found"; \ + echo "🔍 DEBUG: Final port check:"; \ + netstat -tlnp 2>/dev/null | grep -E "(8333|9333|8080)" || echo "No ports bound"; \ + echo "=== Full server logs ==="; \ + if [ -f weed-test.log ]; then \ + cat weed-test.log; \ + else \ + echo "No log file found"; \ + fi; \ + exit 1 + +# Stop SeaweedFS server +stop-server: + @echo "Stopping SeaweedFS server..." + @if [ -f weed-server.pid ]; then \ + SERVER_PID=$$(cat weed-server.pid); \ + echo "Killing server PID $$SERVER_PID"; \ + if ps -p $$SERVER_PID >/dev/null 2>&1; then \ + kill -TERM $$SERVER_PID 2>/dev/null || true; \ + sleep 2; \ + if ps -p $$SERVER_PID >/dev/null 2>&1; then \ + echo "Process still running, sending KILL signal..."; \ + kill -KILL $$SERVER_PID 2>/dev/null || true; \ + sleep 1; \ + fi; \ + else \ + echo "Process $$SERVER_PID not found (already stopped)"; \ + fi; \ + rm -f weed-server.pid; \ + else \ + echo "No PID file found, checking for running processes..."; \ + echo "⚠️ Skipping automatic process cleanup to avoid CI issues"; \ + echo "Note: Any remaining weed processes should be cleaned up by the CI environment"; \ + fi + @echo "✅ SeaweedFS server stopped" + +# Show server logs +logs: + @if test -f weed-test.log; then \ + echo "=== SeaweedFS Server Logs ==="; \ + tail -f weed-test.log; \ + else \ + echo "No log file found. Server may not be running."; \ + fi + +# Core retention tests (basic functionality) +test-retention-quick: check-deps + @echo "Running core S3 retention tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestBasicRetentionWorkflow|TestRetentionModeCompliance|TestLegalHoldWorkflow" . + @echo "✅ Core retention tests completed" + +# All retention tests (comprehensive) +test-retention: check-deps + @echo "Running all S3 retention tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "$(TEST_PATTERN)" . + @echo "✅ All retention tests completed" + +# WORM integration tests +test-retention-worm: check-deps + @echo "Running WORM integration tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestWORM|TestRetentionExtendedAttributes|TestRetentionConcurrentOperations" . + @echo "✅ WORM integration tests completed" + +# Comprehensive retention tests (all features) +test-retention-comprehensive: check-deps + @echo "Running comprehensive S3 retention tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestRetention|TestObjectLock|TestLegalHold|TestWORM" . + @echo "✅ Comprehensive retention tests completed" + +# All tests without server management +test-retention-simple: check-deps + @echo "Running retention tests (assuming server is already running)..." + @go test -v -timeout=$(TEST_TIMEOUT) . + @echo "✅ All retention tests completed" + +# Start server, run tests, stop server +test-with-server: start-server + @echo "Running retention tests with managed server..." + @sleep 5 # Give server time to fully start + @make test-retention-comprehensive || (echo "Tests failed, stopping server..." && make stop-server && exit 1) + @make stop-server + @echo "✅ All tests completed with managed server" + +# Health check +health-check: + @echo "Checking server health..." + @if curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1; then \ + echo "✅ Server is accessible on port $(S3_PORT)"; \ + else \ + echo "❌ Server is not accessible on port $(S3_PORT)"; \ + exit 1; \ + fi + +# Clean up +clean: + @echo "Cleaning up test artifacts..." + @make stop-server + @rm -f weed-test.log + @rm -f weed-server.pid + @rm -rf ./test-volume-data + @echo "✅ Cleanup completed" + +# Individual test targets for specific functionality +test-basic-retention: + @echo "Running basic retention tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestBasicRetentionWorkflow" . + +test-compliance-retention: + @echo "Running compliance retention tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestRetentionModeCompliance" . + +test-legal-hold: + @echo "Running legal hold tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestLegalHoldWorkflow" . + +test-object-lock-config: + @echo "Running object lock configuration tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestObjectLockConfiguration" . + +test-retention-versions: + @echo "Running retention with versions tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestRetentionWithVersions" . + +test-retention-combination: + @echo "Running retention and legal hold combination tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestRetentionAndLegalHoldCombination" . + +test-expired-retention: + @echo "Running expired retention tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestExpiredRetention" . + +test-retention-errors: + @echo "Running retention error case tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestRetentionErrorCases" . + +# WORM-specific test targets +test-worm-integration: + @echo "Running WORM integration tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestWORMRetentionIntegration" . + +test-worm-legacy: + @echo "Running WORM legacy compatibility tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestWORMLegacyCompatibility" . + +test-retention-overwrite: + @echo "Running retention overwrite protection tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestRetentionOverwriteProtection" . + +test-retention-bulk: + @echo "Running retention bulk operations tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestRetentionBulkOperations" . + +test-retention-multipart: + @echo "Running retention multipart upload tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestRetentionWithMultipartUpload" . + +test-retention-extended-attrs: + @echo "Running retention extended attributes tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestRetentionExtendedAttributes" . + +test-retention-defaults: + @echo "Running retention bucket defaults tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestRetentionBucketDefaults" . + +test-retention-concurrent: + @echo "Running retention concurrent operations tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestRetentionConcurrentOperations" . + +# Development targets +dev-start: start-server + @echo "Development server started. Access S3 API at http://localhost:$(S3_PORT)" + @echo "To stop: make stop-server" + +dev-test: check-deps + @echo "Running tests in development mode..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestBasicRetentionWorkflow" . + +# CI targets +ci-test: check-deps + @echo "Running tests in CI mode..." + @go test -v -timeout=$(TEST_TIMEOUT) -race . + +# All targets +test-all: test-retention test-retention-worm + @echo "✅ All S3 retention tests completed" + +# Benchmark targets +benchmark-retention: + @echo "Running retention performance benchmarks..." + @go test -v -timeout=$(TEST_TIMEOUT) -bench=. -benchmem . + +# Coverage targets +coverage: + @echo "Running tests with coverage..." + @go test -v -timeout=$(TEST_TIMEOUT) -coverprofile=coverage.out . + @go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated: coverage.html" + +# Format and lint +fmt: + @echo "Formatting Go code..." + @go fmt . + +lint: + @echo "Running linter..." + @golint . || echo "golint not available, skipping..." + +# Install dependencies for development +install-deps: + @echo "Installing Go dependencies..." + @go mod tidy + @go mod download + +# Show current configuration +show-config: + @echo "Current configuration:" + @echo " WEED_BINARY: $(WEED_BINARY)" + @echo " S3_PORT: $(S3_PORT)" + @echo " TEST_TIMEOUT: $(TEST_TIMEOUT)" + @echo " TEST_PATTERN: $(TEST_PATTERN)" \ No newline at end of file diff --git a/test/s3/retention/README.md b/test/s3/retention/README.md new file mode 100644 index 000000000..7d92646e6 --- /dev/null +++ b/test/s3/retention/README.md @@ -0,0 +1,264 @@ +# SeaweedFS S3 Object Retention Tests + +This directory contains comprehensive tests for SeaweedFS S3 Object Retention functionality, including Object Lock, Legal Hold, and WORM (Write Once Read Many) capabilities. + +## Overview + +The test suite validates AWS S3-compatible object retention features including: + +- **Object Retention**: GOVERNANCE and COMPLIANCE modes with retain-until-date +- **Legal Hold**: Independent protection that can be applied/removed +- **Object Lock Configuration**: Bucket-level default retention policies +- **WORM Integration**: Compatibility with legacy WORM functionality +- **Version-specific Retention**: Different retention policies per object version +- **Enforcement**: Protection against deletion and overwriting + +## Test Files + +- `s3_retention_test.go` - Core retention functionality tests +- `s3_worm_integration_test.go` - WORM integration and advanced scenarios +- `test_config.json` - Test configuration (endpoints, credentials) +- `Makefile` - Comprehensive test automation +- `go.mod` - Go module dependencies + +## Prerequisites + +- Go 1.21 or later +- SeaweedFS binary built (`make build-weed`) +- AWS SDK Go v2 +- Testify testing framework + +## Quick Start + +### 1. Build and Start Server +```bash +# Build SeaweedFS and start test server +make start-server +``` + +### 2. Run Tests +```bash +# Run core retention tests +make test-retention-quick + +# Run all retention tests +make test-retention + +# Run WORM integration tests +make test-retention-worm + +# Run all tests with managed server +make test-with-server +``` + +### 3. Cleanup +```bash +make clean +``` + +## Test Categories + +### Core Retention Tests +- `TestBasicRetentionWorkflow` - Basic GOVERNANCE mode retention +- `TestRetentionModeCompliance` - COMPLIANCE mode (immutable) +- `TestLegalHoldWorkflow` - Legal hold on/off functionality +- `TestObjectLockConfiguration` - Bucket object lock settings + +### Advanced Tests +- `TestRetentionWithVersions` - Version-specific retention policies +- `TestRetentionAndLegalHoldCombination` - Multiple protection types +- `TestExpiredRetention` - Post-expiration behavior +- `TestRetentionErrorCases` - Error handling and edge cases + +### WORM Integration Tests +- `TestWORMRetentionIntegration` - New retention + legacy WORM +- `TestWORMLegacyCompatibility` - Backward compatibility +- `TestRetentionOverwriteProtection` - Prevent overwrites +- `TestRetentionBulkOperations` - Bulk delete with retention +- `TestRetentionWithMultipartUpload` - Multipart upload retention +- `TestRetentionExtendedAttributes` - Extended attribute storage +- `TestRetentionBucketDefaults` - Default retention application +- `TestRetentionConcurrentOperations` - Concurrent operation safety + +## Individual Test Targets + +Run specific test categories: + +```bash +# Basic functionality +make test-basic-retention +make test-compliance-retention +make test-legal-hold + +# Advanced features +make test-retention-versions +make test-retention-combination +make test-expired-retention + +# WORM integration +make test-worm-integration +make test-worm-legacy +make test-retention-bulk +``` + +## Configuration + +### Server Configuration +The tests use these default settings: +- S3 Port: 8333 +- Test timeout: 15 minutes +- Volume directory: `./test-volume-data` + +### Test Configuration (`test_config.json`) +```json +{ + "endpoint": "http://localhost:8333", + "access_key": "some_access_key1", + "secret_key": "some_secret_key1", + "region": "us-east-1", + "bucket_prefix": "test-retention-", + "use_ssl": false, + "skip_verify_ssl": true +} +``` + +## Expected Behavior + +### GOVERNANCE Mode +- Objects protected until retain-until-date +- Can be bypassed with `x-amz-bypass-governance-retention` header +- Supports time extension (not reduction) + +### COMPLIANCE Mode +- Objects immutably protected until retain-until-date +- Cannot be bypassed or shortened +- Strictest protection level + +### Legal Hold +- Independent ON/OFF protection +- Can coexist with retention policies +- Must be explicitly removed to allow deletion + +### Version Support +- Each object version can have individual retention +- Applies to both versioned and non-versioned buckets +- Version-specific retention retrieval + +## Development + +### Running in Development Mode +```bash +# Start server for development +make dev-start + +# Run quick test +make dev-test +``` + +### Code Quality +```bash +# Format code +make fmt + +# Run linter +make lint + +# Generate coverage report +make coverage +``` + +### Performance Testing +```bash +# Run benchmarks +make benchmark-retention +``` + +## Troubleshooting + +### Server Won't Start +```bash +# Check if port is in use +netstat -tlnp | grep 8333 + +# View server logs +make logs + +# Force cleanup +make clean +``` + +### Test Failures +```bash +# Run with verbose output +go test -v -timeout=15m . + +# Run specific test +go test -v -run TestBasicRetentionWorkflow . + +# Check server health +make health-check +``` + +### Dependencies +```bash +# Install/update dependencies +make install-deps + +# Check dependency status +make check-deps +``` + +## Integration with SeaweedFS + +These tests validate the retention implementation in: +- `weed/s3api/s3api_object_retention.go` - Core retention logic +- `weed/s3api/s3api_object_handlers_retention.go` - HTTP handlers +- `weed/s3api/s3_constants/extend_key.go` - Extended attribute keys +- `weed/s3api/s3err/s3api_errors.go` - Error definitions +- `weed/s3api/s3api_object_handlers_delete.go` - Deletion enforcement +- `weed/s3api/s3api_object_handlers_put.go` - Upload enforcement + +## AWS CLI Compatibility + +The retention implementation supports standard AWS CLI commands: + +```bash +# Set object retention +aws s3api put-object-retention \ + --bucket mybucket \ + --key myobject \ + --retention Mode=GOVERNANCE,RetainUntilDate=2024-12-31T23:59:59Z + +# Get object retention +aws s3api get-object-retention \ + --bucket mybucket \ + --key myobject + +# Set legal hold +aws s3api put-object-legal-hold \ + --bucket mybucket \ + --key myobject \ + --legal-hold Status=ON + +# Configure bucket object lock +aws s3api put-object-lock-configuration \ + --bucket mybucket \ + --object-lock-configuration ObjectLockEnabled=Enabled,Rule='{DefaultRetention={Mode=GOVERNANCE,Days=30}}' +``` + +## Contributing + +When adding new retention tests: + +1. Follow existing test patterns +2. Use descriptive test names +3. Include both positive and negative test cases +4. Test error conditions +5. Update this README with new test descriptions +6. Add appropriate Makefile targets for new test categories + +## References + +- [AWS S3 Object Lock Documentation](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html) +- [AWS S3 API Reference - Object Retention](https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectRetention.html) +- [SeaweedFS S3 API Documentation](https://github.com/seaweedfs/seaweedfs/wiki/Amazon-S3-API) \ No newline at end of file diff --git a/test/s3/retention/go.mod b/test/s3/retention/go.mod new file mode 100644 index 000000000..3d0c0095d --- /dev/null +++ b/test/s3/retention/go.mod @@ -0,0 +1,31 @@ +module github.com/seaweedfs/seaweedfs/test/s3/retention + +go 1.21 + +require ( + github.com/aws/aws-sdk-go-v2 v1.21.2 + github.com/aws/aws-sdk-go-v2/config v1.18.45 + github.com/aws/aws-sdk-go-v2/credentials v1.13.43 + github.com/aws/aws-sdk-go-v2/service/s3 v1.40.0 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.13 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.43 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.3.45 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.6 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.38 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.37 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.15.2 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.23.2 // indirect + github.com/aws/smithy-go v1.15.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/test/s3/retention/go.sum b/test/s3/retention/go.sum new file mode 100644 index 000000000..f31ba829d --- /dev/null +++ b/test/s3/retention/go.sum @@ -0,0 +1,62 @@ +github.com/aws/aws-sdk-go-v2 v1.21.0/go.mod h1:/RfNgGmRxI+iFOB1OeJUyxiU+9s88k3pfHvDagGEp0M= +github.com/aws/aws-sdk-go-v2 v1.21.2 h1:+LXZ0sgo8quN9UOKXXzAWRT3FWd4NxeXWOZom9pE7GA= +github.com/aws/aws-sdk-go-v2 v1.21.2/go.mod h1:ErQhvNuEMhJjweavOYhxVkn2RUx7kQXVATHrjKtxIpM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.13 h1:OPLEkmhXf6xFPiz0bLeDArZIDx1NNS4oJyG4nv3Gct0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.13/go.mod h1:gpAbvyDGQFozTEmlTFO8XcQKHzubdq0LzRyJpG6MiXM= +github.com/aws/aws-sdk-go-v2/config v1.18.45 h1:Aka9bI7n8ysuwPeFdm77nfbyHCAKQ3z9ghB3S/38zes= +github.com/aws/aws-sdk-go-v2/config v1.18.45/go.mod h1:ZwDUgFnQgsazQTnWfeLWk5GjeqTQTL8lMkoE1UXzxdE= +github.com/aws/aws-sdk-go-v2/credentials v1.13.43 h1:LU8vo40zBlo3R7bAvBVy/ku4nxGEyZe9N8MqAeFTzF8= +github.com/aws/aws-sdk-go-v2/credentials v1.13.43/go.mod h1:zWJBz1Yf1ZtX5NGax9ZdNjhhI4rgjfgsyk6vTY1yfVg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.13 h1:PIktER+hwIG286DqXyvVENjgLTAwGgoeriLDD5C+YlQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.13/go.mod h1:f/Ib/qYjhV2/qdsf79H3QP/eRE4AkVyEf6sk7XfZ1tg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41/go.mod h1:CrObHAuPneJBlfEJ5T3szXOUkLEThaGfvnhTf33buas= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.43 h1:nFBQlGtkbPzp/NjZLuFxRqmT91rLJkgvsEQs68h962Y= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.43/go.mod h1:auo+PiyLl0n1l8A0e8RIeR8tOzYPfZZH/JNlrJ8igTQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35/go.mod h1:SJC1nEVVva1g3pHAIdCp7QsRIkMmLAgoDquQ9Rr8kYw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.37 h1:JRVhO25+r3ar2mKGP7E0LDl8K9/G36gjlqca5iQbaqc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.37/go.mod h1:Qe+2KtKml+FEsQF/DHmDV+xjtche/hwoF75EG4UlHW8= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.45 h1:hze8YsjSh8Wl1rYa1CJpRmXP21BvOBuc76YhW0HsuQ4= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.45/go.mod h1:lD5M20o09/LCuQ2mE62Mb/iSdSlCNuj6H5ci7tW7OsE= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.4/go.mod h1:1PrKYwxTM+zjpw9Y41KFtoJCQrJ34Z47Y4VgVbfndjo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.6 h1:wmGLw2i8ZTlHLw7a9ULGfQbuccw8uIiNr6sol5bFzc8= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.6/go.mod h1:Q0Hq2X/NuL7z8b1Dww8rmOFl+jzusKEcyvkKspwdpyc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.14/go.mod h1:dDilntgHy9WnHXsh7dDtUPgHKEfTJIBUTHM8OWm0f/0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.15 h1:7R8uRYyXzdD71KWVCL78lJZltah6VVznXBazvKjfH58= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.15/go.mod h1:26SQUPcTNgV1Tapwdt4a1rOsYRsnBsJHLMPoxK2b0d8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.36/go.mod h1:lGnOkH9NJATw0XEPcAknFBj3zzNTEGRHtSw+CwC1YTg= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.38 h1:skaFGzv+3kA+v2BPKhuekeb1Hbb105+44r8ASC+q5SE= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.38/go.mod h1:epIZoRSSbRIwLPJU5F+OldHhwZPBdpDeQkRdCeY3+00= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35/go.mod h1:QGF2Rs33W5MaN9gYdEQOBBFPLwTZkEhRwI33f7KIG0o= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.37 h1:WWZA/I2K4ptBS1kg0kV1JbBtG/umed0vwHRrmcr9z7k= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.37/go.mod h1:vBmDnwWXWxNPFRMmG2m/3MKOe+xEcMDo1tanpaWCcck= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.4/go.mod h1:LhTyt8J04LL+9cIt7pYJ5lbS/U98ZmXovLOR/4LUsk8= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.6 h1:9ulSU5ClouoPIYhDQdg9tpl83d5Yb91PXTKK+17q+ow= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.6/go.mod h1:lnc2taBsR9nTlz9meD+lhFZZ9EWY712QHrRflWpTcOA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.40.0 h1:wl5dxN1NONhTDQD9uaEvNsDRX29cBmGED/nl0jkWlt4= +github.com/aws/aws-sdk-go-v2/service/s3 v1.40.0/go.mod h1:rDGMZA7f4pbmTtPOk5v5UM2lmX6UAbRnMDJeDvnH7AM= +github.com/aws/aws-sdk-go-v2/service/sso v1.15.2 h1:JuPGc7IkOP4AaqcZSIcyqLpFSqBWK32rM9+a1g6u73k= +github.com/aws/aws-sdk-go-v2/service/sso v1.15.2/go.mod h1:gsL4keucRCgW+xA85ALBpRFfdSLH4kHOVSnLMSuBECo= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.3 h1:HFiiRkf1SdaAmV3/BHOFZ9DjFynPHj8G/UIO1lQS+fk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.3/go.mod h1:a7bHA82fyUXOm+ZSWKU6PIoBxrjSprdLoM8xPYvzYVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.23.2 h1:0BkLfgeDjfZnZ+MhB3ONb01u9pwFYTCZVhlsSSBvlbU= +github.com/aws/aws-sdk-go-v2/service/sts v1.23.2/go.mod h1:Eows6e1uQEsc4ZaHANmsPRzAKcVDrcmjjWiih2+HUUQ= +github.com/aws/smithy-go v1.14.2/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/aws/smithy-go v1.15.0 h1:PS/durmlzvAFpQHDs4wi4sNNP9ExsqZh6IlfdHXgKK8= +github.com/aws/smithy-go v1.15.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/s3/retention/s3_retention_test.go b/test/s3/retention/s3_retention_test.go new file mode 100644 index 000000000..e3f3222c9 --- /dev/null +++ b/test/s3/retention/s3_retention_test.go @@ -0,0 +1,694 @@ +package s3api + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// S3TestConfig holds configuration for S3 tests +type S3TestConfig struct { + Endpoint string + AccessKey string + SecretKey string + Region string + BucketPrefix string + UseSSL bool + SkipVerifySSL bool +} + +// Default test configuration - should match test_config.json +var defaultConfig = &S3TestConfig{ + Endpoint: "http://localhost:8333", // Default SeaweedFS S3 port + AccessKey: "some_access_key1", + SecretKey: "some_secret_key1", + Region: "us-east-1", + BucketPrefix: "test-retention-", + UseSSL: false, + SkipVerifySSL: true, +} + +// getS3Client creates an AWS S3 client for testing +func getS3Client(t *testing.T) *s3.Client { + cfg, err := config.LoadDefaultConfig(context.TODO(), + config.WithRegion(defaultConfig.Region), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + defaultConfig.AccessKey, + defaultConfig.SecretKey, + "", + )), + config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc( + func(service, region string, options ...interface{}) (aws.Endpoint, error) { + return aws.Endpoint{ + URL: defaultConfig.Endpoint, + SigningRegion: defaultConfig.Region, + HostnameImmutable: true, + }, nil + })), + ) + require.NoError(t, err) + + return s3.NewFromConfig(cfg, func(o *s3.Options) { + o.UsePathStyle = true // Important for SeaweedFS + }) +} + +// getNewBucketName generates a unique bucket name +func getNewBucketName() string { + timestamp := time.Now().UnixNano() + return fmt.Sprintf("%s%d", defaultConfig.BucketPrefix, timestamp) +} + +// createBucket creates a new bucket for testing +func createBucket(t *testing.T, client *s3.Client, bucketName string) { + _, err := client.CreateBucket(context.TODO(), &s3.CreateBucketInput{ + Bucket: aws.String(bucketName), + }) + require.NoError(t, err) +} + +// deleteBucket deletes a bucket and all its contents +func deleteBucket(t *testing.T, client *s3.Client, bucketName string) { + // First, try to delete all objects and versions + err := deleteAllObjectVersions(t, client, bucketName) + if err != nil { + t.Logf("Warning: failed to delete all object versions in first attempt: %v", err) + // Try once more in case of transient errors + time.Sleep(500 * time.Millisecond) + err = deleteAllObjectVersions(t, client, bucketName) + if err != nil { + t.Logf("Warning: failed to delete all object versions in second attempt: %v", err) + } + } + + // Wait a bit for eventual consistency + time.Sleep(100 * time.Millisecond) + + // Try to delete the bucket multiple times in case of eventual consistency issues + for retries := 0; retries < 3; retries++ { + _, err = client.DeleteBucket(context.TODO(), &s3.DeleteBucketInput{ + Bucket: aws.String(bucketName), + }) + if err == nil { + t.Logf("Successfully deleted bucket %s", bucketName) + return + } + + t.Logf("Warning: failed to delete bucket %s (attempt %d): %v", bucketName, retries+1, err) + if retries < 2 { + time.Sleep(200 * time.Millisecond) + } + } +} + +// deleteAllObjectVersions deletes all object versions in a bucket +func deleteAllObjectVersions(t *testing.T, client *s3.Client, bucketName string) error { + // List all object versions + paginator := s3.NewListObjectVersionsPaginator(client, &s3.ListObjectVersionsInput{ + Bucket: aws.String(bucketName), + }) + + for paginator.HasMorePages() { + page, err := paginator.NextPage(context.TODO()) + if err != nil { + return err + } + + var objectsToDelete []types.ObjectIdentifier + + // Add versions - first try to remove retention/legal hold + for _, version := range page.Versions { + // Try to remove legal hold if present + _, err := client.PutObjectLegalHold(context.TODO(), &s3.PutObjectLegalHoldInput{ + Bucket: aws.String(bucketName), + Key: version.Key, + VersionId: version.VersionId, + LegalHold: &types.ObjectLockLegalHold{ + Status: types.ObjectLockLegalHoldStatusOff, + }, + }) + if err != nil { + // Legal hold might not be set, ignore error + t.Logf("Note: could not remove legal hold for %s@%s: %v", *version.Key, *version.VersionId, err) + } + + objectsToDelete = append(objectsToDelete, types.ObjectIdentifier{ + Key: version.Key, + VersionId: version.VersionId, + }) + } + + // Add delete markers + for _, deleteMarker := range page.DeleteMarkers { + objectsToDelete = append(objectsToDelete, types.ObjectIdentifier{ + Key: deleteMarker.Key, + VersionId: deleteMarker.VersionId, + }) + } + + // Delete objects in batches with bypass governance retention + if len(objectsToDelete) > 0 { + _, err := client.DeleteObjects(context.TODO(), &s3.DeleteObjectsInput{ + Bucket: aws.String(bucketName), + BypassGovernanceRetention: true, + Delete: &types.Delete{ + Objects: objectsToDelete, + Quiet: true, + }, + }) + if err != nil { + t.Logf("Warning: batch delete failed, trying individual deletion: %v", err) + // Try individual deletion for each object + for _, obj := range objectsToDelete { + _, delErr := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: obj.Key, + VersionId: obj.VersionId, + BypassGovernanceRetention: true, + }) + if delErr != nil { + t.Logf("Warning: failed to delete object %s@%s: %v", *obj.Key, *obj.VersionId, delErr) + } + } + } + } + } + + return nil +} + +// enableVersioning enables versioning on a bucket +func enableVersioning(t *testing.T, client *s3.Client, bucketName string) { + _, err := client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{ + Bucket: aws.String(bucketName), + VersioningConfiguration: &types.VersioningConfiguration{ + Status: types.BucketVersioningStatusEnabled, + }, + }) + require.NoError(t, err) +} + +// putObject puts an object into a bucket +func putObject(t *testing.T, client *s3.Client, bucketName, key, content string) *s3.PutObjectOutput { + resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Body: strings.NewReader(content), + }) + require.NoError(t, err) + return resp +} + +// cleanupAllTestBuckets cleans up any leftover test buckets +func cleanupAllTestBuckets(t *testing.T, client *s3.Client) { + // List all buckets + listResp, err := client.ListBuckets(context.TODO(), &s3.ListBucketsInput{}) + if err != nil { + t.Logf("Warning: failed to list buckets for cleanup: %v", err) + return + } + + // Delete buckets that match our test prefix + for _, bucket := range listResp.Buckets { + if bucket.Name != nil && strings.HasPrefix(*bucket.Name, defaultConfig.BucketPrefix) { + t.Logf("Cleaning up leftover test bucket: %s", *bucket.Name) + deleteBucket(t, client, *bucket.Name) + } + } +} + +// TestBasicRetentionWorkflow tests the basic retention functionality +func TestBasicRetentionWorkflow(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + // Create bucket + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + + // Enable versioning (required for retention) + enableVersioning(t, client, bucketName) + + // Create object + key := "test-object" + content := "test content for retention" + putResp := putObject(t, client, bucketName, key, content) + require.NotNil(t, putResp.VersionId) + + // Set retention with GOVERNANCE mode + retentionUntil := time.Now().Add(24 * time.Hour) + _, err := client.PutObjectRetention(context.TODO(), &s3.PutObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: aws.Time(retentionUntil), + }, + }) + require.NoError(t, err) + + // Get retention and verify it was set correctly + retentionResp, err := client.GetObjectRetention(context.TODO(), &s3.GetObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.NoError(t, err) + assert.Equal(t, types.ObjectLockRetentionModeGovernance, retentionResp.Retention.Mode) + assert.WithinDuration(t, retentionUntil, *retentionResp.Retention.RetainUntilDate, time.Second) + + // Try to delete object without bypass - should fail + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.Error(t, err) + + // Delete object with bypass governance - should succeed + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + BypassGovernanceRetention: true, + }) + require.NoError(t, err) +} + +// TestRetentionModeCompliance tests COMPLIANCE mode retention +func TestRetentionModeCompliance(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Create object + key := "compliance-test-object" + content := "compliance test content" + putResp := putObject(t, client, bucketName, key, content) + require.NotNil(t, putResp.VersionId) + + // Set retention with COMPLIANCE mode + retentionUntil := time.Now().Add(1 * time.Hour) + _, err := client.PutObjectRetention(context.TODO(), &s3.PutObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeCompliance, + RetainUntilDate: aws.Time(retentionUntil), + }, + }) + require.NoError(t, err) + + // Get retention and verify + retentionResp, err := client.GetObjectRetention(context.TODO(), &s3.GetObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.NoError(t, err) + assert.Equal(t, types.ObjectLockRetentionModeCompliance, retentionResp.Retention.Mode) + + // Try to delete object with bypass - should still fail (compliance mode) + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + BypassGovernanceRetention: true, + }) + require.Error(t, err) + + // Try to delete object without bypass - should also fail + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.Error(t, err) +} + +// TestLegalHoldWorkflow tests legal hold functionality +func TestLegalHoldWorkflow(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Create object + key := "legal-hold-test-object" + content := "legal hold test content" + putResp := putObject(t, client, bucketName, key, content) + require.NotNil(t, putResp.VersionId) + + // Set legal hold ON + _, err := client.PutObjectLegalHold(context.TODO(), &s3.PutObjectLegalHoldInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + LegalHold: &types.ObjectLockLegalHold{ + Status: types.ObjectLockLegalHoldStatusOn, + }, + }) + require.NoError(t, err) + + // Get legal hold and verify + legalHoldResp, err := client.GetObjectLegalHold(context.TODO(), &s3.GetObjectLegalHoldInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.NoError(t, err) + assert.Equal(t, types.ObjectLockLegalHoldStatusOn, legalHoldResp.LegalHold.Status) + + // Try to delete object - should fail due to legal hold + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.Error(t, err) + + // Remove legal hold + _, err = client.PutObjectLegalHold(context.TODO(), &s3.PutObjectLegalHoldInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + LegalHold: &types.ObjectLockLegalHold{ + Status: types.ObjectLockLegalHoldStatusOff, + }, + }) + require.NoError(t, err) + + // Verify legal hold is off + legalHoldResp, err = client.GetObjectLegalHold(context.TODO(), &s3.GetObjectLegalHoldInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.NoError(t, err) + assert.Equal(t, types.ObjectLockLegalHoldStatusOff, legalHoldResp.LegalHold.Status) + + // Now delete should succeed + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.NoError(t, err) +} + +// TestObjectLockConfiguration tests bucket object lock configuration +func TestObjectLockConfiguration(t *testing.T) { + client := getS3Client(t) + // Use a more unique bucket name to avoid conflicts + bucketName := fmt.Sprintf("object-lock-config-%d-%d", time.Now().UnixNano(), time.Now().UnixMilli()%10000) + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Set object lock configuration + _, err := client.PutObjectLockConfiguration(context.TODO(), &s3.PutObjectLockConfigurationInput{ + Bucket: aws.String(bucketName), + ObjectLockConfiguration: &types.ObjectLockConfiguration{ + ObjectLockEnabled: types.ObjectLockEnabledEnabled, + Rule: &types.ObjectLockRule{ + DefaultRetention: &types.DefaultRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + Days: 30, + }, + }, + }, + }) + if err != nil { + t.Logf("PutObjectLockConfiguration failed (may not be supported): %v", err) + t.Skip("Object lock configuration not supported, skipping test") + return + } + + // Get object lock configuration and verify + configResp, err := client.GetObjectLockConfiguration(context.TODO(), &s3.GetObjectLockConfigurationInput{ + Bucket: aws.String(bucketName), + }) + require.NoError(t, err) + assert.Equal(t, types.ObjectLockEnabledEnabled, configResp.ObjectLockConfiguration.ObjectLockEnabled) + assert.Equal(t, types.ObjectLockRetentionModeGovernance, configResp.ObjectLockConfiguration.Rule.DefaultRetention.Mode) + assert.Equal(t, int32(30), configResp.ObjectLockConfiguration.Rule.DefaultRetention.Days) +} + +// TestRetentionWithVersions tests retention with specific object versions +func TestRetentionWithVersions(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Create multiple versions of the same object + key := "versioned-retention-test" + content1 := "version 1 content" + content2 := "version 2 content" + + putResp1 := putObject(t, client, bucketName, key, content1) + require.NotNil(t, putResp1.VersionId) + + putResp2 := putObject(t, client, bucketName, key, content2) + require.NotNil(t, putResp2.VersionId) + + // Set retention on first version only + retentionUntil := time.Now().Add(1 * time.Hour) + _, err := client.PutObjectRetention(context.TODO(), &s3.PutObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + VersionId: putResp1.VersionId, + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: aws.Time(retentionUntil), + }, + }) + require.NoError(t, err) + + // Get retention for first version + retentionResp, err := client.GetObjectRetention(context.TODO(), &s3.GetObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + VersionId: putResp1.VersionId, + }) + require.NoError(t, err) + assert.Equal(t, types.ObjectLockRetentionModeGovernance, retentionResp.Retention.Mode) + + // Try to get retention for second version - should fail (no retention set) + _, err = client.GetObjectRetention(context.TODO(), &s3.GetObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + VersionId: putResp2.VersionId, + }) + require.Error(t, err) + + // Delete second version should succeed (no retention) + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + VersionId: putResp2.VersionId, + }) + require.NoError(t, err) + + // Delete first version should fail (has retention) + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + VersionId: putResp1.VersionId, + }) + require.Error(t, err) + + // Delete first version with bypass should succeed + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + VersionId: putResp1.VersionId, + BypassGovernanceRetention: true, + }) + require.NoError(t, err) +} + +// TestRetentionAndLegalHoldCombination tests retention and legal hold together +func TestRetentionAndLegalHoldCombination(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Create object + key := "combined-protection-test" + content := "combined protection test content" + putResp := putObject(t, client, bucketName, key, content) + require.NotNil(t, putResp.VersionId) + + // Set both retention and legal hold + retentionUntil := time.Now().Add(1 * time.Hour) + + // Set retention + _, err := client.PutObjectRetention(context.TODO(), &s3.PutObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: aws.Time(retentionUntil), + }, + }) + require.NoError(t, err) + + // Set legal hold + _, err = client.PutObjectLegalHold(context.TODO(), &s3.PutObjectLegalHoldInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + LegalHold: &types.ObjectLockLegalHold{ + Status: types.ObjectLockLegalHoldStatusOn, + }, + }) + require.NoError(t, err) + + // Try to delete with bypass governance - should still fail due to legal hold + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + BypassGovernanceRetention: true, + }) + require.Error(t, err) + + // Remove legal hold + _, err = client.PutObjectLegalHold(context.TODO(), &s3.PutObjectLegalHoldInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + LegalHold: &types.ObjectLockLegalHold{ + Status: types.ObjectLockLegalHoldStatusOff, + }, + }) + require.NoError(t, err) + + // Now delete with bypass governance should succeed + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + BypassGovernanceRetention: true, + }) + require.NoError(t, err) +} + +// TestExpiredRetention tests that objects can be deleted after retention expires +func TestExpiredRetention(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Create object + key := "expired-retention-test" + content := "expired retention test content" + putResp := putObject(t, client, bucketName, key, content) + require.NotNil(t, putResp.VersionId) + + // Set retention for a very short time (2 seconds) + retentionUntil := time.Now().Add(2 * time.Second) + _, err := client.PutObjectRetention(context.TODO(), &s3.PutObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: aws.Time(retentionUntil), + }, + }) + require.NoError(t, err) + + // Try to delete immediately - should fail + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.Error(t, err) + + // Wait for retention to expire + time.Sleep(3 * time.Second) + + // Now delete should succeed + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.NoError(t, err) +} + +// TestRetentionErrorCases tests various error conditions +func TestRetentionErrorCases(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Test setting retention on non-existent object + _, err := client.PutObjectRetention(context.TODO(), &s3.PutObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String("non-existent-key"), + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: aws.Time(time.Now().Add(1 * time.Hour)), + }, + }) + require.Error(t, err) + + // Test getting retention on non-existent object + _, err = client.GetObjectRetention(context.TODO(), &s3.GetObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String("non-existent-key"), + }) + require.Error(t, err) + + // Test setting legal hold on non-existent object + _, err = client.PutObjectLegalHold(context.TODO(), &s3.PutObjectLegalHoldInput{ + Bucket: aws.String(bucketName), + Key: aws.String("non-existent-key"), + LegalHold: &types.ObjectLockLegalHold{ + Status: types.ObjectLockLegalHoldStatusOn, + }, + }) + require.Error(t, err) + + // Test getting legal hold on non-existent object + _, err = client.GetObjectLegalHold(context.TODO(), &s3.GetObjectLegalHoldInput{ + Bucket: aws.String(bucketName), + Key: aws.String("non-existent-key"), + }) + require.Error(t, err) + + // Test setting retention with past date + key := "retention-past-date-test" + content := "test content" + putObject(t, client, bucketName, key, content) + + pastDate := time.Now().Add(-1 * time.Hour) + _, err = client.PutObjectRetention(context.TODO(), &s3.PutObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: aws.Time(pastDate), + }, + }) + require.Error(t, err) +} diff --git a/test/s3/retention/s3_worm_integration_test.go b/test/s3/retention/s3_worm_integration_test.go new file mode 100644 index 000000000..31217001f --- /dev/null +++ b/test/s3/retention/s3_worm_integration_test.go @@ -0,0 +1,519 @@ +package s3api + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWORMRetentionIntegration tests that both retention and legacy WORM work together +func TestWORMRetentionIntegration(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Create object + key := "worm-retention-integration-test" + content := "worm retention integration test content" + putResp := putObject(t, client, bucketName, key, content) + require.NotNil(t, putResp.VersionId) + + // Set retention (new system) + retentionUntil := time.Now().Add(1 * time.Hour) + _, err := client.PutObjectRetention(context.TODO(), &s3.PutObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: aws.Time(retentionUntil), + }, + }) + require.NoError(t, err) + + // Try to delete - should fail due to retention + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.Error(t, err) + + // Delete with bypass should succeed + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + BypassGovernanceRetention: true, + }) + require.NoError(t, err) +} + +// TestWORMLegacyCompatibility tests that legacy WORM functionality still works +func TestWORMLegacyCompatibility(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Create object with legacy WORM headers (if supported) + key := "legacy-worm-test" + content := "legacy worm test content" + + // Try to create object with legacy WORM TTL header + putResp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Body: strings.NewReader(content), + // Add legacy WORM headers if supported + Metadata: map[string]string{ + "x-amz-meta-worm-ttl": fmt.Sprintf("%d", time.Now().Add(1*time.Hour).Unix()), + }, + }) + require.NoError(t, err) + require.NotNil(t, putResp.VersionId) + + // Object should be created successfully + resp, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.NoError(t, err) + assert.NotNil(t, resp.Metadata) +} + +// TestRetentionOverwriteProtection tests that retention prevents overwrites +func TestRetentionOverwriteProtection(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Create object + key := "overwrite-protection-test" + content := "original content" + putResp := putObject(t, client, bucketName, key, content) + require.NotNil(t, putResp.VersionId) + + // Verify object exists before setting retention + _, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.NoError(t, err, "Object should exist before setting retention") + + // Set retention with specific version ID + retentionUntil := time.Now().Add(1 * time.Hour) + _, err = client.PutObjectRetention(context.TODO(), &s3.PutObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + VersionId: putResp.VersionId, + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: aws.Time(retentionUntil), + }, + }) + require.NoError(t, err) + + // Try to overwrite object - should fail in non-versioned bucket context + content2 := "new content" + _, err = client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Body: strings.NewReader(content2), + }) + // Note: In a real scenario, this might fail or create a new version + // The actual behavior depends on the implementation + if err != nil { + t.Logf("Expected behavior: overwrite blocked due to retention: %v", err) + } else { + t.Logf("Overwrite allowed, likely created new version") + } +} + +// TestRetentionBulkOperations tests retention with bulk operations +func TestRetentionBulkOperations(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Create multiple objects with retention + var objectsToDelete []types.ObjectIdentifier + retentionUntil := time.Now().Add(1 * time.Hour) + + for i := 0; i < 3; i++ { + key := fmt.Sprintf("bulk-test-object-%d", i) + content := fmt.Sprintf("bulk test content %d", i) + + putResp := putObject(t, client, bucketName, key, content) + require.NotNil(t, putResp.VersionId) + + // Set retention on each object with version ID + _, err := client.PutObjectRetention(context.TODO(), &s3.PutObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + VersionId: putResp.VersionId, + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: aws.Time(retentionUntil), + }, + }) + require.NoError(t, err) + + objectsToDelete = append(objectsToDelete, types.ObjectIdentifier{ + Key: aws.String(key), + VersionId: putResp.VersionId, + }) + } + + // Try bulk delete without bypass - should fail or have errors + deleteResp, err := client.DeleteObjects(context.TODO(), &s3.DeleteObjectsInput{ + Bucket: aws.String(bucketName), + Delete: &types.Delete{ + Objects: objectsToDelete, + Quiet: false, + }, + }) + + // Check if operation failed or returned errors for protected objects + if err != nil { + t.Logf("Expected: bulk delete failed due to retention: %v", err) + } else if deleteResp != nil && len(deleteResp.Errors) > 0 { + t.Logf("Expected: bulk delete returned %d errors due to retention", len(deleteResp.Errors)) + for _, delErr := range deleteResp.Errors { + t.Logf("Delete error: %s - %s", *delErr.Code, *delErr.Message) + } + } else { + t.Logf("Warning: bulk delete succeeded - retention may not be enforced for bulk operations") + } + + // Try bulk delete with bypass - should succeed + _, err = client.DeleteObjects(context.TODO(), &s3.DeleteObjectsInput{ + Bucket: aws.String(bucketName), + BypassGovernanceRetention: true, + Delete: &types.Delete{ + Objects: objectsToDelete, + Quiet: false, + }, + }) + if err != nil { + t.Logf("Bulk delete with bypass failed (may not be supported): %v", err) + } else { + t.Logf("Bulk delete with bypass succeeded") + } +} + +// TestRetentionWithMultipartUpload tests retention with multipart uploads +func TestRetentionWithMultipartUpload(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Start multipart upload + key := "multipart-retention-test" + createResp, err := client.CreateMultipartUpload(context.TODO(), &s3.CreateMultipartUploadInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.NoError(t, err) + uploadId := createResp.UploadId + + // Upload a part + partContent := "This is a test part for multipart upload" + uploadResp, err := client.UploadPart(context.TODO(), &s3.UploadPartInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + PartNumber: 1, + UploadId: uploadId, + Body: strings.NewReader(partContent), + }) + require.NoError(t, err) + + // Complete multipart upload + completeResp, err := client.CompleteMultipartUpload(context.TODO(), &s3.CompleteMultipartUploadInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + UploadId: uploadId, + MultipartUpload: &types.CompletedMultipartUpload{ + Parts: []types.CompletedPart{ + { + ETag: uploadResp.ETag, + PartNumber: 1, + }, + }, + }, + }) + require.NoError(t, err) + + // Add a small delay to ensure the object is fully created + time.Sleep(500 * time.Millisecond) + + // Verify object exists after multipart upload - retry if needed + var headErr error + for retries := 0; retries < 10; retries++ { + _, headErr = client.HeadObject(context.TODO(), &s3.HeadObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + if headErr == nil { + break + } + t.Logf("HeadObject attempt %d failed: %v", retries+1, headErr) + time.Sleep(200 * time.Millisecond) + } + + if headErr != nil { + t.Logf("Object not found after multipart upload completion, checking if multipart upload is fully supported") + // Check if the object exists by trying to list it + listResp, listErr := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ + Bucket: aws.String(bucketName), + Prefix: aws.String(key), + }) + if listErr != nil || len(listResp.Contents) == 0 { + t.Skip("Multipart upload may not be fully supported, skipping test") + return + } + // If object exists in listing but not accessible via HeadObject, skip test + t.Skip("Object exists in listing but not accessible via HeadObject, multipart upload may not be fully supported") + return + } + + require.NoError(t, headErr, "Object should exist after multipart upload") + + // Set retention on the completed multipart object with version ID + retentionUntil := time.Now().Add(1 * time.Hour) + _, err = client.PutObjectRetention(context.TODO(), &s3.PutObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + VersionId: completeResp.VersionId, + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: aws.Time(retentionUntil), + }, + }) + require.NoError(t, err) + + // Try to delete - should fail + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.Error(t, err) +} + +// TestRetentionExtendedAttributes tests that retention uses extended attributes correctly +func TestRetentionExtendedAttributes(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Create object + key := "extended-attrs-test" + content := "extended attributes test content" + putResp := putObject(t, client, bucketName, key, content) + require.NotNil(t, putResp.VersionId) + + // Set retention + retentionUntil := time.Now().Add(1 * time.Hour) + _, err := client.PutObjectRetention(context.TODO(), &s3.PutObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + VersionId: putResp.VersionId, + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: aws.Time(retentionUntil), + }, + }) + require.NoError(t, err) + + // Set legal hold + _, err = client.PutObjectLegalHold(context.TODO(), &s3.PutObjectLegalHoldInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + VersionId: putResp.VersionId, + LegalHold: &types.ObjectLockLegalHold{ + Status: types.ObjectLockLegalHoldStatusOn, + }, + }) + require.NoError(t, err) + + // Get object metadata to verify extended attributes are set + resp, err := client.HeadObject(context.TODO(), &s3.HeadObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.NoError(t, err) + + // Check that the object has metadata (may be empty in some implementations) + // Note: The actual metadata keys depend on the implementation + if resp.Metadata != nil && len(resp.Metadata) > 0 { + t.Logf("Object metadata: %+v", resp.Metadata) + } else { + t.Logf("Object metadata: empty (extended attributes may be stored internally)") + } + + // Verify retention can be retrieved + retentionResp, err := client.GetObjectRetention(context.TODO(), &s3.GetObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.NoError(t, err) + assert.Equal(t, types.ObjectLockRetentionModeGovernance, retentionResp.Retention.Mode) + + // Verify legal hold can be retrieved + legalHoldResp, err := client.GetObjectLegalHold(context.TODO(), &s3.GetObjectLegalHoldInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + require.NoError(t, err) + assert.Equal(t, types.ObjectLockLegalHoldStatusOn, legalHoldResp.LegalHold.Status) +} + +// TestRetentionBucketDefaults tests object lock configuration defaults +func TestRetentionBucketDefaults(t *testing.T) { + client := getS3Client(t) + // Use a very unique bucket name to avoid conflicts + bucketName := fmt.Sprintf("bucket-defaults-%d-%d", time.Now().UnixNano(), time.Now().UnixMilli()%10000) + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Set bucket object lock configuration with default retention + _, err := client.PutObjectLockConfiguration(context.TODO(), &s3.PutObjectLockConfigurationInput{ + Bucket: aws.String(bucketName), + ObjectLockConfiguration: &types.ObjectLockConfiguration{ + ObjectLockEnabled: types.ObjectLockEnabledEnabled, + Rule: &types.ObjectLockRule{ + DefaultRetention: &types.DefaultRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + Days: 1, // 1 day default + }, + }, + }, + }) + if err != nil { + t.Logf("PutObjectLockConfiguration failed (may not be supported): %v", err) + t.Skip("Object lock configuration not supported, skipping test") + return + } + + // Create object (should inherit default retention) + key := "bucket-defaults-test" + content := "bucket defaults test content" + putResp := putObject(t, client, bucketName, key, content) + require.NotNil(t, putResp.VersionId) + + // Check if object has default retention applied + // Note: This depends on the implementation - some S3 services apply + // default retention automatically, others require explicit setting + retentionResp, err := client.GetObjectRetention(context.TODO(), &s3.GetObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + if err != nil { + t.Logf("No automatic default retention applied: %v", err) + } else { + t.Logf("Default retention applied: %+v", retentionResp.Retention) + assert.Equal(t, types.ObjectLockRetentionModeGovernance, retentionResp.Retention.Mode) + } +} + +// TestRetentionConcurrentOperations tests concurrent retention operations +func TestRetentionConcurrentOperations(t *testing.T) { + client := getS3Client(t) + bucketName := getNewBucketName() + + // Create bucket and enable versioning + createBucket(t, client, bucketName) + defer deleteBucket(t, client, bucketName) + enableVersioning(t, client, bucketName) + + // Create object + key := "concurrent-ops-test" + content := "concurrent operations test content" + putResp := putObject(t, client, bucketName, key, content) + require.NotNil(t, putResp.VersionId) + + // Test concurrent retention and legal hold operations + retentionUntil := time.Now().Add(1 * time.Hour) + + // Set retention and legal hold concurrently + errChan := make(chan error, 2) + + go func() { + _, err := client.PutObjectRetention(context.TODO(), &s3.PutObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + Retention: &types.ObjectLockRetention{ + Mode: types.ObjectLockRetentionModeGovernance, + RetainUntilDate: aws.Time(retentionUntil), + }, + }) + errChan <- err + }() + + go func() { + _, err := client.PutObjectLegalHold(context.TODO(), &s3.PutObjectLegalHoldInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + LegalHold: &types.ObjectLockLegalHold{ + Status: types.ObjectLockLegalHoldStatusOn, + }, + }) + errChan <- err + }() + + // Wait for both operations to complete + for i := 0; i < 2; i++ { + err := <-errChan + if err != nil { + t.Logf("Concurrent operation failed: %v", err) + } + } + + // Verify both settings are applied + retentionResp, err := client.GetObjectRetention(context.TODO(), &s3.GetObjectRetentionInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + if err == nil { + assert.Equal(t, types.ObjectLockRetentionModeGovernance, retentionResp.Retention.Mode) + } + + legalHoldResp, err := client.GetObjectLegalHold(context.TODO(), &s3.GetObjectLegalHoldInput{ + Bucket: aws.String(bucketName), + Key: aws.String(key), + }) + if err == nil { + assert.Equal(t, types.ObjectLockLegalHoldStatusOn, legalHoldResp.LegalHold.Status) + } +} diff --git a/test/s3/retention/test_config.json b/test/s3/retention/test_config.json new file mode 100644 index 000000000..b3281778b --- /dev/null +++ b/test/s3/retention/test_config.json @@ -0,0 +1,9 @@ +{ + "endpoint": "http://localhost:8333", + "access_key": "some_access_key1", + "secret_key": "some_secret_key1", + "region": "us-east-1", + "bucket_prefix": "test-retention-", + "use_ssl": false, + "skip_verify_ssl": true +} \ No newline at end of file diff --git a/weed/s3api/s3_constants/extend_key.go b/weed/s3api/s3_constants/extend_key.go index 9806d899e..79fcbb239 100644 --- a/weed/s3api/s3_constants/extend_key.go +++ b/weed/s3api/s3_constants/extend_key.go @@ -11,4 +11,25 @@ const ( ExtETagKey = "Seaweed-X-Amz-ETag" ExtLatestVersionIdKey = "Seaweed-X-Amz-Latest-Version-Id" ExtLatestVersionFileNameKey = "Seaweed-X-Amz-Latest-Version-File-Name" + + // Object Retention and Legal Hold + ExtObjectLockModeKey = "Seaweed-X-Amz-Object-Lock-Mode" + ExtRetentionUntilDateKey = "Seaweed-X-Amz-Retention-Until-Date" + ExtLegalHoldKey = "Seaweed-X-Amz-Legal-Hold" + ExtObjectLockEnabledKey = "Seaweed-X-Amz-Object-Lock-Enabled" + ExtObjectLockConfigKey = "Seaweed-X-Amz-Object-Lock-Config" +) + +// Object Lock and Retention Constants +const ( + // Retention modes + RetentionModeGovernance = "GOVERNANCE" + RetentionModeCompliance = "COMPLIANCE" + + // Legal hold status + LegalHoldOn = "ON" + LegalHoldOff = "OFF" + + // Object lock enabled status + ObjectLockEnabled = "Enabled" ) diff --git a/weed/s3api/s3api_object_handlers_delete.go b/weed/s3api/s3api_object_handlers_delete.go index d7457fabe..35c842e6c 100644 --- a/weed/s3api/s3api_object_handlers_delete.go +++ b/weed/s3api/s3api_object_handlers_delete.go @@ -49,6 +49,16 @@ func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Reque auditLog = s3err.GetAccessLog(r, http.StatusNoContent, s3err.ErrNone) } + // Check object lock permissions before deletion (only for versioned buckets) + if versioningEnabled { + bypassGovernance := r.Header.Get("x-amz-bypass-governance-retention") == "true" + if err := s3a.checkObjectLockPermissions(bucket, object, versionId, bypassGovernance); err != nil { + glog.V(2).Infof("DeleteObjectHandler: object lock check failed for %s/%s: %v", bucket, object, err) + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } + } + if versioningEnabled { // Handle versioned delete if versionId != "" { @@ -117,9 +127,10 @@ func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Reque w.WriteHeader(http.StatusNoContent) } -// / ObjectIdentifier carries key name for the object to delete. +// ObjectIdentifier represents an object to be deleted with its key name and optional version ID. type ObjectIdentifier struct { - ObjectName string `xml:"Key"` + Key string `xml:"Key"` + VersionId string `xml:"VersionId,omitempty"` } // DeleteObjectsRequest - xml carrying the object key names which needs to be deleted. @@ -132,9 +143,10 @@ type DeleteObjectsRequest struct { // DeleteError structure. type DeleteError struct { - Code string - Message string - Key string + Code string `xml:"Code"` + Message string `xml:"Message"` + Key string `xml:"Key"` + VersionId string `xml:"VersionId,omitempty"` } // DeleteObjectsResponse container for multiple object deletes. @@ -180,18 +192,48 @@ func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *h if s3err.Logger != nil { auditLog = s3err.GetAccessLog(r, http.StatusNoContent, s3err.ErrNone) } + + // Check for bypass governance retention header + bypassGovernance := r.Header.Get("x-amz-bypass-governance-retention") == "true" + + // Check if versioning is enabled for the bucket (needed for object lock checks) + versioningEnabled, err := s3a.isVersioningEnabled(bucket) + if err != nil { + if err == filer_pb.ErrNotFound { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket) + return + } + glog.Errorf("Error checking versioning status for bucket %s: %v", bucket, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { // delete file entries for _, object := range deleteObjects.Objects { - if object.ObjectName == "" { + if object.Key == "" { continue } - lastSeparator := strings.LastIndex(object.ObjectName, "/") - parentDirectoryPath, entryName, isDeleteData, isRecursive := "", object.ObjectName, true, false - if lastSeparator > 0 && lastSeparator+1 < len(object.ObjectName) { - entryName = object.ObjectName[lastSeparator+1:] - parentDirectoryPath = "/" + object.ObjectName[:lastSeparator] + + // Check object lock permissions before deletion (only for versioned buckets) + if versioningEnabled { + if err := s3a.checkObjectLockPermissions(bucket, object.Key, object.VersionId, bypassGovernance); err != nil { + glog.V(2).Infof("DeleteMultipleObjectsHandler: object lock check failed for %s/%s (version: %s): %v", bucket, object.Key, object.VersionId, err) + deleteErrors = append(deleteErrors, DeleteError{ + Code: s3err.GetAPIError(s3err.ErrAccessDenied).Code, + Message: s3err.GetAPIError(s3err.ErrAccessDenied).Description, + Key: object.Key, + VersionId: object.VersionId, + }) + continue + } + } + lastSeparator := strings.LastIndex(object.Key, "/") + parentDirectoryPath, entryName, isDeleteData, isRecursive := "", object.Key, true, false + if lastSeparator > 0 && lastSeparator+1 < len(object.Key) { + entryName = object.Key[lastSeparator+1:] + parentDirectoryPath = "/" + object.Key[:lastSeparator] } parentDirectoryPath = fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, parentDirectoryPath) @@ -204,9 +246,10 @@ func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *h } else { delete(directoriesWithDeletion, parentDirectoryPath) deleteErrors = append(deleteErrors, DeleteError{ - Code: "", - Message: err.Error(), - Key: object.ObjectName, + Code: "", + Message: err.Error(), + Key: object.Key, + VersionId: object.VersionId, }) } if auditLog != nil { diff --git a/weed/s3api/s3api_object_handlers_put.go b/weed/s3api/s3api_object_handlers_put.go index 8b85a049a..371ab870f 100644 --- a/weed/s3api/s3api_object_handlers_put.go +++ b/weed/s3api/s3api_object_handlers_put.go @@ -85,6 +85,13 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request) glog.V(1).Infof("PutObjectHandler: bucket %s, object %s, versioningEnabled=%v", bucket, object, versioningEnabled) + // Check object lock permissions before PUT operation (only for versioned buckets) + bypassGovernance := r.Header.Get("x-amz-bypass-governance-retention") == "true" + if err := s3a.checkObjectLockPermissionsForPut(bucket, object, bypassGovernance, versioningEnabled); err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } + if versioningEnabled { // Handle versioned PUT glog.V(1).Infof("PutObjectHandler: using versioned PUT for %s/%s", bucket, object) diff --git a/weed/s3api/s3api_object_handlers_retention.go b/weed/s3api/s3api_object_handlers_retention.go new file mode 100644 index 000000000..e92e821c8 --- /dev/null +++ b/weed/s3api/s3api_object_handlers_retention.go @@ -0,0 +1,356 @@ +package s3api + +import ( + "encoding/xml" + "errors" + "net/http" + + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" + stats_collect "github.com/seaweedfs/seaweedfs/weed/stats" +) + +// PutObjectRetentionHandler Put object Retention +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectRetention.html +func (s3a *S3ApiServer) PutObjectRetentionHandler(w http.ResponseWriter, r *http.Request) { + bucket, object := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("PutObjectRetentionHandler %s %s", bucket, object) + + // Check if Object Lock is available for this bucket (requires versioning) + if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "PutObjectRetentionHandler") { + return + } + + // Get version ID from query parameters + versionId := r.URL.Query().Get("versionId") + + // Check for bypass governance retention header + bypassGovernance := r.Header.Get("x-amz-bypass-governance-retention") == "true" + + // Parse retention configuration from request body + retention, err := parseObjectRetention(r) + if err != nil { + glog.Errorf("PutObjectRetentionHandler: failed to parse retention config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML) + return + } + + // Validate retention configuration + if err := validateRetention(retention); err != nil { + glog.Errorf("PutObjectRetentionHandler: invalid retention config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + return + } + + // Set retention on the object + if err := s3a.setObjectRetention(bucket, object, versionId, retention, bypassGovernance); err != nil { + glog.Errorf("PutObjectRetentionHandler: failed to set retention: %v", err) + + // Handle specific error cases + if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) || errors.Is(err, ErrLatestVersionNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + + if errors.Is(err, ErrComplianceModeActive) || errors.Is(err, ErrGovernanceModeActive) { + s3err.WriteErrorResponse(w, r, s3err.ErrAccessDenied) + return + } + + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + // Return success (HTTP 200 with no body) + w.WriteHeader(http.StatusOK) + glog.V(3).Infof("PutObjectRetentionHandler: successfully set retention for %s/%s", bucket, object) +} + +// GetObjectRetentionHandler Get object Retention +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectRetention.html +func (s3a *S3ApiServer) GetObjectRetentionHandler(w http.ResponseWriter, r *http.Request) { + bucket, object := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("GetObjectRetentionHandler %s %s", bucket, object) + + // Check if Object Lock is available for this bucket (requires versioning) + if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "GetObjectRetentionHandler") { + return + } + + // Get version ID from query parameters + versionId := r.URL.Query().Get("versionId") + + // Get retention configuration for the object + retention, err := s3a.getObjectRetention(bucket, object, versionId) + if err != nil { + glog.Errorf("GetObjectRetentionHandler: failed to get retention: %v", err) + + // Handle specific error cases + if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + + if errors.Is(err, ErrNoRetentionConfiguration) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLockConfiguration) + return + } + + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Marshal retention configuration to XML + retentionXML, err := xml.Marshal(retention) + if err != nil { + glog.Errorf("GetObjectRetentionHandler: failed to marshal retention: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Set response headers + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + + // Write XML response + if _, err := w.Write([]byte(xml.Header)); err != nil { + glog.Errorf("GetObjectRetentionHandler: failed to write XML header: %v", err) + return + } + + if _, err := w.Write(retentionXML); err != nil { + glog.Errorf("GetObjectRetentionHandler: failed to write retention XML: %v", err) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + glog.V(3).Infof("GetObjectRetentionHandler: successfully retrieved retention for %s/%s", bucket, object) +} + +// PutObjectLegalHoldHandler Put object Legal Hold +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLegalHold.html +func (s3a *S3ApiServer) PutObjectLegalHoldHandler(w http.ResponseWriter, r *http.Request) { + bucket, object := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("PutObjectLegalHoldHandler %s %s", bucket, object) + + // Check if Object Lock is available for this bucket (requires versioning) + if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "PutObjectLegalHoldHandler") { + return + } + + // Get version ID from query parameters + versionId := r.URL.Query().Get("versionId") + + // Parse legal hold configuration from request body + legalHold, err := parseObjectLegalHold(r) + if err != nil { + glog.Errorf("PutObjectLegalHoldHandler: failed to parse legal hold config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML) + return + } + + // Validate legal hold configuration + if err := validateLegalHold(legalHold); err != nil { + glog.Errorf("PutObjectLegalHoldHandler: invalid legal hold config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + return + } + + // Set legal hold on the object + if err := s3a.setObjectLegalHold(bucket, object, versionId, legalHold); err != nil { + glog.Errorf("PutObjectLegalHoldHandler: failed to set legal hold: %v", err) + + // Handle specific error cases + if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + // Return success (HTTP 200 with no body) + w.WriteHeader(http.StatusOK) + glog.V(3).Infof("PutObjectLegalHoldHandler: successfully set legal hold for %s/%s", bucket, object) +} + +// GetObjectLegalHoldHandler Get object Legal Hold +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectLegalHold.html +func (s3a *S3ApiServer) GetObjectLegalHoldHandler(w http.ResponseWriter, r *http.Request) { + bucket, object := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("GetObjectLegalHoldHandler %s %s", bucket, object) + + // Check if Object Lock is available for this bucket (requires versioning) + if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "GetObjectLegalHoldHandler") { + return + } + + // Get version ID from query parameters + versionId := r.URL.Query().Get("versionId") + + // Get legal hold configuration for the object + legalHold, err := s3a.getObjectLegalHold(bucket, object, versionId) + if err != nil { + glog.Errorf("GetObjectLegalHoldHandler: failed to get legal hold: %v", err) + + // Handle specific error cases + if errors.Is(err, ErrObjectNotFound) || errors.Is(err, ErrVersionNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + + if errors.Is(err, ErrNoLegalHoldConfiguration) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLegalHold) + return + } + + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Marshal legal hold configuration to XML + legalHoldXML, err := xml.Marshal(legalHold) + if err != nil { + glog.Errorf("GetObjectLegalHoldHandler: failed to marshal legal hold: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + // Set response headers + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + + // Write XML response + if _, err := w.Write([]byte(xml.Header)); err != nil { + glog.Errorf("GetObjectLegalHoldHandler: failed to write XML header: %v", err) + return + } + + if _, err := w.Write(legalHoldXML); err != nil { + glog.Errorf("GetObjectLegalHoldHandler: failed to write legal hold XML: %v", err) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + glog.V(3).Infof("GetObjectLegalHoldHandler: successfully retrieved legal hold for %s/%s", bucket, object) +} + +// PutObjectLockConfigurationHandler Put object Lock configuration +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLockConfiguration.html +func (s3a *S3ApiServer) PutObjectLockConfigurationHandler(w http.ResponseWriter, r *http.Request) { + bucket, _ := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("PutObjectLockConfigurationHandler %s", bucket) + + // Check if Object Lock is available for this bucket (requires versioning) + if !s3a.handleObjectLockAvailabilityCheck(w, r, bucket, "PutObjectLockConfigurationHandler") { + return + } + + // Parse object lock configuration from request body + config, err := parseObjectLockConfiguration(r) + if err != nil { + glog.Errorf("PutObjectLockConfigurationHandler: failed to parse object lock config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrMalformedXML) + return + } + + // Validate object lock configuration + if err := validateObjectLockConfiguration(config); err != nil { + glog.Errorf("PutObjectLockConfigurationHandler: invalid object lock config: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + return + } + + // Set object lock configuration on the bucket + errCode := s3a.updateBucketConfig(bucket, func(bucketConfig *BucketConfig) error { + if bucketConfig.Entry.Extended == nil { + bucketConfig.Entry.Extended = make(map[string][]byte) + } + + // Store the configuration as JSON in extended attributes + configXML, err := xml.Marshal(config) + if err != nil { + return err + } + + bucketConfig.Entry.Extended[s3_constants.ExtObjectLockConfigKey] = configXML + + if config.ObjectLockEnabled != "" { + bucketConfig.Entry.Extended[s3_constants.ExtObjectLockEnabledKey] = []byte(config.ObjectLockEnabled) + } + + return nil + }) + + if errCode != s3err.ErrNone { + glog.Errorf("PutObjectLockConfigurationHandler: failed to set object lock config: %v", errCode) + s3err.WriteErrorResponse(w, r, errCode) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + // Return success (HTTP 200 with no body) + w.WriteHeader(http.StatusOK) + glog.V(3).Infof("PutObjectLockConfigurationHandler: successfully set object lock config for %s", bucket) +} + +// GetObjectLockConfigurationHandler Get object Lock configuration +// https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectLockConfiguration.html +func (s3a *S3ApiServer) GetObjectLockConfigurationHandler(w http.ResponseWriter, r *http.Request) { + bucket, _ := s3_constants.GetBucketAndObject(r) + glog.V(3).Infof("GetObjectLockConfigurationHandler %s", bucket) + + // Get bucket configuration + bucketConfig, errCode := s3a.getBucketConfig(bucket) + if errCode != s3err.ErrNone { + glog.Errorf("GetObjectLockConfigurationHandler: failed to get bucket config: %v", errCode) + s3err.WriteErrorResponse(w, r, errCode) + return + } + + // Check if object lock configuration exists + if bucketConfig.Entry.Extended == nil { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLockConfiguration) + return + } + + configXML, exists := bucketConfig.Entry.Extended[s3_constants.ExtObjectLockConfigKey] + if !exists { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchObjectLockConfiguration) + return + } + + // Set response headers + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + + // Write XML response + if _, err := w.Write([]byte(xml.Header)); err != nil { + glog.Errorf("GetObjectLockConfigurationHandler: failed to write XML header: %v", err) + return + } + + if _, err := w.Write(configXML); err != nil { + glog.Errorf("GetObjectLockConfigurationHandler: failed to write config XML: %v", err) + return + } + + // Record metrics + stats_collect.RecordBucketActiveTime(bucket) + + glog.V(3).Infof("GetObjectLockConfigurationHandler: successfully retrieved object lock config for %s", bucket) +} diff --git a/weed/s3api/s3api_object_handlers_skip.go b/weed/s3api/s3api_object_handlers_skip.go index 160d02475..0b74a0ec7 100644 --- a/weed/s3api/s3api_object_handlers_skip.go +++ b/weed/s3api/s3api_object_handlers_skip.go @@ -4,7 +4,7 @@ import ( "net/http" ) -// GetObjectAclHandler Put object ACL +// GetObjectAclHandler Get object ACL // https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObjectAcl.html func (s3a *S3ApiServer) GetObjectAclHandler(w http.ResponseWriter, r *http.Request) { @@ -19,27 +19,3 @@ func (s3a *S3ApiServer) PutObjectAclHandler(w http.ResponseWriter, r *http.Reque w.WriteHeader(http.StatusNoContent) } - -// PutObjectRetentionHandler Put object Retention -// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectRetention.html -func (s3a *S3ApiServer) PutObjectRetentionHandler(w http.ResponseWriter, r *http.Request) { - - w.WriteHeader(http.StatusNoContent) - -} - -// PutObjectLegalHoldHandler Put object Legal Hold -// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLegalHold.html -func (s3a *S3ApiServer) PutObjectLegalHoldHandler(w http.ResponseWriter, r *http.Request) { - - w.WriteHeader(http.StatusNoContent) - -} - -// PutObjectLockConfigurationHandler Put object Lock configuration -// https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLockConfiguration.html -func (s3a *S3ApiServer) PutObjectLockConfigurationHandler(w http.ResponseWriter, r *http.Request) { - - w.WriteHeader(http.StatusNoContent) - -} diff --git a/weed/s3api/s3api_object_retention.go b/weed/s3api/s3api_object_retention.go new file mode 100644 index 000000000..bedf693ef --- /dev/null +++ b/weed/s3api/s3api_object_retention.go @@ -0,0 +1,598 @@ +package s3api + +import ( + "encoding/xml" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/seaweedfs/seaweedfs/weed/glog" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" +) + +// Sentinel errors for proper error handling instead of string matching +var ( + ErrNoRetentionConfiguration = errors.New("no retention configuration found") + ErrNoLegalHoldConfiguration = errors.New("no legal hold configuration found") + ErrBucketNotFound = errors.New("bucket not found") + ErrObjectNotFound = errors.New("object not found") + ErrVersionNotFound = errors.New("version not found") + ErrLatestVersionNotFound = errors.New("latest version not found") + ErrComplianceModeActive = errors.New("object is under COMPLIANCE mode retention and cannot be deleted or modified") + ErrGovernanceModeActive = errors.New("object is under GOVERNANCE mode retention and cannot be deleted or modified without bypass") +) + +const ( + // Maximum retention period limits according to AWS S3 specifications + MaxRetentionDays = 36500 // Maximum number of days for object retention (100 years) + MaxRetentionYears = 100 // Maximum number of years for object retention +) + +// ObjectRetention represents S3 Object Retention configuration +type ObjectRetention struct { + XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ Retention"` + Mode string `xml:"Mode,omitempty"` + RetainUntilDate *time.Time `xml:"RetainUntilDate,omitempty"` +} + +// ObjectLegalHold represents S3 Object Legal Hold configuration +type ObjectLegalHold struct { + XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ LegalHold"` + Status string `xml:"Status,omitempty"` +} + +// ObjectLockConfiguration represents S3 Object Lock Configuration +type ObjectLockConfiguration struct { + XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ ObjectLockConfiguration"` + ObjectLockEnabled string `xml:"ObjectLockEnabled,omitempty"` + Rule *ObjectLockRule `xml:"Rule,omitempty"` +} + +// ObjectLockRule represents an Object Lock Rule +type ObjectLockRule struct { + XMLName xml.Name `xml:"Rule"` + DefaultRetention *DefaultRetention `xml:"DefaultRetention,omitempty"` +} + +// DefaultRetention represents default retention settings +type DefaultRetention struct { + XMLName xml.Name `xml:"DefaultRetention"` + Mode string `xml:"Mode,omitempty"` + Days int `xml:"Days,omitempty"` + Years int `xml:"Years,omitempty"` +} + +// Custom time unmarshalling for AWS S3 ISO8601 format +func (or *ObjectRetention) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + type Alias ObjectRetention + aux := &struct { + *Alias + RetainUntilDate *string `xml:"RetainUntilDate,omitempty"` + }{ + Alias: (*Alias)(or), + } + + if err := d.DecodeElement(aux, &start); err != nil { + return err + } + + if aux.RetainUntilDate != nil { + t, err := time.Parse(time.RFC3339, *aux.RetainUntilDate) + if err != nil { + return err + } + or.RetainUntilDate = &t + } + + return nil +} + +// parseXML is a generic helper function to parse XML from an HTTP request body. +// It uses xml.Decoder for streaming XML parsing, which is more memory-efficient +// and avoids loading the entire request body into memory. +// +// The function assumes: +// - The request body is not nil (returns error if it is) +// - The request body will be closed after parsing (deferred close) +// - The XML content matches the structure of the provided result type T +// +// This approach is optimized for small XML payloads typical in S3 API requests +// (retention configurations, legal hold settings, etc.) where the overhead of +// streaming parsing is acceptable for the memory efficiency benefits. +func parseXML[T any](r *http.Request, result *T) error { + if r.Body == nil { + return fmt.Errorf("error parsing XML: empty request body") + } + defer r.Body.Close() + + decoder := xml.NewDecoder(r.Body) + if err := decoder.Decode(result); err != nil { + return fmt.Errorf("error parsing XML: %v", err) + } + + return nil +} + +// parseObjectRetention parses XML retention configuration from request body +func parseObjectRetention(r *http.Request) (*ObjectRetention, error) { + var retention ObjectRetention + if err := parseXML(r, &retention); err != nil { + return nil, err + } + return &retention, nil +} + +// parseObjectLegalHold parses XML legal hold configuration from request body +func parseObjectLegalHold(r *http.Request) (*ObjectLegalHold, error) { + var legalHold ObjectLegalHold + if err := parseXML(r, &legalHold); err != nil { + return nil, err + } + return &legalHold, nil +} + +// parseObjectLockConfiguration parses XML object lock configuration from request body +func parseObjectLockConfiguration(r *http.Request) (*ObjectLockConfiguration, error) { + var config ObjectLockConfiguration + if err := parseXML(r, &config); err != nil { + return nil, err + } + return &config, nil +} + +// validateRetention validates retention configuration +func validateRetention(retention *ObjectRetention) error { + // AWS requires both Mode and RetainUntilDate for PutObjectRetention + if retention.Mode == "" { + return fmt.Errorf("retention configuration must specify Mode") + } + + if retention.RetainUntilDate == nil { + return fmt.Errorf("retention configuration must specify RetainUntilDate") + } + + if retention.Mode != s3_constants.RetentionModeGovernance && retention.Mode != s3_constants.RetentionModeCompliance { + return fmt.Errorf("invalid retention mode: %s", retention.Mode) + } + + if retention.RetainUntilDate.Before(time.Now()) { + return fmt.Errorf("retain until date must be in the future") + } + + return nil +} + +// validateLegalHold validates legal hold configuration +func validateLegalHold(legalHold *ObjectLegalHold) error { + if legalHold.Status != s3_constants.LegalHoldOn && legalHold.Status != s3_constants.LegalHoldOff { + return fmt.Errorf("invalid legal hold status: %s", legalHold.Status) + } + + return nil +} + +// validateObjectLockConfiguration validates object lock configuration +func validateObjectLockConfiguration(config *ObjectLockConfiguration) error { + // ObjectLockEnabled is required for bucket-level configuration + if config.ObjectLockEnabled == "" { + return fmt.Errorf("object lock configuration must specify ObjectLockEnabled") + } + + // Validate ObjectLockEnabled value + if config.ObjectLockEnabled != s3_constants.ObjectLockEnabled { + return fmt.Errorf("invalid object lock enabled value: %s", config.ObjectLockEnabled) + } + + // Validate Rule if present + if config.Rule != nil { + if config.Rule.DefaultRetention == nil { + return fmt.Errorf("rule configuration must specify DefaultRetention") + } + return validateDefaultRetention(config.Rule.DefaultRetention) + } + + return nil +} + +// validateDefaultRetention validates default retention configuration +func validateDefaultRetention(retention *DefaultRetention) error { + // Mode is required + if retention.Mode == "" { + return fmt.Errorf("default retention must specify Mode") + } + + // Mode must be valid + if retention.Mode != s3_constants.RetentionModeGovernance && retention.Mode != s3_constants.RetentionModeCompliance { + return fmt.Errorf("invalid default retention mode: %s", retention.Mode) + } + + // Exactly one of Days or Years must be specified + if retention.Days == 0 && retention.Years == 0 { + return fmt.Errorf("default retention must specify either Days or Years") + } + + if retention.Days > 0 && retention.Years > 0 { + return fmt.Errorf("default retention cannot specify both Days and Years") + } + + // Validate ranges + if retention.Days < 0 || retention.Days > MaxRetentionDays { + return fmt.Errorf("default retention days must be between 0 and %d", MaxRetentionDays) + } + + if retention.Years < 0 || retention.Years > MaxRetentionYears { + return fmt.Errorf("default retention years must be between 0 and %d", MaxRetentionYears) + } + + return nil +} + +// getObjectEntry retrieves the appropriate object entry based on versioning and versionId +func (s3a *S3ApiServer) getObjectEntry(bucket, object, versionId string) (*filer_pb.Entry, error) { + var entry *filer_pb.Entry + var err error + + if versionId != "" { + entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) + } else { + // Check if versioning is enabled + versioningEnabled, vErr := s3a.isVersioningEnabled(bucket) + if vErr != nil { + return nil, fmt.Errorf("error checking versioning: %v", vErr) + } + + if versioningEnabled { + entry, err = s3a.getLatestObjectVersion(bucket, object) + } else { + bucketDir := s3a.option.BucketsPath + "/" + bucket + entry, err = s3a.getEntry(bucketDir, object) + } + } + + if err != nil { + return nil, fmt.Errorf("failed to retrieve object %s/%s: %w", bucket, object, ErrObjectNotFound) + } + + return entry, nil +} + +// getObjectRetention retrieves retention configuration from object metadata +func (s3a *S3ApiServer) getObjectRetention(bucket, object, versionId string) (*ObjectRetention, error) { + entry, err := s3a.getObjectEntry(bucket, object, versionId) + if err != nil { + return nil, err + } + + if entry.Extended == nil { + return nil, ErrNoRetentionConfiguration + } + + retention := &ObjectRetention{} + + if modeBytes, exists := entry.Extended[s3_constants.ExtObjectLockModeKey]; exists { + retention.Mode = string(modeBytes) + } + + if dateBytes, exists := entry.Extended[s3_constants.ExtRetentionUntilDateKey]; exists { + if timestamp, err := strconv.ParseInt(string(dateBytes), 10, 64); err == nil { + t := time.Unix(timestamp, 0) + retention.RetainUntilDate = &t + } else { + return nil, fmt.Errorf("failed to parse retention timestamp for %s/%s: corrupted timestamp data", bucket, object) + } + } + + if retention.Mode == "" || retention.RetainUntilDate == nil { + return nil, ErrNoRetentionConfiguration + } + + return retention, nil +} + +// setObjectRetention sets retention configuration on object metadata +func (s3a *S3ApiServer) setObjectRetention(bucket, object, versionId string, retention *ObjectRetention, bypassGovernance bool) error { + var entry *filer_pb.Entry + var err error + var entryPath string + + if versionId != "" { + entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) + if err != nil { + return fmt.Errorf("failed to get version %s for object %s/%s: %w", versionId, bucket, object, ErrVersionNotFound) + } + entryPath = object + ".versions/" + s3a.getVersionFileName(versionId) + } else { + // Check if versioning is enabled + versioningEnabled, vErr := s3a.isVersioningEnabled(bucket) + if vErr != nil { + return fmt.Errorf("error checking versioning: %v", vErr) + } + + if versioningEnabled { + entry, err = s3a.getLatestObjectVersion(bucket, object) + if err != nil { + return fmt.Errorf("failed to get latest version for object %s/%s: %w", bucket, object, ErrLatestVersionNotFound) + } + // Extract version ID from entry metadata + if entry.Extended != nil { + if versionIdBytes, exists := entry.Extended[s3_constants.ExtVersionIdKey]; exists { + versionId = string(versionIdBytes) + entryPath = object + ".versions/" + s3a.getVersionFileName(versionId) + } + } + } else { + bucketDir := s3a.option.BucketsPath + "/" + bucket + entry, err = s3a.getEntry(bucketDir, object) + if err != nil { + return fmt.Errorf("failed to get object %s/%s: %w", bucket, object, ErrObjectNotFound) + } + entryPath = object + } + } + + // Check if object is already under retention + if entry.Extended != nil { + if existingMode, exists := entry.Extended[s3_constants.ExtObjectLockModeKey]; exists { + if string(existingMode) == s3_constants.RetentionModeCompliance && !bypassGovernance { + return fmt.Errorf("cannot modify retention on object under COMPLIANCE mode") + } + + if existingDateBytes, dateExists := entry.Extended[s3_constants.ExtRetentionUntilDateKey]; dateExists { + if timestamp, err := strconv.ParseInt(string(existingDateBytes), 10, 64); err == nil { + existingDate := time.Unix(timestamp, 0) + if existingDate.After(time.Now()) && string(existingMode) == s3_constants.RetentionModeGovernance && !bypassGovernance { + return fmt.Errorf("cannot modify retention on object under GOVERNANCE mode without bypass") + } + } + } + } + } + + // Update retention metadata + if entry.Extended == nil { + entry.Extended = make(map[string][]byte) + } + + if retention.Mode != "" { + entry.Extended[s3_constants.ExtObjectLockModeKey] = []byte(retention.Mode) + } + + if retention.RetainUntilDate != nil { + entry.Extended[s3_constants.ExtRetentionUntilDateKey] = []byte(strconv.FormatInt(retention.RetainUntilDate.Unix(), 10)) + + // Also update the existing WORM fields for compatibility + entry.WormEnforcedAtTsNs = time.Now().UnixNano() + } + + // Update the entry + // NOTE: Potential race condition exists if concurrent calls to PutObjectRetention + // and PutObjectLegalHold update the same object simultaneously, as they might + // overwrite each other's Extended map changes. This is mitigated by the fact + // that mkFile operations are typically serialized at the filer level, but + // future implementations might consider using atomic update operations or + // entry-level locking for complete safety. + bucketDir := s3a.option.BucketsPath + "/" + bucket + return s3a.mkFile(bucketDir, entryPath, entry.Chunks, func(updatedEntry *filer_pb.Entry) { + updatedEntry.Extended = entry.Extended + updatedEntry.WormEnforcedAtTsNs = entry.WormEnforcedAtTsNs + }) +} + +// getObjectLegalHold retrieves legal hold configuration from object metadata +func (s3a *S3ApiServer) getObjectLegalHold(bucket, object, versionId string) (*ObjectLegalHold, error) { + entry, err := s3a.getObjectEntry(bucket, object, versionId) + if err != nil { + return nil, err + } + + if entry.Extended == nil { + return nil, ErrNoLegalHoldConfiguration + } + + legalHold := &ObjectLegalHold{} + + if statusBytes, exists := entry.Extended[s3_constants.ExtLegalHoldKey]; exists { + legalHold.Status = string(statusBytes) + } else { + return nil, ErrNoLegalHoldConfiguration + } + + return legalHold, nil +} + +// setObjectLegalHold sets legal hold configuration on object metadata +func (s3a *S3ApiServer) setObjectLegalHold(bucket, object, versionId string, legalHold *ObjectLegalHold) error { + var entry *filer_pb.Entry + var err error + var entryPath string + + if versionId != "" { + entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) + if err != nil { + return fmt.Errorf("failed to get version %s for object %s/%s: %w", versionId, bucket, object, ErrVersionNotFound) + } + entryPath = object + ".versions/" + s3a.getVersionFileName(versionId) + } else { + // Check if versioning is enabled + versioningEnabled, vErr := s3a.isVersioningEnabled(bucket) + if vErr != nil { + return fmt.Errorf("error checking versioning: %v", vErr) + } + + if versioningEnabled { + entry, err = s3a.getLatestObjectVersion(bucket, object) + if err != nil { + return fmt.Errorf("failed to get latest version for object %s/%s: %w", bucket, object, ErrLatestVersionNotFound) + } + // Extract version ID from entry metadata + if entry.Extended != nil { + if versionIdBytes, exists := entry.Extended[s3_constants.ExtVersionIdKey]; exists { + versionId = string(versionIdBytes) + entryPath = object + ".versions/" + s3a.getVersionFileName(versionId) + } + } + } else { + bucketDir := s3a.option.BucketsPath + "/" + bucket + entry, err = s3a.getEntry(bucketDir, object) + if err != nil { + return fmt.Errorf("failed to get object %s/%s: %w", bucket, object, ErrObjectNotFound) + } + entryPath = object + } + } + + // Update legal hold metadata + if entry.Extended == nil { + entry.Extended = make(map[string][]byte) + } + + entry.Extended[s3_constants.ExtLegalHoldKey] = []byte(legalHold.Status) + + // Update the entry + // NOTE: Potential race condition exists if concurrent calls to PutObjectRetention + // and PutObjectLegalHold update the same object simultaneously, as they might + // overwrite each other's Extended map changes. This is mitigated by the fact + // that mkFile operations are typically serialized at the filer level, but + // future implementations might consider using atomic update operations or + // entry-level locking for complete safety. + bucketDir := s3a.option.BucketsPath + "/" + bucket + return s3a.mkFile(bucketDir, entryPath, entry.Chunks, func(updatedEntry *filer_pb.Entry) { + updatedEntry.Extended = entry.Extended + }) +} + +// isObjectRetentionActive checks if an object is currently under retention +func (s3a *S3ApiServer) isObjectRetentionActive(bucket, object, versionId string) (bool, error) { + retention, err := s3a.getObjectRetention(bucket, object, versionId) + if err != nil { + // If no retention found, object is not under retention + if errors.Is(err, ErrNoRetentionConfiguration) { + return false, nil + } + return false, err + } + + if retention.RetainUntilDate != nil && retention.RetainUntilDate.After(time.Now()) { + return true, nil + } + + return false, nil +} + +// getObjectRetentionWithStatus retrieves retention configuration and returns both the data and active status +// This is an optimization to avoid duplicate fetches when both retention data and status are needed +func (s3a *S3ApiServer) getObjectRetentionWithStatus(bucket, object, versionId string) (*ObjectRetention, bool, error) { + retention, err := s3a.getObjectRetention(bucket, object, versionId) + if err != nil { + // If no retention found, object is not under retention + if errors.Is(err, ErrNoRetentionConfiguration) { + return nil, false, nil + } + return nil, false, err + } + + // Check if retention is currently active + isActive := retention.RetainUntilDate != nil && retention.RetainUntilDate.After(time.Now()) + return retention, isActive, nil +} + +// isObjectLegalHoldActive checks if an object is currently under legal hold +func (s3a *S3ApiServer) isObjectLegalHoldActive(bucket, object, versionId string) (bool, error) { + legalHold, err := s3a.getObjectLegalHold(bucket, object, versionId) + if err != nil { + // If no legal hold found, object is not under legal hold + if errors.Is(err, ErrNoLegalHoldConfiguration) { + return false, nil + } + return false, err + } + + return legalHold.Status == s3_constants.LegalHoldOn, nil +} + +// checkObjectLockPermissions checks if an object can be deleted or modified +func (s3a *S3ApiServer) checkObjectLockPermissions(bucket, object, versionId string, bypassGovernance bool) error { + // Get retention configuration and status in a single call to avoid duplicate fetches + retention, retentionActive, err := s3a.getObjectRetentionWithStatus(bucket, object, versionId) + if err != nil { + glog.Warningf("Error checking retention for %s/%s: %v", bucket, object, err) + } + + // Check if object is under legal hold + legalHoldActive, err := s3a.isObjectLegalHoldActive(bucket, object, versionId) + if err != nil { + glog.Warningf("Error checking legal hold for %s/%s: %v", bucket, object, err) + } + + // If object is under legal hold, it cannot be deleted or modified + if legalHoldActive { + return fmt.Errorf("object is under legal hold and cannot be deleted or modified") + } + + // If object is under retention, check the mode + if retentionActive && retention != nil { + if retention.Mode == s3_constants.RetentionModeCompliance { + return ErrComplianceModeActive + } + + if retention.Mode == s3_constants.RetentionModeGovernance && !bypassGovernance { + return ErrGovernanceModeActive + } + } + + return nil +} + +// isObjectLockAvailable checks if Object Lock features are available for the bucket +// Object Lock requires versioning to be enabled (AWS S3 requirement) +func (s3a *S3ApiServer) isObjectLockAvailable(bucket string) error { + versioningEnabled, err := s3a.isVersioningEnabled(bucket) + if err != nil { + if errors.Is(err, filer_pb.ErrNotFound) { + return ErrBucketNotFound + } + return fmt.Errorf("error checking versioning status: %v", err) + } + + if !versioningEnabled { + return fmt.Errorf("object lock requires versioning to be enabled") + } + + return nil +} + +// checkObjectLockPermissionsForPut checks object lock permissions for PUT operations +// This is a shared helper to avoid code duplication in PUT handlers +func (s3a *S3ApiServer) checkObjectLockPermissionsForPut(bucket, object string, bypassGovernance bool, versioningEnabled bool) error { + // Object Lock only applies to versioned buckets (AWS S3 requirement) + if !versioningEnabled { + return nil + } + + // For PUT operations, we check permissions on the current object (empty versionId) + if err := s3a.checkObjectLockPermissions(bucket, object, "", bypassGovernance); err != nil { + glog.V(2).Infof("checkObjectLockPermissionsForPut: object lock check failed for %s/%s: %v", bucket, object, err) + return err + } + return nil +} + +// handleObjectLockAvailabilityCheck is a helper function to check object lock availability +// and write the appropriate error response if not available. This reduces code duplication +// across all retention handlers. +func (s3a *S3ApiServer) handleObjectLockAvailabilityCheck(w http.ResponseWriter, r *http.Request, bucket, handlerName string) bool { + if err := s3a.isObjectLockAvailable(bucket); err != nil { + glog.Errorf("%s: object lock not available for bucket %s: %v", handlerName, bucket, err) + if errors.Is(err, ErrBucketNotFound) { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket) + } else { + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) + } + return false + } + return true +} diff --git a/weed/s3api/s3api_object_retention_test.go b/weed/s3api/s3api_object_retention_test.go new file mode 100644 index 000000000..0caa50b42 --- /dev/null +++ b/weed/s3api/s3api_object_retention_test.go @@ -0,0 +1,726 @@ +package s3api + +import ( + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" +) + +// TODO: If needed, re-implement TestPutObjectRetention with proper setup for buckets, objects, and versioning. + +func TestValidateRetention(t *testing.T) { + tests := []struct { + name string + retention *ObjectRetention + expectError bool + errorMsg string + }{ + { + name: "Valid GOVERNANCE retention", + retention: &ObjectRetention{ + Mode: s3_constants.RetentionModeGovernance, + RetainUntilDate: timePtr(time.Now().Add(24 * time.Hour)), + }, + expectError: false, + }, + { + name: "Valid COMPLIANCE retention", + retention: &ObjectRetention{ + Mode: s3_constants.RetentionModeCompliance, + RetainUntilDate: timePtr(time.Now().Add(24 * time.Hour)), + }, + expectError: false, + }, + { + name: "Missing Mode", + retention: &ObjectRetention{ + RetainUntilDate: timePtr(time.Now().Add(24 * time.Hour)), + }, + expectError: true, + errorMsg: "retention configuration must specify Mode", + }, + { + name: "Missing RetainUntilDate", + retention: &ObjectRetention{ + Mode: s3_constants.RetentionModeGovernance, + }, + expectError: true, + errorMsg: "retention configuration must specify RetainUntilDate", + }, + { + name: "Invalid Mode", + retention: &ObjectRetention{ + Mode: "INVALID_MODE", + RetainUntilDate: timePtr(time.Now().Add(24 * time.Hour)), + }, + expectError: true, + errorMsg: "invalid retention mode", + }, + { + name: "Past RetainUntilDate", + retention: &ObjectRetention{ + Mode: s3_constants.RetentionModeGovernance, + RetainUntilDate: timePtr(time.Now().Add(-24 * time.Hour)), + }, + expectError: true, + errorMsg: "retain until date must be in the future", + }, + { + name: "Empty retention", + retention: &ObjectRetention{}, + expectError: true, + errorMsg: "retention configuration must specify Mode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRetention(tt.retention) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +func TestValidateLegalHold(t *testing.T) { + tests := []struct { + name string + legalHold *ObjectLegalHold + expectError bool + errorMsg string + }{ + { + name: "Valid ON status", + legalHold: &ObjectLegalHold{ + Status: s3_constants.LegalHoldOn, + }, + expectError: false, + }, + { + name: "Valid OFF status", + legalHold: &ObjectLegalHold{ + Status: s3_constants.LegalHoldOff, + }, + expectError: false, + }, + { + name: "Invalid status", + legalHold: &ObjectLegalHold{ + Status: "INVALID_STATUS", + }, + expectError: true, + errorMsg: "invalid legal hold status", + }, + { + name: "Empty status", + legalHold: &ObjectLegalHold{ + Status: "", + }, + expectError: true, + errorMsg: "invalid legal hold status", + }, + { + name: "Lowercase on", + legalHold: &ObjectLegalHold{ + Status: "on", + }, + expectError: true, + errorMsg: "invalid legal hold status", + }, + { + name: "Lowercase off", + legalHold: &ObjectLegalHold{ + Status: "off", + }, + expectError: true, + errorMsg: "invalid legal hold status", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateLegalHold(tt.legalHold) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +func TestParseObjectRetention(t *testing.T) { + tests := []struct { + name string + xmlBody string + expectError bool + errorMsg string + expectedResult *ObjectRetention + }{ + { + name: "Valid retention XML", + xmlBody: ` + GOVERNANCE + 2024-12-31T23:59:59Z + `, + expectError: false, + expectedResult: &ObjectRetention{ + Mode: "GOVERNANCE", + RetainUntilDate: timePtr(time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)), + }, + }, + { + name: "Valid compliance retention XML", + xmlBody: ` + COMPLIANCE + 2025-01-01T00:00:00Z + `, + expectError: false, + expectedResult: &ObjectRetention{ + Mode: "COMPLIANCE", + RetainUntilDate: timePtr(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)), + }, + }, + { + name: "Empty XML body", + xmlBody: "", + expectError: true, + errorMsg: "error parsing XML", + }, + { + name: "Invalid XML", + xmlBody: `GOVERNANCEinvalid-date`, + expectError: true, + errorMsg: "cannot parse", + }, + { + name: "Malformed XML", + xmlBody: "GOVERNANCE2024-12-31T23:59:59Z", + expectError: true, + errorMsg: "error parsing XML", + }, + { + name: "Missing Mode", + xmlBody: ` + 2024-12-31T23:59:59Z + `, + expectError: false, + expectedResult: &ObjectRetention{ + Mode: "", + RetainUntilDate: timePtr(time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)), + }, + }, + { + name: "Missing RetainUntilDate", + xmlBody: ` + GOVERNANCE + `, + expectError: false, + expectedResult: &ObjectRetention{ + Mode: "GOVERNANCE", + RetainUntilDate: nil, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock HTTP request with XML body + req := &http.Request{ + Body: io.NopCloser(strings.NewReader(tt.xmlBody)), + } + + result, err := parseObjectRetention(req) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result == nil { + t.Errorf("Expected result but got nil") + } else { + if result.Mode != tt.expectedResult.Mode { + t.Errorf("Expected Mode %s, got %s", tt.expectedResult.Mode, result.Mode) + } + if tt.expectedResult.RetainUntilDate == nil { + if result.RetainUntilDate != nil { + t.Errorf("Expected RetainUntilDate to be nil, got %v", result.RetainUntilDate) + } + } else if result.RetainUntilDate == nil { + t.Errorf("Expected RetainUntilDate to be %v, got nil", tt.expectedResult.RetainUntilDate) + } else if !result.RetainUntilDate.Equal(*tt.expectedResult.RetainUntilDate) { + t.Errorf("Expected RetainUntilDate %v, got %v", tt.expectedResult.RetainUntilDate, result.RetainUntilDate) + } + } + } + }) + } +} + +func TestParseObjectLegalHold(t *testing.T) { + tests := []struct { + name string + xmlBody string + expectError bool + errorMsg string + expectedResult *ObjectLegalHold + }{ + { + name: "Valid legal hold ON", + xmlBody: ` + ON + `, + expectError: false, + expectedResult: &ObjectLegalHold{ + Status: "ON", + }, + }, + { + name: "Valid legal hold OFF", + xmlBody: ` + OFF + `, + expectError: false, + expectedResult: &ObjectLegalHold{ + Status: "OFF", + }, + }, + { + name: "Empty XML body", + xmlBody: "", + expectError: true, + errorMsg: "error parsing XML", + }, + { + name: "Invalid XML", + xmlBody: "ON", + expectError: true, + errorMsg: "error parsing XML", + }, + { + name: "Missing Status", + xmlBody: ` + `, + expectError: false, + expectedResult: &ObjectLegalHold{ + Status: "", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock HTTP request with XML body + req := &http.Request{ + Body: io.NopCloser(strings.NewReader(tt.xmlBody)), + } + + result, err := parseObjectLegalHold(req) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result == nil { + t.Errorf("Expected result but got nil") + } else { + if result.Status != tt.expectedResult.Status { + t.Errorf("Expected Status %s, got %s", tt.expectedResult.Status, result.Status) + } + } + } + }) + } +} + +func TestParseObjectLockConfiguration(t *testing.T) { + tests := []struct { + name string + xmlBody string + expectError bool + errorMsg string + expectedResult *ObjectLockConfiguration + }{ + { + name: "Valid object lock configuration", + xmlBody: ` + Enabled + `, + expectError: false, + expectedResult: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + }, + }, + { + name: "Valid object lock configuration with rule", + xmlBody: ` + Enabled + + + GOVERNANCE + 30 + + + `, + expectError: false, + expectedResult: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "GOVERNANCE", + Days: 30, + }, + }, + }, + }, + { + name: "Empty XML body", + xmlBody: "", + expectError: true, + errorMsg: "error parsing XML", + }, + { + name: "Invalid XML", + xmlBody: "Enabled", + expectError: true, + errorMsg: "error parsing XML", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock HTTP request with XML body + req := &http.Request{ + Body: io.NopCloser(strings.NewReader(tt.xmlBody)), + } + + result, err := parseObjectLockConfiguration(req) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if result == nil { + t.Errorf("Expected result but got nil") + } else { + if result.ObjectLockEnabled != tt.expectedResult.ObjectLockEnabled { + t.Errorf("Expected ObjectLockEnabled %s, got %s", tt.expectedResult.ObjectLockEnabled, result.ObjectLockEnabled) + } + if tt.expectedResult.Rule == nil { + if result.Rule != nil { + t.Errorf("Expected Rule to be nil, got %v", result.Rule) + } + } else if result.Rule == nil { + t.Errorf("Expected Rule to be non-nil") + } else { + if result.Rule.DefaultRetention == nil { + t.Errorf("Expected DefaultRetention to be non-nil") + } else { + if result.Rule.DefaultRetention.Mode != tt.expectedResult.Rule.DefaultRetention.Mode { + t.Errorf("Expected DefaultRetention Mode %s, got %s", tt.expectedResult.Rule.DefaultRetention.Mode, result.Rule.DefaultRetention.Mode) + } + if result.Rule.DefaultRetention.Days != tt.expectedResult.Rule.DefaultRetention.Days { + t.Errorf("Expected DefaultRetention Days %d, got %d", tt.expectedResult.Rule.DefaultRetention.Days, result.Rule.DefaultRetention.Days) + } + } + } + } + } + }) + } +} + +func TestValidateObjectLockConfiguration(t *testing.T) { + tests := []struct { + name string + config *ObjectLockConfiguration + expectError bool + errorMsg string + }{ + { + name: "Valid config with ObjectLockEnabled only", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + }, + expectError: false, + }, + { + name: "Missing ObjectLockEnabled", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "", + }, + expectError: true, + errorMsg: "object lock configuration must specify ObjectLockEnabled", + }, + { + name: "Valid config with rule and days", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "GOVERNANCE", + Days: 30, + }, + }, + }, + expectError: false, + }, + { + name: "Valid config with rule and years", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "COMPLIANCE", + Years: 1, + }, + }, + }, + expectError: false, + }, + { + name: "Invalid ObjectLockEnabled value", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "InvalidValue", + }, + expectError: true, + errorMsg: "invalid object lock enabled value", + }, + { + name: "Invalid rule - missing mode", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Days: 30, + }, + }, + }, + expectError: true, + errorMsg: "default retention must specify Mode", + }, + { + name: "Invalid rule - both days and years", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "GOVERNANCE", + Days: 30, + Years: 1, + }, + }, + }, + expectError: true, + errorMsg: "default retention cannot specify both Days and Years", + }, + { + name: "Invalid rule - neither days nor years", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "GOVERNANCE", + }, + }, + }, + expectError: true, + errorMsg: "default retention must specify either Days or Years", + }, + { + name: "Invalid rule - invalid mode", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "INVALID_MODE", + Days: 30, + }, + }, + }, + expectError: true, + errorMsg: "invalid default retention mode", + }, + { + name: "Invalid rule - days out of range", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "GOVERNANCE", + Days: 50000, + }, + }, + }, + expectError: true, + errorMsg: fmt.Sprintf("default retention days must be between 0 and %d", MaxRetentionDays), + }, + { + name: "Invalid rule - years out of range", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: &DefaultRetention{ + Mode: "GOVERNANCE", + Years: 200, + }, + }, + }, + expectError: true, + errorMsg: fmt.Sprintf("default retention years must be between 0 and %d", MaxRetentionYears), + }, + { + name: "Invalid rule - missing DefaultRetention", + config: &ObjectLockConfiguration{ + ObjectLockEnabled: "Enabled", + Rule: &ObjectLockRule{ + DefaultRetention: nil, + }, + }, + expectError: true, + errorMsg: "rule configuration must specify DefaultRetention", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateObjectLockConfiguration(tt.config) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +func TestValidateDefaultRetention(t *testing.T) { + tests := []struct { + name string + retention *DefaultRetention + expectError bool + errorMsg string + }{ + { + name: "Valid retention with days", + retention: &DefaultRetention{ + Mode: "GOVERNANCE", + Days: 30, + }, + expectError: false, + }, + { + name: "Valid retention with years", + retention: &DefaultRetention{ + Mode: "COMPLIANCE", + Years: 1, + }, + expectError: false, + }, + { + name: "Missing mode", + retention: &DefaultRetention{ + Days: 30, + }, + expectError: true, + errorMsg: "default retention must specify Mode", + }, + { + name: "Invalid mode", + retention: &DefaultRetention{ + Mode: "INVALID", + Days: 30, + }, + expectError: true, + errorMsg: "invalid default retention mode", + }, + { + name: "Both days and years specified", + retention: &DefaultRetention{ + Mode: "GOVERNANCE", + Days: 30, + Years: 1, + }, + expectError: true, + errorMsg: "default retention cannot specify both Days and Years", + }, + { + name: "Neither days nor years specified", + retention: &DefaultRetention{ + Mode: "GOVERNANCE", + }, + expectError: true, + errorMsg: "default retention must specify either Days or Years", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDefaultRetention(tt.retention) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error but got none") + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error message to contain '%s', got: %v", tt.errorMsg, err) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +// Helper function to create a time pointer +func timePtr(t time.Time) *time.Time { + return &t +} diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index 28eac9951..426535fe0 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -206,11 +206,13 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) { bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectRetentionHandler, ACTION_WRITE)), "PUT")).Queries("retention", "") // PutObjectLegalHold bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectLegalHoldHandler, ACTION_WRITE)), "PUT")).Queries("legal-hold", "") - // PutObjectLockConfiguration - bucket.Methods(http.MethodPut).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectLockConfigurationHandler, ACTION_WRITE)), "PUT")).Queries("object-lock", "") // GetObjectACL bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectAclHandler, ACTION_READ_ACP)), "GET")).Queries("acl", "") + // GetObjectRetention + bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectRetentionHandler, ACTION_READ)), "GET")).Queries("retention", "") + // GetObjectLegalHold + bucket.Methods(http.MethodGet).Path("/{object:.+}").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectLegalHoldHandler, ACTION_READ)), "GET")).Queries("legal-hold", "") // objects with query @@ -272,6 +274,10 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) { bucket.Methods(http.MethodGet).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetBucketVersioningHandler, ACTION_READ)), "GET")).Queries("versioning", "") bucket.Methods(http.MethodPut).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutBucketVersioningHandler, ACTION_WRITE)), "PUT")).Queries("versioning", "") + // GetObjectLockConfiguration / PutObjectLockConfiguration (bucket-level operations) + bucket.Methods(http.MethodGet).Path("/").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetObjectLockConfigurationHandler, ACTION_READ)), "GET")).Queries("object-lock", "") + bucket.Methods(http.MethodPut).Path("/").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutObjectLockConfigurationHandler, ACTION_WRITE)), "PUT")).Queries("object-lock", "") + // GetBucketTagging bucket.Methods(http.MethodGet).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.GetBucketTaggingHandler, ACTION_TAGGING)), "GET")).Queries("tagging", "") bucket.Methods(http.MethodPut).HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PutBucketTaggingHandler, ACTION_TAGGING)), "PUT")).Queries("tagging", "") diff --git a/weed/s3api/s3err/s3api_errors.go b/weed/s3api/s3err/s3api_errors.go index bcb0a26a8..17057f604 100644 --- a/weed/s3api/s3err/s3api_errors.go +++ b/weed/s3api/s3err/s3api_errors.go @@ -110,6 +110,8 @@ const ( OwnershipControlsNotFoundError ErrNoSuchTagSet + ErrNoSuchObjectLockConfiguration + ErrNoSuchObjectLegalHold ) // Error message constants for checksum validation @@ -197,6 +199,16 @@ var errorCodeResponse = map[ErrorCode]APIError{ Description: "The TagSet does not exist", HTTPStatusCode: http.StatusNotFound, }, + ErrNoSuchObjectLockConfiguration: { + Code: "NoSuchObjectLockConfiguration", + Description: "The specified object does not have an ObjectLock configuration", + HTTPStatusCode: http.StatusNotFound, + }, + ErrNoSuchObjectLegalHold: { + Code: "NoSuchObjectLegalHold", + Description: "The specified object does not have a legal hold configuration", + HTTPStatusCode: http.StatusNotFound, + }, ErrNoSuchCORSConfiguration: { Code: "NoSuchCORSConfiguration", Description: "The CORS configuration does not exist",