From 31cb28d9d3f4ace011083157002af59737a3ee37 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 21 Dec 2025 12:47:27 -0800 Subject: [PATCH 01/66] feat: auto-configure optimal volume size limit based on available disk space (#7833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: auto-configure optimal volume size limit based on available disk space - Add calculateOptimalVolumeSizeMB() function with OS-independent disk detection - Reuses existing stats.NewDiskStatus() which works across Linux, macOS, Windows, BSD, Solaris - Algorithm: available disk / 100, rounded up to nearest power of 2 (64MB, 128MB, 256MB, 512MB, 1024MB) - Volume size capped to maximum of 1GB (1024MB) for better stability - Minimum volume size is 64MB - Uses efficient bits.Len() for power-of-2 rounding instead of floating-point operations - Only auto-calculates volume size if user didn't specify a custom value via -master.volumeSizeLimitMB - Respects user-specified values without override - Master logs whether value was auto-calculated or user-specified - Welcome message displays the configured volume size with correct format string ordering - Removed unused autoVolumeSizeMB variable (logging handles source tracking) Fixes: #0 * Refactor: Consolidate volume size constants and use robust flag detection for mini mode This commit addresses all code review feedback on the auto-optimal volume size feature: 1. **Consolidate hardcoded defaults into package-level constants** - Moved minVolumeSizeMB=64 and maxVolumeSizeMB=1024 from local function-scope constants to package-level constants for consistency and maintainability - All three volume size constants (min, default, max) now defined in one place 2. **Implement robust flag detection using flag.Visit()** - Added isFlagPassed() helper function using flag.Visit() to check if a CLI flag was explicitly passed on the command line - Replaces the previous implementation that checked if current value equals default (which could incorrectly assume user intent if default was specified) - Now correctly detects user override regardless of the actual value 3. **Restructure power-of-2 rounding logic for clarity** - Changed from 'only round if above min threshold' to 'always round to power-of-2 first, then apply min/max constraints' - More robust: works correctly even if min/max constants are adjusted in future - Clearer intent: all non-zero values go through consistent rounding logic 4. **Fix import ordering** - Added 'flag' import (aliased to fla9 package) to support isFlagPassed() - Added 'math/bits' import to support power-of-2 rounding Benefits: - Better code organization with all volume size limits in package constants - Correct user override detection that doesn't rely on value equality checks - More maintainable rounding logic that's easier to understand and modify - Consistent with SeaweedFS conventions (uses fla9 package like other commands) * fix: Address code review feedback for volume size calculation This commit resolves three code review comments for better code quality and robustness: 1. **Handle comma-separated directories in -dir flag** - The -dir flag accepts comma-separated list of directories, but the volume size calculation was passing the entire string to util.ResolvePath() - Now splits on comma and uses the first directory for disk space calculation - Added explanatory comment about the multi-directory support - Ensures the optimal size calculation works correctly in all scenarios 2. **Change disk detection failure from verbose log to warning** - When disk status cannot be determined, the warning is now logged via glog.Warningf() instead of glog.V(1).Infof() - Makes the event visible in default logs without requiring verbose mode - Better alerting for operators about fallback to default values 3. **Avoid recalculating availableMB/100 and define bytesPerMB constant** - Added bytesPerMB = 1024*1024 constant for clarity and reusability - Replaced hardcoded (1024 * 1024) with bytesPerMB constant - Store availableMB/100 in initialOptimalMB variable to avoid recalculation - Log message now references initialOptimalMB instead of recalculating - Improves maintainability and reduces redundant computation All three changes maintain the same logic while improving code quality and robustness as requested by the reviewer. * fix: Address rounding logic, logging clarity, and disk capacity measurement issues This commit resolves three additional code review comments to improve robustness and clarity of the volume size calculation: 1. **Fix power-of-2 rounding logic for edge cases** - The previous condition 'if optimalMB > 0' created a bug: when optimalMB=1, bits.Len(0)=0, resulting in 1<<0=1, which is below minimum (64MB) - Changed to explicitly handle zero case first: 'if optimalMB == 0' - Separate zero-handling from power-of-2 rounding ensures correct behavior: * optimalMB=0 → set to minVolumeSizeMB (64) * optimalMB>=1 → apply power-of-2 rounding - Then apply min/max constraints unconditionally - More explicit and easier to reason about correctness 2. **Use total disk capacity instead of free space for stable configuration** - Changed from diskStatus.Free (available space) to diskStatus.All (total capacity) - Free space varies based on current disk usage at startup time - This caused inconsistent volume sizes: same disk could get different sizes depending on how full it is when the service starts - Using total capacity ensures predictable, stable configuration across restarts - Better aligns with the intended behavior of sizing based on disk capacity - Added explanatory comments about why total capacity is more appropriate 3. **Improve log message clarity and accuracy** - Updated message to clearly show: * 'total disk capacity' instead of vague 'available disk' * 'capacity/100 before rounding' to match actual calculation * 'clamped to [min,max]' instead of 'capped to max' to show both bounds * Includes min and max values in log for context - More accurate and helpful for operators troubleshooting volume sizing These changes ensure the volume size calculation is both correct and predictable. * feat: Save mini configuration to file for persistence and documentation This commit adds persistent configuration storage for the 'weed mini' command, saving all non-default parameters to a JSON configuration file for: 1. **Configuration Documentation** - All parameters actually passed on the command line are saved - Provides a clear record of the running configuration - Useful for auditing and understanding how the system is configured 2. **Persistence of Auto-Calculated Values** - The auto-calculated optimal volume size (master.volumeSizeLimitMB) is saved with a note indicating it was auto-calculated - On restart, if the auto-calculated value exists, it won't be recalculated - Users can delete the auto-calculated entry to force recalculation on next startup - Provides stable, predictable configuration across restarts 3. **Configuration File Location** - Saved to: /.seaweedfs/mini.config.json - Uses the first directory from comma-separated -dir list - Directory is created automatically if it doesn't exist - JSON format for easy parsing and manual editing 4. **Implementation Details** - Uses flag.Visit() to collect only explicitly passed flags - Distinguishes between user-specified and auto-calculated values - Includes helpful notes in the JSON file - Graceful handling of save errors (logs warnings, doesn't fail startup) The configuration file includes all parameters such as: - IP and port settings (master, filer, volume, admin) - Data directories and metadata folders - Replication and collection settings - S3 and IAM configurations - Performance tuning parameters (concurrency limits, timeouts, etc.) - Auto-calculated volume size (if applicable) Example mini.config.json output: { "debug": "true", "dir": "/data/seaweedfs", "master.port": "9333", "filer.port": "8888", "volume.port": "9340", "master.volumeSizeLimitMB.auto": "256", "_note_auto_calculated": "This value was auto-calculated. Remove it to recalculate on next startup." } This allows operators to: - Review what configuration was active - Replicate the configuration on other systems - Understand the startup behavior - Control when auto-calculation occurs * refactor: Change configuration file format to match command-line options format Update the saved configuration format from JSON to shell-compatible options format that matches how options are expected to be passed on the command line. Configuration file: .seaweedfs/mini.options Format: Each line contains a command-line option in the format -name=value Benefits: - Format is compatible with shell scripts and can be sourced - Can be easily converted to command-line options - Human-readable and editable - Values with spaces are properly quoted - Includes helpful comments explaining auto-calculated values - Directly usable with weed mini command The file can be used in multiple ways: 1. Extract options: cat .seaweedfs/mini.options | grep -v '^#' | tr '\n' ' ' 2. Inline in command: weed mini \$(cat .seaweedfs/mini.options | grep -v '^#') 3. Manual review: cat .seaweedfs/mini.options * refactor: Save mini.options directly to -dir folder * docs: Update PR description with accurate algorithm and examples Update the function documentation comments to accurately reflect the implemented algorithm and provide real-world examples with actual calculated outputs. Changes: - Clarify that algorithm uses total disk capacity (not free space) - Document exact calculation: capacity/100, round to power of 2, clamp to [64,1024] - Add realistic examples showing input disk sizes and resulting volume sizes: * 10GB disk → 64MB (minimum) * 100GB disk → 64MB (minimum) * 1TB disk → 64MB (minimum) * 6.4TB disk → 64MB * 12.8TB disk → 128MB * 100TB disk → 1024MB (maximum) * 1PB disk → 1024MB (maximum) - Include note that values are rounded to next power of 2 and capped at 1GB This helps users understand the volume size calculation and predict what size will be set for their specific disk configurations. * feat: integrate configuration file loading into mini startup - Load mini.options file at startup if it exists - Apply loaded configuration options before normal initialization - CLI flags override file-based configuration - Exclude 'dir' option from being saved (environment-specific) - Configuration file format: option=value without leading dashes - Auto-calculated volume size persists with recalculation marker --- weed/command/mini.go | 220 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 216 insertions(+), 4 deletions(-) diff --git a/weed/command/mini.go b/weed/command/mini.go index 264370069..b61da5f2e 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -3,6 +3,7 @@ package command import ( "context" "fmt" + "math/bits" "net" "net/http" "os" @@ -17,6 +18,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/security" stats_collect "github.com/seaweedfs/seaweedfs/weed/stats" "github.com/seaweedfs/seaweedfs/weed/util" + flag "github.com/seaweedfs/seaweedfs/weed/util/fla9" "github.com/seaweedfs/seaweedfs/weed/util/grace" "github.com/seaweedfs/seaweedfs/weed/worker" "github.com/seaweedfs/seaweedfs/weed/worker/types" @@ -36,8 +38,12 @@ type MiniOptions struct { } const ( - miniVolumeMaxDataVolumeCounts = "0" // auto-configured based on free disk space - miniVolumeMinFreeSpace = "1" // 1% minimum free space + bytesPerMB = 1024 * 1024 // Bytes per MB + miniVolumeMaxDataVolumeCounts = "0" // auto-configured based on free disk space + miniVolumeMinFreeSpace = "1" // 1% minimum free space + minVolumeSizeMB = 64 // Minimum volume size in MB + defaultMiniVolumeSizeMB = 128 // Default volume size for mini mode + maxVolumeSizeMB = 1024 // Maximum volume size in MB (1GB) ) var ( @@ -125,7 +131,7 @@ func initMiniMasterFlags() { miniMasterOptions.portGrpc = cmdMini.Flag.Int("master.port.grpc", 0, "master server grpc listen port") miniMasterOptions.metaFolder = cmdMini.Flag.String("master.dir", "", "data directory to store meta data, default to same as -dir specified") miniMasterOptions.peers = cmdMini.Flag.String("master.peers", "", "all master nodes in comma separated ip:masterPort list (default: none for single master)") - miniMasterOptions.volumeSizeLimitMB = cmdMini.Flag.Uint("master.volumeSizeLimitMB", 128, "Master stops directing writes to oversized volumes (default: 128MB for mini)") + miniMasterOptions.volumeSizeLimitMB = cmdMini.Flag.Uint("master.volumeSizeLimitMB", defaultMiniVolumeSizeMB, "Master stops directing writes to oversized volumes (default: 128MB for mini)") miniMasterOptions.volumePreallocate = cmdMini.Flag.Bool("master.volumePreallocate", false, "Preallocate disk space for volumes.") miniMasterOptions.maxParallelVacuumPerServer = cmdMini.Flag.Int("master.maxParallelVacuumPerServer", 1, "maximum number of volumes to vacuum in parallel on one volume server") miniMasterOptions.defaultReplication = cmdMini.Flag.String("master.defaultReplication", "", "Default replication type if not specified.") @@ -256,8 +262,196 @@ func init() { initMiniAdminFlags() } +// calculateOptimalVolumeSizeMB calculates optimal volume size based on total disk capacity. +// +// Algorithm: +// 1. Read total disk capacity using the OS-independent stats.NewDiskStatus() +// 2. Divide total disk capacity by 100 to estimate optimal volume size +// 3. Round up to nearest power of 2 (64MB, 128MB, 256MB, 512MB, 1024MB, etc.) +// 4. Clamp the result to range [64MB, 1024MB] +// +// Examples (values are rounded to next power of 2 and capped at 1GB): +// - 10GB disk → 10 / 100 = 0.1MB → rounds to 64MB (minimum) +// - 100GB disk → 100 / 100 = 1MB → rounds to 1MB, clamped to 64MB (minimum) +// - 500GB disk → 500 / 100 = 5MB → rounds to 8MB, clamped to 64MB (minimum) +// - 1TB disk → 1000 / 100 = 10MB → rounds to 16MB, clamped to 64MB (minimum) +// - 6.4TB disk → 6400 / 100 = 64MB → rounds to 64MB +// - 12.8TB disk → 12800 / 100 = 128MB → rounds to 128MB +// - 100TB disk → 100000 / 100 = 1000MB → rounds to 1024MB (maximum) +// - 1PB disk → 1000000 / 100 = 10000MB → capped at 1024MB (maximum) +func calculateOptimalVolumeSizeMB(dataFolder string) uint { + // Get disk status for the data folder using OS-independent function + diskStatus := stats_collect.NewDiskStatus(dataFolder) + if diskStatus == nil || diskStatus.All == 0 { + glog.Warningf("Could not determine disk size, using default %dMB", defaultMiniVolumeSizeMB) + return defaultMiniVolumeSizeMB + } + + // Calculate optimal size: total disk capacity / 100 for stability + // Using total capacity (All) instead of free space ensures consistent volume size + // regardless of current disk usage. diskStatus.All is in bytes, convert to MB + totalCapacityMB := diskStatus.All / bytesPerMB + initialOptimalMB := uint(totalCapacityMB / 100) + optimalMB := initialOptimalMB + + // Round up to nearest power of 2: 64MB, 128MB, 256MB, 512MB, etc. + // Minimum is 64MB, maximum is 1024MB (1GB) + if optimalMB == 0 { + // If the computed optimal size is 0, start from the minimum volume size + optimalMB = minVolumeSizeMB + } else { + // Round up to the nearest power of 2 + optimalMB = 1 << bits.Len(optimalMB-1) + } + + // Apply the minimum and maximum constraints + if optimalMB < minVolumeSizeMB { + optimalMB = minVolumeSizeMB + } else if optimalMB > maxVolumeSizeMB { + optimalMB = maxVolumeSizeMB + } + + glog.Infof("Optimal volume size: %dMB (total disk capacity: %dMB, capacity/100 before rounding: %dMB, rounded to nearest power of 2, clamped to [%d,%d]MB)", + optimalMB, totalCapacityMB, initialOptimalMB, minVolumeSizeMB, maxVolumeSizeMB) + + return optimalMB +} + +// isFlagPassed checks if a specific flag was passed on the command line +func isFlagPassed(name string) bool { + found := false + cmdMini.Flag.Visit(func(f *flag.Flag) { + if f.Name == name { + found = true + } + }) + return found +} + +// loadMiniConfigurationFile reads the mini.options file and returns parsed options +// File format: one option per line, without leading dash (e.g., "ip=127.0.0.1") +func loadMiniConfigurationFile(dataFolder string) (map[string]string, error) { + configFile := filepath.Join(util.ResolvePath(util.StringSplit(dataFolder, ",")[0]), "mini.options") + + options := make(map[string]string) + + // Check if file exists + data, err := os.ReadFile(configFile) + if err != nil { + if os.IsNotExist(err) { + // File doesn't exist - this is OK, return empty options + return options, nil + } + glog.Warningf("Failed to read configuration file %s: %v", configFile, err) + return options, err + } + + // Parse the file line by line + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + + // Skip empty lines and comments + if len(line) == 0 || strings.HasPrefix(line, "#") { + continue + } + + // Remove leading dash if present + if strings.HasPrefix(line, "-") { + line = line[1:] + } + + // Parse key=value + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + // Remove quotes if present + if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) || + (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) { + value = value[1 : len(value)-1] + } + options[key] = value + } + } + + glog.Infof("Loaded %d options from configuration file %s", len(options), configFile) + return options, nil +} + +// applyConfigFileOptions sets command-line flags from loaded configuration file +func applyConfigFileOptions(options map[string]string) { + for key, value := range options { + // Set the flag value if it hasn't been explicitly set on command line + flag := cmdMini.Flag.Lookup(key) + if flag != nil { + // Only set if not already set (by command line) + if flag.Value.String() == flag.DefValue { + flag.Value.Set(value) + glog.V(2).Infof("Applied config file option: %s=%s", key, value) + } + } + } +} + +// saveMiniConfiguration saves the current mini configuration to a file +// The file format uses option=value format without leading dashes +func saveMiniConfiguration(dataFolder string) error { + configDir := util.ResolvePath(util.StringSplit(dataFolder, ",")[0]) + if err := os.MkdirAll(configDir, 0755); err != nil { + glog.Warningf("Failed to create config directory %s: %v", configDir, err) + return err + } + + configFile := filepath.Join(configDir, "mini.options") + + var sb strings.Builder + sb.WriteString("#!/bin/bash\n") + sb.WriteString("# Mini server configuration\n") + sb.WriteString("# Format: option=value (no leading dash)\n") + sb.WriteString("# This file is loaded on startup if it exists\n\n") + + // Collect all flags that were explicitly passed (except "dir") + cmdMini.Flag.Visit(func(f *flag.Flag) { + // Skip the "dir" option - it's environment-specific + if f.Name == "dir" { + return + } + value := f.Value.String() + // Quote the value if it contains spaces + if strings.Contains(value, " ") { + sb.WriteString(fmt.Sprintf("%s=\"%s\"\n", f.Name, value)) + } else { + sb.WriteString(fmt.Sprintf("%s=%s\n", f.Name, value)) + } + }) + + // Add auto-calculated volume size if it was computed + if !isFlagPassed("master.volumeSizeLimitMB") && miniMasterOptions.volumeSizeLimitMB != nil { + sb.WriteString(fmt.Sprintf("\n# Auto-calculated volume size based on total disk capacity\n")) + sb.WriteString(fmt.Sprintf("# Delete this line to force recalculation on next startup\n")) + sb.WriteString(fmt.Sprintf("master.volumeSizeLimitMB=%d\n", *miniMasterOptions.volumeSizeLimitMB)) + } + + if err := os.WriteFile(configFile, []byte(sb.String()), 0644); err != nil { + glog.Warningf("Failed to save configuration to %s: %v", configFile, err) + return err + } + + glog.Infof("Mini configuration saved to %s", configFile) + return nil +} + func runMini(cmd *Command, args []string) bool { + // Load configuration from file if it exists + configOptions, err := loadMiniConfigurationFile(*miniDataFolders) + if err != nil { + glog.Warningf("Error loading configuration file: %v", err) + } + // Apply loaded options to flags (CLI flags will override these) + applyConfigFileOptions(configOptions) + if *miniOptions.debug { grace.StartDebugServer(*miniOptions.debugPort) } @@ -325,6 +519,20 @@ func runMini(cmd *Command, args []string) bool { } miniFilerOptions.defaultLevelDbDirectory = miniMasterOptions.metaFolder + // Calculate and set optimal volume size limit based on available disk space + // Only auto-calculate if user didn't explicitly specify a value via -master.volumeSizeLimitMB + if !isFlagPassed("master.volumeSizeLimitMB") { + // User didn't override, use auto-calculated value + // The -dir flag can accept comma-separated directories; use the first one for disk space calculation + resolvedDataFolder := util.ResolvePath(util.StringSplit(*miniDataFolders, ",")[0]) + optimalVolumeSizeMB := calculateOptimalVolumeSizeMB(resolvedDataFolder) + miniMasterOptions.volumeSizeLimitMB = &optimalVolumeSizeMB + glog.Infof("Mini started with auto-calculated optimal volume size limit: %dMB", optimalVolumeSizeMB) + } else { + // User specified a custom value + glog.Infof("Mini started with user-specified volume size limit: %dMB", *miniMasterOptions.volumeSizeLimitMB) + } + miniWhiteList := util.StringSplit(*miniWhiteListOption, ",") // Start all services with proper dependency coordination @@ -338,6 +546,9 @@ func runMini(cmd *Command, args []string) bool { // Print welcome message after all services are running printWelcomeMessage() + // Save configuration to file for persistence and documentation + saveMiniConfiguration(*miniDataFolders) + select {} } @@ -633,7 +844,7 @@ const welcomeMessageTemplate = ` Volume Server: http://%s:%d Optimized Settings: - • Volume size limit: 128MB + • Volume size limit: %dMB • Volume max: auto (based on free disk space) • Pre-stop seconds: 1 (faster shutdown) • Master peers: none (single master mode) @@ -673,6 +884,7 @@ func printWelcomeMessage() { *miniIp, *miniWebDavOptions.port, *miniIp, *miniAdminOptions.port, *miniIp, *miniOptions.v.port, + *miniMasterOptions.volumeSizeLimitMB, *miniDataFolders, ) From 1dfda78e59bd181868718114f498777941cad5e0 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 21 Dec 2025 12:49:05 -0800 Subject: [PATCH 02/66] update doc --- weed/command/mini.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/weed/command/mini.go b/weed/command/mini.go index b61da5f2e..2602a4a23 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -266,19 +266,17 @@ func init() { // // Algorithm: // 1. Read total disk capacity using the OS-independent stats.NewDiskStatus() -// 2. Divide total disk capacity by 100 to estimate optimal volume size +// 2. Convert capacity from bytes to MB, then divide by 100 // 3. Round up to nearest power of 2 (64MB, 128MB, 256MB, 512MB, 1024MB, etc.) // 4. Clamp the result to range [64MB, 1024MB] // -// Examples (values are rounded to next power of 2 and capped at 1GB): -// - 10GB disk → 10 / 100 = 0.1MB → rounds to 64MB (minimum) -// - 100GB disk → 100 / 100 = 1MB → rounds to 1MB, clamped to 64MB (minimum) -// - 500GB disk → 500 / 100 = 5MB → rounds to 8MB, clamped to 64MB (minimum) -// - 1TB disk → 1000 / 100 = 10MB → rounds to 16MB, clamped to 64MB (minimum) -// - 6.4TB disk → 6400 / 100 = 64MB → rounds to 64MB -// - 12.8TB disk → 12800 / 100 = 128MB → rounds to 128MB -// - 100TB disk → 100000 / 100 = 1000MB → rounds to 1024MB (maximum) -// - 1PB disk → 1000000 / 100 = 10000MB → capped at 1024MB (maximum) +// Examples (GB→MB conversion, divide by 100, round to next power-of-2, clamp [64,1024]): +// - 10GB disk → 10240MB / 100 = 102.4MB → rounds to 128MB +// - 100GB disk → 102400MB / 100 = 1024MB → rounds to 1024MB +// - 500GB disk → 512000MB / 100 = 5120MB → rounds to 8192MB → capped to 1024MB +// - 1TB disk → 1048576MB / 100 = 10485.76MB → capped to 1024MB (maximum) +// - 6.4TB disk → 6553600MB / 100 = 65536MB → capped to 1024MB (maximum) +// - 12.8TB disk → 13107200MB / 100 = 131072MB → capped to 1024MB (maximum) func calculateOptimalVolumeSizeMB(dataFolder string) uint { // Get disk status for the data folder using OS-independent function diskStatus := stats_collect.NewDiskStatus(dataFolder) From 683eef72a680cad2565c6ad19d152b782f6758d6 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 21 Dec 2025 19:29:08 -0800 Subject: [PATCH 03/66] fix: prevent panic on close of closed channel in worker client reconnection (#7837) * fix: prevent panic on close of closed channel in worker client reconnection - Use idiomatic Go pattern of setting channels to nil after closing instead of flags - Extract repeated safe-close logic into safeCloseChannel() helper method - Call safeCloseChannel() in attemptConnection(), reconnect(), and handleDisconnect() - In safeCloseChannel(), check if channel is not nil, close it, and set to nil - Also set streamExit to nil in attemptConnection() when registration fails - This follows Go best practices for channel management and prevents double-close panics - Improved code maintainability by eliminating duplication * fix: prevent panic on close of closed channel in worker client reconnection - Use idiomatic Go pattern of setting channels to nil after closing instead of flags - Extract repeated safe-close logic into safeCloseChannel() helper method - Call safeCloseChannel() in attemptConnection(), reconnect(), and handleDisconnect() - In safeCloseChannel(), check if channel is not nil, close it, and set to nil - Also set streamExit to nil in attemptConnection() when registration fails - Document thread-safety assumptions: function is safe in current usage (serialized in managerLoop) but would need synchronization if used in concurrent contexts - This follows Go best practices for channel management and prevents double-close panics - Improved code maintainability by eliminating duplication --- weed/worker/client.go | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/weed/worker/client.go b/weed/worker/client.go index 74c80662c..a080e58cf 100644 --- a/weed/worker/client.go +++ b/weed/worker/client.go @@ -98,6 +98,17 @@ func NewGrpcAdminClient(adminAddress string, workerID string, dialOption grpc.Di return c } +// safeCloseChannel safely closes a channel and sets it to nil to prevent double-close panics. +// NOTE: This function is NOT thread-safe. It is safe to use in this codebase because all calls +// are serialized within the managerLoop goroutine. If this function is used in concurrent contexts +// in the future, synchronization (e.g., sync.Mutex) should be added. +func (c *GrpcAdminClient) safeCloseChannel(chPtr *chan struct{}) { + if *chPtr != nil { + close(*chPtr) + *chPtr = nil + } +} + func (c *GrpcAdminClient) managerLoop() { state := &grpcState{shouldReconnect: true} @@ -221,7 +232,7 @@ func (c *GrpcAdminClient) attemptConnection(s *grpcState) error { if s.lastWorkerInfo != nil { // Send registration via the normal outgoing channel and wait for response via incoming if err := c.sendRegistration(s.lastWorkerInfo); err != nil { - close(s.streamExit) + c.safeCloseChannel(&s.streamExit) s.streamCancel() s.conn.Close() s.connected = false @@ -240,9 +251,7 @@ func (c *GrpcAdminClient) attemptConnection(s *grpcState) error { // reconnect attempts to re-establish the connection func (c *GrpcAdminClient) reconnect(s *grpcState) error { // Clean up existing connection completely - if s.streamExit != nil { - close(s.streamExit) - } + c.safeCloseChannel(&s.streamExit) if s.streamCancel != nil { s.streamCancel() } @@ -425,7 +434,7 @@ func (c *GrpcAdminClient) handleDisconnect(cmd grpcCommand, s *grpcState) { } // Send shutdown signal to stop reconnection loop - close(s.reconnectStop) + c.safeCloseChannel(&s.reconnectStop) s.connected = false s.shouldReconnect = false @@ -450,7 +459,7 @@ func (c *GrpcAdminClient) handleDisconnect(cmd grpcCommand, s *grpcState) { } // Send shutdown signal to stop handlers loop - close(s.streamExit) + c.safeCloseChannel(&s.streamExit) // Cancel stream context if s.streamCancel != nil { From 9a4f32fc495d8d83756c4e6e985bce06cc4f7517 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sun, 21 Dec 2025 23:25:30 -0800 Subject: [PATCH 04/66] feat: add automatic port detection and fallback for mini command (#7836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add automatic port detection and fallback for mini command - Added port availability detection using TCP binding tests - Implemented port fallback mechanism searching for available ports - Support for both HTTP and gRPC port handling - IP-aware port checking using actual service bind address - Dual-interface verification (specific IP and wildcard 0.0.0.0) - All services (Master, Volume, Filer, S3, WebDAV, Admin) auto-reallocate to available ports - Enables multiple mini instances to run simultaneously without conflicts * fix: use actual bind IP for service health checks - Previously health checks were hardcoded to localhost (127.0.0.1) - This caused failures when services bind to actual IP (e.g., 10.21.153.8) - Now health checks use the same IP that services are binding to - Fixes Volume and other service health check failures on non-localhost IPs * refactor: improve port detection logic and remove gRPC handling duplication - findAvailablePortOnIP now returns 0 on failure instead of unavailable port Allows callers to detect when port finding fails and handle appropriately - Remove duplicate gRPC port handling from ensureAllPortsAvailableOnIP All gRPC port logic is now centralized in initializeGrpcPortsOnIP - Log final port configuration only after all ports are finalized Both HTTP and gRPC ports are now correctly initialized before logging - Add error logging when port allocation fails Makes debugging easier when ports can't be found * refactor: fix race condition and clean up port detection code - Convert parallel HTTP port checks to sequential to prevent race conditions where multiple goroutines could allocate the same available port - Remove unused 'sync' import since WaitGroup is no longer used - Add documentation to localhost wrapper functions explaining they are kept for backwards compatibility and future use - All gRPC port logic is now exclusively handled in initializeGrpcPortsOnIP eliminating any duplication in ensureAllPortsAvailableOnIP * refactor: address code review comments - constants, helper function, and cleanup - Define GrpcPortOffset constant (10000) to replace magic numbers throughout the code for better maintainability and consistency - Extract bindIp determination logic into getBindIp() helper function to eliminate code duplication between runMini and startMiniServices - Remove redundant 'calculatedPort = calculatedPort' assignment that had no effect - Update all gRPC port calculations to use GrpcPortOffset constant (lines 489, 886 and the error logging at line 501) * refactor: remove unused wrapper functions and update documentation - Remove unused localhost wrapper functions that were never called: - isPortOpen() - wrapper around isPortOpenOnIP with hardcoded 127.0.0.1 - findAvailablePort() - wrapper around findAvailablePortOnIP with hardcoded 127.0.0.1 - ensurePortAvailable() - wrapper around ensurePortAvailableOnIP with hardcoded 127.0.0.1 - ensureAllPortsAvailable() - wrapper around ensureAllPortsAvailableOnIP with hardcoded 127.0.0.1 Since this is new functionality with no backwards compatibility concerns, these wrapper functions were not needed. The comments claiming they were 'kept for future use or backwards compatibility' are no longer valid. - Update documentation to reference GrpcPortOffset constant instead of hardcoded 10000: - Update comment in ensureAllPortsAvailableOnIP to use GrpcPortOffset - Update admin.port.grpc flag help text to reference GrpcPortOffset Note: getBindIp() is actually being used and should be retained (contrary to the review comment suggesting it was unused - it's called in both runMini and startMiniServices functions) * refactor: prevent HTTP/gRPC port collisions and improve error handling - Add upfront reservation of all calculated gRPC ports before allocating HTTP ports to prevent collisions where an HTTP port allocation could use a port that will later be needed for a gRPC port calculation. Example scenario that is now prevented: - Master HTTP reallocated from 9333 to 9334 (original in use) - Filer HTTP search finds 19334 available and assigns it - Master gRPC calculated as 9334 + GrpcPortOffset = 19334 → collision! Now: reserved gRPC ports are tracked upfront and HTTP port search skips them. - Improve admin server gRPC port fallback error handling: - Change from silent V(1) verbose log to Warningf to make the error visible - Update comment to clarify this indicates a problem in the port initialization sequence - Add explanation that the fallback calculation may cause bind failure - Update ensureAllPortsAvailableOnIP comment to clarify it avoids reserved ports * fix: enforce reserved ports in HTTP allocation and improve admin gRPC fallback Critical fixes for port allocation safety: 1. Make findAvailablePortOnIP and ensurePortAvailableOnIP aware of reservedPorts: - Add reservedPorts map parameter to both functions - findAvailablePortOnIP now skips reserved ports when searching for alternatives - ensurePortAvailableOnIP passes reservedPorts through to findAvailablePortOnIP - This prevents HTTP ports from being allocated to ports reserved for gRPC 2. Update ensureAllPortsAvailableOnIP to pass reservedPorts: - Pass the reservedPorts map to ensurePortAvailableOnIP calls - Maintains the map updates (delete/add) for accuracy as ports change 3. Replace blind admin gRPC port fallback with proper availability checks: - Previous code just calculated *miniAdminOptions.port + GrpcPortOffset - New code checks both the calculated port and finds alternatives if needed - Uses the same availability checking logic as initializeGrpcPortsOnIP - Properly logs the fallback process and any port changes - Will fail gracefully if no available ports found (consistent with other services) These changes eliminate two critical vulnerabilities: - HTTP port allocation can no longer accidentally claim gRPC ports - Admin gRPC port fallback no longer blindly uses an unchecked port * fix: prevent gRPC port collisions during multi-service fallback allocation Critical fix for gRPC port allocation safety across multiple services: Problem: When multiple services need gRPC port fallback allocation in sequence (e.g., Master gRPC unavailable → finds alternative, then Filer gRPC unavailable → searches from calculated port), there was no tracking of previously allocated gRPC ports. This could allow two services to claim the same port. Scenario that is now prevented: - Master gRPC: calculated 19333 unavailable → finds 19334 → assigns 19334 - Filer gRPC: calculated 18888 unavailable → searches from 18889, might land on 19334 if consecutive ports in range are unavailable (especially with custom port configurations or in high-port-contention environments) Solution: - Add allocatedGrpcPorts map to track gRPC ports allocated within the function - Check allocatedGrpcPorts before using calculated port for each service - Pass allocatedGrpcPorts to findAvailablePortOnIP when finding fallback ports - Add allocatedGrpcPorts[port] = true after each successful allocation - This ensures no two services can allocate the same gRPC port The fix handles both: 1. Calculated gRPC ports (when grpcPort == 0) 2. Explicitly set gRPC ports (when user provides -service.port.grpc value) While default port spacing makes collision unlikely, this fix is essential for: - Custom port configurations - High-contention environments - Edge cases with many unavailable consecutive ports - Correctness and safety guarantees * feat: enforce hard-fail behavior for explicitly specified ports When users explicitly specify a port via command-line flags (e.g., -s3.port=8333), the server should fail immediately if the port is unavailable, rather than silently falling back to an alternative port. This prevents user confusion and makes misconfiguration failures obvious. Changes: - Modified ensurePortAvailableOnIP() to check if a port was explicitly passed via isFlagPassed() - If an explicit port is unavailable, return error instead of silently allocating alternative - Updated ensureAllPortsAvailableOnIP() to handle the returned error and fail startup - Modified runMini() to check error from ensureAllPortsAvailableOnIP() and return false on failure - Default ports (not explicitly specified) continue to fallback to available alternatives This ensures: - Explicit ports: fail if unavailable (e.g., -s3.port=8333 fails if 8333 is taken) - Default ports: fallback to alternatives (e.g., s3.port without flag falls back to 8334 if 8333 taken) * fix: accurate error messages for explicitly specified unavailable ports When a port is explicitly specified via CLI flags but is unavailable, the error message now correctly reports the originally requested port instead of reporting a fallback port that was calculated internally. The issue was that the config file applied after CLI flag parsing caused isFlagPassed() to return true for ports loaded from the config file (since flag.Visit() was called during config file application), incorrectly marking them as explicitly specified. Solution: Capture which port flags were explicitly passed on the CLI BEFORE the config file is applied, storing them in the explicitPortFlags map. This preserves the accurate distinction between user-specified ports and defaults/config-file ports. Example: - User runs: weed mini -dir=. -s3.port=22 - Now correctly shows: 'port 22 for S3 (specified by flag s3.port) is not available' - Previously incorrectly showed: 'port 8334 for S3...' (some calculated fallback) * fix: respect explicitly specified ports and prevent config file override When a port is explicitly specified via CLI flags (e.g., -s3.port=8333), the config file options should NOT override it. Previously, config file options would be applied if the flag value differed from default, but this check wasn't sufficient to prevent override in all cases. Solution: Check the explicitPortFlags map before applying any config file port options. If a port was explicitly passed on the CLI, skip applying the config file option for that port. This ensures: - Explicit ports take absolute precedence over config file ports - Config file ports are only used if port wasn't specified on CLI - Example: 'weed mini -s3.port=8333' will use 8333, never the config file value * fix: don't print usage on port allocation error When a port allocation fails (e.g., explicit port is unavailable), exit immediately without showing the usage example. This provides cleaner error output when the error is expected (port conflict). * fix: increase worker registration timeout for reconnections Increase the worker registration timeout from 10 seconds to 30 seconds. The 10-second timeout was too aggressive for reconnections when the admin server might be busy processing other operations. Reconnecting workers need more time to: 1. Re-establish the gRPC connection 2. Send the registration message 3. Wait for the admin server to process and respond This prevents spurious "registration timeout" errors during long-running mini instances when brief network hiccups or admin server load cause delays. * refactor: clean up code quality issues Remove no-op assignment (calculatedPort = calculatedPort) that had no effect. The variable already holds the correct value when no alternative port is found. Improve documentation for the defensive gRPC port initialization fallback in startAdminServer. While this code shouldn't execute in normal flow because ensureAllPortsAvailableOnIP is called earlier in runMini, the fallback handles edge cases where port initialization may have been skipped or failed silently due to configuration changes or error handling paths. --- weed/command/mini.go | 291 ++++++++++++++++++++++++++++++++++++++++-- weed/worker/client.go | 3 +- 2 files changed, 284 insertions(+), 10 deletions(-) diff --git a/weed/command/mini.go b/weed/command/mini.go index 2602a4a23..fc359f904 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -44,6 +44,7 @@ const ( minVolumeSizeMB = 64 // Minimum volume size in MB defaultMiniVolumeSizeMB = 128 // Default volume size for mini mode maxVolumeSizeMB = 1024 // Maximum volume size in MB (1GB) + GrpcPortOffset = 10000 // Offset used to calculate gRPC port from HTTP port ) var ( @@ -54,6 +55,8 @@ var ( miniWebDavOptions WebDavOption miniAdminOptions AdminOptions createdInitialIAM bool // Track if initial IAM config was created from env vars + // Track which port flags were explicitly passed on CLI before config file is applied + explicitPortFlags map[string]bool ) func init() { @@ -117,6 +120,15 @@ var ( miniS3AllowDeleteBucketNotEmpty = cmdMini.Flag.Bool("s3.allowDeleteBucketNotEmpty", true, "allow recursive deleting all entries along with bucket") ) +// getBindIp determines the bind IP address based on miniIp and miniBindIp flags +// Returns miniBindIp if set (non-empty), otherwise returns miniIp +func getBindIp() string { + if *miniBindIp != "" { + return *miniBindIp + } + return *miniIp +} + // initMiniCommonFlags initializes common mini flags func initMiniCommonFlags() { miniOptions.cpuprofile = cmdMini.Flag.String("cpuprofile", "", "cpu profile output file") @@ -242,7 +254,7 @@ func initMiniWebDAVFlags() { // initMiniAdminFlags initializes Admin server flag options func initMiniAdminFlags() { miniAdminOptions.port = cmdMini.Flag.Int("admin.port", 23646, "admin server http listen port") - miniAdminOptions.grpcPort = cmdMini.Flag.Int("admin.port.grpc", 0, "admin server grpc listen port (default: admin http port + 10000)") + miniAdminOptions.grpcPort = cmdMini.Flag.Int("admin.port.grpc", 0, "admin server grpc listen port (default: admin http port + GrpcPortOffset)") miniAdminOptions.master = cmdMini.Flag.String("admin.master", "", "master server address (automatically set)") miniAdminOptions.dataDir = cmdMini.Flag.String("admin.dataDir", "", "directory to store admin configuration and data files") miniAdminOptions.adminUser = cmdMini.Flag.String("admin.user", "admin", "admin interface username") @@ -326,6 +338,221 @@ func isFlagPassed(name string) bool { return found } +// isPortOpenOnIP checks if a port is available for binding on a specific IP address +func isPortOpenOnIP(ip string, port int) bool { + listener, err := net.Listen("tcp", fmt.Sprintf("%s:%d", ip, port)) + if err != nil { + return false + } + listener.Close() + return true +} + +// isPortAvailable checks if a port is available on any interface +// This is more comprehensive than checking a single IP +func isPortAvailable(port int) bool { + // Try to listen on all interfaces (0.0.0.0) + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return false + } + listener.Close() + return true +} + +// findAvailablePortOnIP finds the next available port on a specific IP starting from the given port +// It skips any ports that are in the reservedPorts map (for gRPC port collision avoidance) +// It returns the first available port found within maxAttempts, or 0 if none found +func findAvailablePortOnIP(ip string, startPort int, maxAttempts int, reservedPorts map[int]bool) int { + for i := 0; i < maxAttempts; i++ { + port := startPort + i + // Skip ports reserved for gRPC calculation + if reservedPorts[port] { + continue + } + // Check on both the specific IP and on all interfaces for maximum reliability + if isPortOpenOnIP(ip, port) && isPortAvailable(port) { + return port + } + } + // If no port found, return 0 to indicate failure + return 0 +} + +// ensurePortAvailableOnIP ensures a port pointer points to an available port on a specific IP +// If the port is not available, it finds the next available port and updates the pointer +// The reservedPorts map contains ports that should not be allocated (for gRPC collision avoidance) +func ensurePortAvailableOnIP(portPtr *int, serviceName string, ip string, reservedPorts map[int]bool, flagName string) error { + if portPtr == nil { + return nil + } + + original := *portPtr + + // Check if this port was explicitly specified by the user (from CLI, before config file was applied) + isExplicitPort := explicitPortFlags[flagName] + + // Skip if this port is reserved for gRPC calculation + if reservedPorts[original] { + if isExplicitPort { + return fmt.Errorf("port %d for %s (specified by flag %s) is reserved for gRPC calculation and cannot be used", original, serviceName, flagName) + } + glog.Warningf("Port %d for %s is reserved for gRPC calculation, finding alternative...", original, serviceName) + newPort := findAvailablePortOnIP(ip, original+1, 100, reservedPorts) + if newPort == 0 { + glog.Errorf("Could not find available port for %s starting from %d, will use original %d and fail on binding", serviceName, original+1, original) + } else { + glog.Infof("Port %d for %s is available, using it instead of %d", newPort, serviceName, original) + *portPtr = newPort + } + return nil + } + + // Check on both the specific IP and on all interfaces (0.0.0.0) for maximum reliability + if !isPortOpenOnIP(ip, original) || !isPortAvailable(original) { + // If explicitly specified, fail immediately with the originally requested port + if isExplicitPort { + return fmt.Errorf("port %d for %s (specified by flag %s) is not available on %s and cannot be used", original, serviceName, flagName, ip) + } + // For default ports, try to find an alternative + glog.Warningf("Port %d for %s is not available on %s, finding alternative port...", original, serviceName, ip) + newPort := findAvailablePortOnIP(ip, original+1, 100, reservedPorts) + if newPort == 0 { + glog.Errorf("Could not find available port for %s starting from %d, will use original %d and fail on binding", serviceName, original+1, original) + } else { + glog.Infof("Port %d for %s is available, using it instead of %d", newPort, serviceName, original) + *portPtr = newPort + } + } else { + glog.V(1).Infof("Port %d for %s is available on %s", original, serviceName, ip) + } + return nil +} + +// ensureAllPortsAvailableOnIP ensures all mini service ports are available on a specific IP +// Returns an error if an explicitly specified port is unavailable. +// This should be called before starting any services +func ensureAllPortsAvailableOnIP(bindIp string) error { + portConfigs := []struct { + port *int + name string + flagName string + grpcPtr *int + }{ + {miniMasterOptions.port, "Master", "master.port", miniMasterOptions.portGrpc}, + {miniFilerOptions.port, "Filer", "filer.port", miniFilerOptions.portGrpc}, + {miniOptions.v.port, "Volume", "volume.port", miniOptions.v.portGrpc}, + {miniS3Options.port, "S3", "s3.port", miniS3Options.portGrpc}, + {miniWebDavOptions.port, "WebDAV", "webdav.port", nil}, + {miniAdminOptions.port, "Admin", "admin.port", miniAdminOptions.grpcPort}, + } + + // First, reserve all gRPC ports that will be calculated to prevent HTTP port allocation from using them + // This prevents collisions like: HTTP port moves to X, then gRPC port is calculated as Y where Y == X + reservedPorts := make(map[int]bool) + for _, config := range portConfigs { + if config.grpcPtr != nil && *config.grpcPtr == 0 { + // This gRPC port will be calculated as httpPort + GrpcPortOffset + calculatedGrpcPort := *config.port + GrpcPortOffset + reservedPorts[calculatedGrpcPort] = true + } + } + + // Check all HTTP ports sequentially to avoid race conditions + // Each port check and allocation must complete before the next one starts + // to prevent multiple goroutines from claiming the same available port + // Also avoid allocating ports that are reserved for gRPC calculation + for _, config := range portConfigs { + original := *config.port + if err := ensurePortAvailableOnIP(config.port, config.name, bindIp, reservedPorts, config.flagName); err != nil { + return err + } + // If port was changed, update the reserved gRPC ports mapping + if *config.port != original && config.grpcPtr != nil && *config.grpcPtr == 0 { + delete(reservedPorts, original+GrpcPortOffset) + reservedPorts[*config.port+GrpcPortOffset] = true + } + } + + // Initialize all gRPC ports before services start + // This ensures they won't be recalculated and cause conflicts + // All gRPC port handling (calculation, validation, and assignment) is performed exclusively in initializeGrpcPortsOnIP + initializeGrpcPortsOnIP(bindIp) + + // Log the final port configuration + glog.Infof("Final port configuration - Master: %d, Filer: %d, Volume: %d, S3: %d, WebDAV: %d, Admin: %d", + *miniMasterOptions.port, *miniFilerOptions.port, *miniOptions.v.port, + *miniS3Options.port, *miniWebDavOptions.port, *miniAdminOptions.port) + + // Log gRPC ports too (now finalized) + glog.Infof("gRPC port configuration - Master: %d, Filer: %d, Volume: %d, S3: %d, Admin: %d", + *miniMasterOptions.portGrpc, *miniFilerOptions.portGrpc, *miniOptions.v.portGrpc, + *miniS3Options.portGrpc, *miniAdminOptions.grpcPort) + + return nil +} + +// initializeGrpcPortsOnIP initializes all gRPC ports based on their HTTP ports on a specific IP +// If a gRPC port is 0, it will be set to httpPort + GrpcPortOffset +// This must be called after HTTP ports are finalized and before services start +func initializeGrpcPortsOnIP(bindIp string) { + // Track gRPC ports allocated during this function to prevent collisions between services + // when multiple services need fallback port allocation + allocatedGrpcPorts := make(map[int]bool) + + grpcConfigs := []struct { + httpPort *int + grpcPort *int + name string + }{ + {miniMasterOptions.port, miniMasterOptions.portGrpc, "Master"}, + {miniFilerOptions.port, miniFilerOptions.portGrpc, "Filer"}, + {miniOptions.v.port, miniOptions.v.portGrpc, "Volume"}, + {miniS3Options.port, miniS3Options.portGrpc, "S3"}, + {miniAdminOptions.port, miniAdminOptions.grpcPort, "Admin"}, + } + + for _, config := range grpcConfigs { + if config.grpcPort == nil { + continue + } + + // If gRPC port is 0, calculate it + if *config.grpcPort == 0 { + calculatedPort := *config.httpPort + GrpcPortOffset + // Check if calculated port is available (on both specific IP and all interfaces) + // Also check if it was already allocated to another service in this function + if !isPortOpenOnIP(bindIp, calculatedPort) || !isPortAvailable(calculatedPort) || allocatedGrpcPorts[calculatedPort] { + glog.Warningf("Calculated gRPC port %d for %s is not available, finding alternative...", calculatedPort, config.name) + newPort := findAvailablePortOnIP(bindIp, calculatedPort+1, 100, allocatedGrpcPorts) + if newPort == 0 { + glog.Errorf("Could not find available gRPC port for %s starting from %d, will use calculated %d and fail on binding", config.name, calculatedPort+1, calculatedPort) + } else { + calculatedPort = newPort + glog.Infof("gRPC port %d for %s is available, using it instead of calculated %d", newPort, config.name, *config.httpPort+GrpcPortOffset) + } + } + *config.grpcPort = calculatedPort + allocatedGrpcPorts[calculatedPort] = true + glog.V(1).Infof("%s gRPC port initialized to %d", config.name, calculatedPort) + } else { + // gRPC port was explicitly set, verify it's still available (check on both specific IP and all interfaces) + // Also check if it was already allocated to another service in this function + if !isPortOpenOnIP(bindIp, *config.grpcPort) || !isPortAvailable(*config.grpcPort) || allocatedGrpcPorts[*config.grpcPort] { + glog.Warningf("Explicitly set gRPC port %d for %s is not available, finding alternative...", *config.grpcPort, config.name) + newPort := findAvailablePortOnIP(bindIp, *config.grpcPort+1, 100, allocatedGrpcPorts) + if newPort == 0 { + glog.Errorf("Could not find available gRPC port for %s starting from %d, will use original %d and fail on binding", config.name, *config.grpcPort+1, *config.grpcPort) + } else { + glog.Infof("gRPC port %d for %s is available, using it instead of %d", newPort, config.name, *config.grpcPort) + *config.grpcPort = newPort + } + } + allocatedGrpcPorts[*config.grpcPort] = true + } + } +} + // loadMiniConfigurationFile reads the mini.options file and returns parsed options // File format: one option per line, without leading dash (e.g., "ip=127.0.0.1") func loadMiniConfigurationFile(dataFolder string) (map[string]string, error) { @@ -380,6 +607,11 @@ func loadMiniConfigurationFile(dataFolder string) (map[string]string, error) { // applyConfigFileOptions sets command-line flags from loaded configuration file func applyConfigFileOptions(options map[string]string) { for key, value := range options { + // Skip port flags that were explicitly passed on CLI + if explicitPortFlags[key] { + glog.V(2).Infof("Skipping config file option %s=%s (explicitly specified on command line)", key, value) + continue + } // Set the flag value if it hasn't been explicitly set on command line flag := cmdMini.Flag.Lookup(key) if flag != nil { @@ -442,6 +674,14 @@ func saveMiniConfiguration(dataFolder string) error { func runMini(cmd *Command, args []string) bool { + // Capture which port flags were explicitly passed on CLI BEFORE config file is applied + // This is necessary to distinguish user-specified ports from defaults or config file options + explicitPortFlags = make(map[string]bool) + portFlagNames := []string{"master.port", "filer.port", "volume.port", "s3.port", "webdav.port", "admin.port"} + for _, flagName := range portFlagNames { + explicitPortFlags[flagName] = isFlagPassed(flagName) + } + // Load configuration from file if it exists configOptions, err := loadMiniConfigurationFile(*miniDataFolders) if err != nil { @@ -459,6 +699,15 @@ func runMini(cmd *Command, args []string) bool { grace.SetupProfiling(*miniOptions.cpuprofile, *miniOptions.memprofile) + // Determine bind IP + bindIp := getBindIp() + + // Ensure all ports are available, find alternatives if needed + if err := ensureAllPortsAvailableOnIP(bindIp); err != nil { + glog.Errorf("Port allocation failed: %v", err) + os.Exit(1) + } + // Set master.peers to "none" if not specified (single master mode) if *miniMasterOptions.peers == "" { *miniMasterOptions.peers = "none" @@ -552,13 +801,16 @@ func runMini(cmd *Command, args []string) bool { // startMiniServices starts all mini services with proper dependency coordination func startMiniServices(miniWhiteList []string, allServicesReady chan struct{}) { + // Determine bind IP for health checks + bindIp := getBindIp() + // Start Master server (no dependencies) go startMiniService("Master", func() { startMaster(miniMasterOptions, miniWhiteList) }, *miniMasterOptions.port) // Wait for master to be ready - waitForServiceReady("Master", *miniMasterOptions.port) + waitForServiceReady("Master", *miniMasterOptions.port, bindIp) // Start Volume server (depends on master) go startMiniService("Volume", func() { @@ -567,7 +819,7 @@ func startMiniServices(miniWhiteList []string, allServicesReady chan struct{}) { }, *miniOptions.v.port) // Wait for volume to be ready - waitForServiceReady("Volume", *miniOptions.v.port) + waitForServiceReady("Volume", *miniOptions.v.port, bindIp) // Start Filer (depends on master and volume) go startMiniService("Filer", func() { @@ -575,7 +827,7 @@ func startMiniServices(miniWhiteList []string, allServicesReady chan struct{}) { }, *miniFilerOptions.port) // Wait for filer to be ready - waitForServiceReady("Filer", *miniFilerOptions.port) + waitForServiceReady("Filer", *miniFilerOptions.port, bindIp) // Start S3 and WebDAV in parallel (both depend on filer) go startMiniService("S3", func() { @@ -587,8 +839,8 @@ func startMiniServices(miniWhiteList []string, allServicesReady chan struct{}) { }, *miniWebDavOptions.port) // Wait for both S3 and WebDAV to be ready - waitForServiceReady("S3", *miniS3Options.port) - waitForServiceReady("WebDAV", *miniWebDavOptions.port) + waitForServiceReady("S3", *miniS3Options.port, bindIp) + waitForServiceReady("WebDAV", *miniWebDavOptions.port, bindIp) // Start Admin with worker (depends on master, filer, S3, WebDAV) go startMiniAdminWithWorker(allServicesReady) @@ -601,8 +853,8 @@ func startMiniService(name string, fn func(), port int) { } // waitForServiceReady pings the service HTTP endpoint to check if it's ready to accept connections -func waitForServiceReady(name string, port int) { - address := fmt.Sprintf("http://127.0.0.1:%d", port) +func waitForServiceReady(name string, port int, bindIp string) { + address := fmt.Sprintf("http://%s:%d", bindIp, port) maxAttempts := 30 // 30 * 200ms = 6 seconds max wait attempt := 0 client := &http.Client{ @@ -679,8 +931,29 @@ func startMiniAdminWithWorker(allServicesReady chan struct{}) { // Set admin options *miniAdminOptions.master = masterAddr + + // gRPC port should have been initialized by ensureAllPortsAvailableOnIP in runMini + // If it's still 0, that indicates a problem with the port initialization sequence + // This defensive fallback handles edge cases where port initialization may have been skipped + // or failed silently (e.g., due to configuration changes or error handling paths) if *miniAdminOptions.grpcPort == 0 { - *miniAdminOptions.grpcPort = *miniAdminOptions.port + 10000 + glog.Warningf("Admin gRPC port was not initialized before startAdminServer, attempting fallback initialization...") + // Use the same availability checking logic as initializeGrpcPortsOnIP + calculatedPort := *miniAdminOptions.port + GrpcPortOffset + if !isPortOpenOnIP(getBindIp(), calculatedPort) || !isPortAvailable(calculatedPort) { + glog.Warningf("Calculated fallback gRPC port %d is not available, finding alternative...", calculatedPort) + newPort := findAvailablePortOnIP(getBindIp(), calculatedPort+1, 100, make(map[int]bool)) + if newPort == 0 { + glog.Errorf("Could not find available gRPC port for Admin starting from %d, will use calculated %d and fail on binding", calculatedPort+1, calculatedPort) + *miniAdminOptions.grpcPort = calculatedPort + } else { + glog.Infof("Fallback: using gRPC port %d for Admin", newPort) + *miniAdminOptions.grpcPort = newPort + } + } else { + *miniAdminOptions.grpcPort = calculatedPort + glog.Infof("Fallback: Admin gRPC port initialized to %d", calculatedPort) + } } // Create data directory if specified diff --git a/weed/worker/client.go b/weed/worker/client.go index a080e58cf..d562b8703 100644 --- a/weed/worker/client.go +++ b/weed/worker/client.go @@ -522,7 +522,8 @@ func (c *GrpcAdminClient) sendRegistration(worker *types.WorkerData) error { } // Wait for registration response - timeout := time.NewTimer(10 * time.Second) + // Use longer timeout for reconnections since admin server might be busy + timeout := time.NewTimer(30 * time.Second) defer timeout.Stop() for { From cc2edfaf68e5ea253cd1c9176868f6d131fe0b33 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 22 Dec 2025 00:58:23 -0800 Subject: [PATCH 05/66] fix: enable RetryForever for active-active cluster sync to prevent out-of-sync (#7840) Fixes #7230 When a cluster goes down during file replication, the chunk upload process would fail after a limited number of retries. Once the remote cluster came back online, those failed uploads were never retried, leaving the clusters out-of-sync. This change enables the RetryForever flag in the UploadOption when replicating chunks between filers. This ensures that upload operations will keep retrying indefinitely, and once the remote cluster comes back online, the pending uploads will automatically succeed. Users no longer need to manually run fs.meta.save and fs.meta.load as a workaround for out-of-sync clusters. --- weed/replication/sink/filersink/fetch_write.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/weed/replication/sink/filersink/fetch_write.go b/weed/replication/sink/filersink/fetch_write.go index 1f257941f..1bcb36a5f 100644 --- a/weed/replication/sink/filersink/fetch_write.go +++ b/weed/replication/sink/filersink/fetch_write.go @@ -2,12 +2,13 @@ package filersink import ( "fmt" - "github.com/schollz/progressbar/v3" - "github.com/seaweedfs/seaweedfs/weed/util" "os" "path/filepath" "sync" + "github.com/schollz/progressbar/v3" + "github.com/seaweedfs/seaweedfs/weed/util" + "google.golang.org/grpc" "github.com/seaweedfs/seaweedfs/weed/glog" @@ -114,6 +115,7 @@ func (fs *FilerSink) fetchAndWrite(sourceChunk *filer_pb.FileChunk, path string) IsInputCompressed: "gzip" == header.Get("Content-Encoding"), MimeType: header.Get("Content-Type"), PairMap: nil, + RetryForever: true, }, func(host, fileId string) string { fileUrl := fmt.Sprintf("http://%s/%s", host, fileId) From 044e44830592b174997a398f58b0b447779b0357 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:18:03 -0800 Subject: [PATCH 06/66] chore(deps): bump github.com/ydb-platform/ydb-go-sdk-auth-environ from 0.5.0 to 0.5.1 (#7848) chore(deps): bump github.com/ydb-platform/ydb-go-sdk-auth-environ Bumps [github.com/ydb-platform/ydb-go-sdk-auth-environ](https://github.com/ydb-platform/ydb-go-sdk-auth-environ) from 0.5.0 to 0.5.1. - [Changelog](https://github.com/ydb-platform/ydb-go-sdk-auth-environ/blob/master/CHANGELOG.md) - [Commits](https://github.com/ydb-platform/ydb-go-sdk-auth-environ/compare/v0.5.0...v0.5.1) --- updated-dependencies: - dependency-name: github.com/ydb-platform/ydb-go-sdk-auth-environ dependency-version: 0.5.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c1407f165..6a29a4e2a 100644 --- a/go.mod +++ b/go.mod @@ -158,7 +158,7 @@ require ( github.com/tarantool/go-tarantool/v2 v2.4.1 github.com/tikv/client-go/v2 v2.0.7 github.com/xeipuuv/gojsonschema v1.2.0 - github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.0 + github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.1 github.com/ydb-platform/ydb-go-sdk/v3 v3.122.0 go.etcd.io/etcd/client/pkg/v3 v3.6.6 go.uber.org/atomic v1.11.0 diff --git a/go.sum b/go.sum index 818406722..f637f84fe 100644 --- a/go.sum +++ b/go.sum @@ -1777,8 +1777,8 @@ github.com/ydb-platform/ydb-go-genproto v0.0.0-20221215182650-986f9d10542f/go.mo github.com/ydb-platform/ydb-go-genproto v0.0.0-20230528143953-42c825ace222/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= github.com/ydb-platform/ydb-go-genproto v0.0.0-20251125145508-6d7ef87db5cb h1:LZ6dhVfWzhicf/P5Xh7fA0Jd7rfGduxmB2QZpD+Lz9Q= github.com/ydb-platform/ydb-go-genproto v0.0.0-20251125145508-6d7ef87db5cb/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= -github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.0 h1:/NyPd9KnCJgzrEXCArqk1ThqCH2Dh31uUwl88o/VkuM= -github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.0/go.mod h1:9YzkhlIymWaJGX6KMU3vh5sOf3UKbCXkG/ZdjaI3zNM= +github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.1 h1:XaRxeVrOyl3y6v9CiYMWaFdZ6zevvYe+TRxOR8ifa2s= +github.com/ydb-platform/ydb-go-sdk-auth-environ v0.5.1/go.mod h1:9YzkhlIymWaJGX6KMU3vh5sOf3UKbCXkG/ZdjaI3zNM= github.com/ydb-platform/ydb-go-sdk/v3 v3.44.0/go.mod h1:oSLwnuilwIpaF5bJJMAofnGgzPJusoI3zWMNb8I+GnM= github.com/ydb-platform/ydb-go-sdk/v3 v3.47.3/go.mod h1:bWnOIcUHd7+Sl7DN+yhyY1H/I61z53GczvwJgXMgvj0= github.com/ydb-platform/ydb-go-sdk/v3 v3.122.0 h1:uvqheUfoEJz0CLLLaYct69wpvoaosM/2joWFcbwoqfw= From 276fd764da6e471c94dc0adf3309c7eba54b2164 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:18:14 -0800 Subject: [PATCH 07/66] chore(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.31.3 to 1.32.6 (#7846) chore(deps): bump github.com/aws/aws-sdk-go-v2/config Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.31.3 to 1.32.6. - [Release notes](https://github.com/aws/aws-sdk-go-v2/releases) - [Changelog](https://github.com/aws/aws-sdk-go-v2/blob/main/changelog-template.json) - [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.31.3...v1.32.6) --- updated-dependencies: - dependency-name: github.com/aws/aws-sdk-go-v2/config dependency-version: 1.32.6 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 9 +++++---- go.sum | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 6a29a4e2a..7c8b320a3 100644 --- a/go.mod +++ b/go.mod @@ -127,8 +127,8 @@ require ( github.com/arangodb/go-driver v1.6.9 github.com/armon/go-metrics v0.4.1 github.com/aws/aws-sdk-go-v2 v1.41.0 - github.com/aws/aws-sdk-go-v2/config v1.31.3 - github.com/aws/aws-sdk-go-v2/credentials v1.19.5 + github.com/aws/aws-sdk-go-v2/config v1.32.6 + github.com/aws/aws-sdk-go-v2/credentials v1.19.6 github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 github.com/cognusion/imaging v1.0.2 github.com/fluent/fluent-logger-golang v1.10.1 @@ -173,6 +173,7 @@ require ( cloud.google.com/go/longrunning v0.6.7 // indirect cloud.google.com/go/pubsub/v2 v2.0.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect github.com/bazelbuild/rules_go v0.46.0 // indirect github.com/biogo/store v0.0.0-20201120204734-aad293a2328f // indirect github.com/blevesearch/snowballstem v0.9.0 // indirect @@ -264,7 +265,7 @@ require ( github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.4 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 // indirect @@ -272,7 +273,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 // indirect github.com/aws/aws-sdk-go-v2/service/sns v1.34.7 // indirect github.com/aws/aws-sdk-go-v2/service/sqs v1.38.8 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect github.com/aws/smithy-go v1.24.0 // indirect diff --git a/go.sum b/go.sum index f637f84fe..8a5a38ef1 100644 --- a/go.sum +++ b/go.sum @@ -669,10 +669,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgP github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.31.3 h1:RIb3yr/+PZ18YYNe6MDiG/3jVoJrPmdoCARwNkMGvco= -github.com/aws/aws-sdk-go-v2/config v1.31.3/go.mod h1:jjgx1n7x0FAKl6TnakqrpkHWWKcX3xfWtdnIJs5K9CE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4= -github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk= +github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= +github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.4 h1:0SzCLoPRSK3qSydsaFQWugP+lOBCTPwfcBOm6222+UA= @@ -681,8 +681,8 @@ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMH github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 h1:NLYTEyZmVZo0Qh183sC8nC+ydJXOOeIL/qI/sS3PdLY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15/go.mod h1:Z803iB3B0bc8oJV8zH2PERLRfQUJ2n2BXISpsA4+O1M= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= @@ -695,12 +695,14 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 h1:wsSQ4SVz5YE1c github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15/go.mod h1:I7sditnFGtYMIqPRU1QoHZAUrXkGp4SczmlLwrNPlD0= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 h1:IrbE3B8O9pm3lsg96AXIN5MXX4pECEuExh/A0Du3AuI= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0/go.mod h1:/sJLzHtiiZvs6C1RbxS/anSAFwZD6oC6M/kotQzOiLw= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= github.com/aws/aws-sdk-go-v2/service/sns v1.34.7 h1:OBuZE9Wt8h2imuRktu+WfjiTGrnYdCIJg8IX92aalHE= github.com/aws/aws-sdk-go-v2/service/sns v1.34.7/go.mod h1:4WYoZAhHt+dWYpoOQUgkUKfuQbE6Gg/hW4oXE0pKS9U= github.com/aws/aws-sdk-go-v2/service/sqs v1.38.8 h1:80dpSqWMwx2dAm30Ib7J6ucz1ZHfiv5OCRwN/EnCOXQ= github.com/aws/aws-sdk-go-v2/service/sqs v1.38.8/go.mod h1:IzNt/udsXlETCdvBOL0nmyMe2t9cGmXmZgsdoZGYYhI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= From 1d0361d936d9409826eebb2987a302b94eed22eb Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 22 Dec 2025 15:50:13 -0800 Subject: [PATCH 08/66] Fix: Eliminate duplicate versioned objects in S3 list operations (#7850) * Fix: Eliminate duplicate versioned objects in S3 list operations - Move versioned directory processing outside of pagination loop to process only once - Add deduplication during .versions directory collection phase - Fix directory handling to not add directories to results in recursive mode - Directly add versioned entries to contents array instead of using callback Fixes issue where AWS S3 list operations returned duplicated versioned objects (e.g., 1000 duplicate entries from 4 unique objects). Now correctly returns only the unique logical entries without duplication. Verified with: aws s3api list-objects --endpoint-url http://localhost:8333 --bucket pm-itatiaiucu-01 Returns exactly 4 entries (ClientInfo.xml and Repository from 2 Veeam backup folders) * Refactor: Process .versions directories immediately when encountered Instead of collecting .versions directories and processing them after the pagination loop, process them immediately when encountered during traversal. Benefits: - Simpler code: removed versionedDirEntry struct and collection array - More efficient: no need to store and iterate through collected entries - Same O(V) complexity but with less memory overhead - Clearer logic: processing happens in one pass during traversal Since each .versions directory is only visited once during recursive traversal (we never traverse into them), there's no need for deferred processing or deduplication. * Add comprehensive tests for versioned objects list - TestListObjectsWithVersionedObjects: Tests listing with various delimiters - TestVersionedObjectsNoDuplication: Core test validating no 250x duplication - TestVersionedObjectsWithDeleteMarker: Tests delete marker filtering - TestVersionedObjectsMaxKeys: Tests pagination with versioned objects - TestVersionsDirectoryNotTraversed: Ensures .versions never traversed - Fix existing test signature to match updated doListFilerEntries * style: Fix formatting alignment in versioned objects tests * perf: Optimize path extraction using string indexing Replace multiple strings.Split/Join calls with efficient strings.Index slicing to extract bucket-relative path from directory string. Reduces unnecessary allocations and improves performance in versioned objects listing path construction. * refactor: Address code review feedback from Gemini Code Assist 1. Fix misleading comment about versioned directory processing location. Versioned directories are processed immediately in doListFilerEntries, not deferred to ListObjectsV1Handler. 2. Simplify path extraction logic using explicit bucket path construction instead of index-based string slicing for better readability and maintainability. 3. Add clarifying comment to test callback explaining why production logic is duplicated - necessary because listFilerEntries is not easily testable with filer client injection. * fmt * refactor: Address code review feedback from Copilot - Fix misleading comment about versioned directory processing location (note that processing happens within doListFilerEntries, not at top level) - Add maxKeys validation checks in all test callbacks for consistency - Add maxKeys check before calling eachEntryFn for versioned objects - Improve test documentation to clarify testing approach and avoid apologetic tone * refactor: Address code review feedback from Gemini Code Assist - Remove redundant maxKeys check before eachEntryFn call on line 541 (the loop already checks maxKeys <= 0 at line 502, ensuring quota exists) - Fix pagination pattern consistency in all test callbacks - TestVersionedObjectsNoDuplication: Use cursor.maxKeys <= 0 check and decrement - TestVersionedObjectsWithDeleteMarker: Use cursor.maxKeys <= 0 check and decrement - TestVersionsDirectoryNotTraversed: Use cursor.maxKeys <= 0 check and decrement - Ensures consistent pagination logic across all callbacks matching production behavior * refactor: Address code review suggestions for code quality - Adjust log verbosity from V(5) to V(4) for file additions to reduce noise while maintaining useful debug output during troubleshooting - Remove unused isRecursive parameter from doListFilerEntries function signature and all call sites (not used for any logic decisions) - Consolidate redundant comments about versioned directory handling to reduce documentation duplication These changes improve code maintainability and clarity. * fmt * refactor: Add pagination test and optimize stream processing - Add comprehensive test validation to TestVersionedObjectsMaxKeys that verifies truncation is correctly set when maxKeys is exhausted with more entries available, ensuring proper pagination state - Optimize stream processing in doListFilerEntries by using 'break' instead of 'continue' when quota is exhausted (cursor.maxKeys <= 0) This avoids receiving and discarding entries from the stream when we've already reached the requested limit, improving efficiency --- weed/s3api/s3api_object_handlers_list.go | 99 ++-- weed/s3api/s3api_object_handlers_list_test.go | 2 +- ...api_object_handlers_list_versioned_test.go | 433 ++++++++++++++++++ 3 files changed, 466 insertions(+), 68 deletions(-) create mode 100644 weed/s3api/s3api_object_handlers_list_versioned_test.go diff --git a/weed/s3api/s3api_object_handlers_list.go b/weed/s3api/s3api_object_handlers_list.go index 22a671e67..5e060b008 100644 --- a/weed/s3api/s3api_object_handlers_list.go +++ b/weed/s3api/s3api_object_handlers_list.go @@ -120,7 +120,7 @@ func (s3a *S3ApiServer) ListObjectsV1Handler(w http.ResponseWriter, r *http.Requ bucket, _ := s3_constants.GetBucketAndObject(r) originalPrefix, marker, delimiter, encodingTypeUrl, maxKeys, allowUnordered, errCode := getListObjectsV1Args(r.URL.Query()) - glog.V(2).Infof("ListObjectsV1Handler bucket=%s prefix=%s", bucket, originalPrefix) + glog.V(2).Infof("ListObjectsV1Handler bucket=%s prefix=%s delimiter=%s maxKeys=%d", bucket, originalPrefix, delimiter, maxKeys) if errCode != s3err.ErrNone { s3err.WriteErrorResponse(w, r, errCode) @@ -203,7 +203,7 @@ func (s3a *S3ApiServer) listFilerEntries(bucket string, originalPrefix string, m for { empty := true - nextMarker, doErr = s3a.doListFilerEntries(client, reqDir, prefix, cursor, marker, delimiter, false, func(dir string, entry *filer_pb.Entry) { + nextMarker, doErr = s3a.doListFilerEntries(client, reqDir, prefix, cursor, marker, delimiter, false, bucket, func(dir string, entry *filer_pb.Entry) { empty = false dirName, entryName, _ := entryUrlEncode(dir, entry.Name, encodingTypeUrl) if entry.IsDirectory { @@ -307,6 +307,7 @@ func (s3a *S3ApiServer) listFilerEntries(bucket string, originalPrefix string, m } } if !delimiterFound { + glog.V(4).Infof("Adding file to contents: %s", entryName) contents = append(contents, newListEntry(entry, "", dirName, entryName, bucketPrefix, fetchOwner, false, false, s3a.iam)) cursor.maxKeys-- lastEntryWasCommonPrefix = false @@ -439,7 +440,7 @@ func toParentAndDescendants(dirAndName string) (dir, name string) { return } -func (s3a *S3ApiServer) doListFilerEntries(client filer_pb.SeaweedFilerClient, dir, prefix string, cursor *ListingCursor, marker, delimiter string, inclusiveStartFrom bool, eachEntryFn func(dir string, entry *filer_pb.Entry)) (nextMarker string, err error) { +func (s3a *S3ApiServer) doListFilerEntries(client filer_pb.SeaweedFilerClient, dir, prefix string, cursor *ListingCursor, marker, delimiter string, inclusiveStartFrom bool, bucket string, eachEntryFn func(dir string, entry *filer_pb.Entry)) (nextMarker string, err error) { // invariants // prefix and marker should be under dir, marker may contain "/" // maxKeys should be updated for each recursion @@ -453,7 +454,7 @@ func (s3a *S3ApiServer) doListFilerEntries(client filer_pb.SeaweedFilerClient, d if strings.Contains(marker, "/") { subDir, subMarker := toParentAndDescendants(marker) // println("doListFilerEntries dir", dir+"/"+subDir, "subMarker", subMarker) - subNextMarker, subErr := s3a.doListFilerEntries(client, dir+"/"+subDir, "", cursor, subMarker, delimiter, false, eachEntryFn) + subNextMarker, subErr := s3a.doListFilerEntries(client, dir+"/"+subDir, "", cursor, subMarker, delimiter, false, bucket, eachEntryFn) if subErr != nil { err = subErr return @@ -486,10 +487,6 @@ func (s3a *S3ApiServer) doListFilerEntries(client filer_pb.SeaweedFilerClient, d return } - // Track .versions directories found in this directory for later processing - // Store the full entry to avoid additional getEntry calls (N+1 query optimization) - var versionsDirs []*filer_pb.Entry - for { resp, recvErr := stream.Recv() if recvErr != nil { @@ -504,7 +501,7 @@ func (s3a *S3ApiServer) doListFilerEntries(client filer_pb.SeaweedFilerClient, d if cursor.maxKeys <= 0 { cursor.isTruncated = true - continue + break } // Set nextMarker only when we have quota to process this entry @@ -524,24 +521,42 @@ func (s3a *S3ApiServer) doListFilerEntries(client filer_pb.SeaweedFilerClient, d continue } - // Skip .versions directories in regular list operations but track them for logical object creation - // Store the full entry to avoid additional getEntry calls later + // Process .versions directories immediately to create logical versioned object entries + // These directories are never traversed (we continue here), so each is only encountered once if strings.HasSuffix(entry.Name, s3_constants.VersionsFolder) { - glog.V(4).Infof("Found .versions directory: %s", entry.Name) - versionsDirs = append(versionsDirs, entry) + // Extract object name from .versions directory name + baseObjectName := strings.TrimSuffix(entry.Name, s3_constants.VersionsFolder) + // Construct full object path relative to bucket + bucketFullPath := s3a.option.BucketsPath + "/" + bucket + bucketRelativePath := strings.TrimPrefix(dir, bucketFullPath) + bucketRelativePath = strings.TrimPrefix(bucketRelativePath, "/") + var fullObjectPath string + if bucketRelativePath == "" { + fullObjectPath = baseObjectName + } else { + fullObjectPath = bucketRelativePath + "/" + baseObjectName + } + // Use metadata from the already-fetched .versions directory entry + if latestVersionEntry, err := s3a.getLatestVersionEntryFromDirectoryEntry(bucket, fullObjectPath, entry); err == nil { + eachEntryFn(dir, latestVersionEntry) + } else if !errors.Is(err, ErrDeleteMarker) { + // Log unexpected errors (delete markers are expected) + glog.V(2).Infof("Skipping versioned object %s due to error: %v", fullObjectPath, err) + } continue } if delimiter != "/" || cursor.prefixEndsOnDelimiter { + // When delimiter is empty (recursive mode), recurse into directories but don't add them to results + // Only files and versioned objects should appear in results if cursor.prefixEndsOnDelimiter { cursor.prefixEndsOnDelimiter = false if entry.IsDirectoryKeyObject() { eachEntryFn(dir, entry) } - } else { - eachEntryFn(dir, entry) } - subNextMarker, subErr := s3a.doListFilerEntries(client, dir+"/"+entry.Name, "", cursor, "", delimiter, false, eachEntryFn) + // Recurse into subdirectory - don't add the directory itself to results + subNextMarker, subErr := s3a.doListFilerEntries(client, dir+"/"+entry.Name, "", cursor, "", delimiter, false, bucket, eachEntryFn) if subErr != nil { err = fmt.Errorf("doListFilerEntries2: %w", subErr) return @@ -564,57 +579,7 @@ func (s3a *S3ApiServer) doListFilerEntries(client filer_pb.SeaweedFilerClient, d } } - // After processing all regular entries, handle versioned objects - // Create logical entries for objects that have .versions directories - // OPTIMIZATION: Use the already-fetched .versions directory entry to avoid N+1 queries - for _, versionsDir := range versionsDirs { - if cursor.maxKeys <= 0 { - cursor.isTruncated = true - break - } - - // Update nextMarker to ensure pagination advances past this .versions directory - // This is critical to prevent infinite loops when results are truncated - nextMarker = versionsDir.Name - - // Extract object name from .versions directory name (remove .versions suffix) - baseObjectName := strings.TrimSuffix(versionsDir.Name, s3_constants.VersionsFolder) - - // Construct full object path relative to bucket - // dir is something like "/buckets/sea-test-1/Veeam/Backup/vbr/Config" - // we need to get the path relative to bucket: "Veeam/Backup/vbr/Config/Owner" - bucketPath := strings.TrimPrefix(dir, s3a.option.BucketsPath+"/") - bucketName := strings.Split(bucketPath, "/")[0] - - // Remove bucket name from path to get directory within bucket - bucketRelativePath := strings.Join(strings.Split(bucketPath, "/")[1:], "/") - - var fullObjectPath string - if bucketRelativePath == "" { - // Object is at bucket root - fullObjectPath = baseObjectName - } else { - // Object is in subdirectory - fullObjectPath = bucketRelativePath + "/" + baseObjectName - } - - glog.V(4).Infof("Processing versioned object: baseObjectName=%s, bucketRelativePath=%s, fullObjectPath=%s", - baseObjectName, bucketRelativePath, fullObjectPath) - - // OPTIMIZATION: Use metadata from the already-fetched .versions directory entry - // This avoids additional getEntry calls which cause high "find" usage - if latestVersionEntry, err := s3a.getLatestVersionEntryFromDirectoryEntry(bucketName, fullObjectPath, versionsDir); err == nil { - glog.V(4).Infof("Creating logical entry for versioned object: %s", fullObjectPath) - eachEntryFn(dir, latestVersionEntry) - } else if errors.Is(err, ErrDeleteMarker) { - // Expected: latest version is a delete marker, object should not appear in list - glog.V(4).Infof("Skipping versioned object %s: delete marker", fullObjectPath) - } else { - // Unexpected failure: missing metadata, fetch error, etc. - glog.V(3).Infof("Skipping versioned object %s due to error: %v", fullObjectPath, err) - } - } - + // Versioned directories are processed above (lines 524-546) return } diff --git a/weed/s3api/s3api_object_handlers_list_test.go b/weed/s3api/s3api_object_handlers_list_test.go index b24771e8a..37bafbbac 100644 --- a/weed/s3api/s3api_object_handlers_list_test.go +++ b/weed/s3api/s3api_object_handlers_list_test.go @@ -218,7 +218,7 @@ func TestDoListFilerEntries_BucketRootPrefixSlashDelimiterSlash_ListsDirectories cursor := &ListingCursor{maxKeys: 1000} seen := make([]string, 0) - _, err := s3a.doListFilerEntries(client, "/buckets/test-bucket", "/", cursor, "", "/", false, func(dir string, entry *filer_pb.Entry) { + _, err := s3a.doListFilerEntries(client, "/buckets/test-bucket", "/", cursor, "", "/", false, "test-bucket", func(dir string, entry *filer_pb.Entry) { if entry.IsDirectory { seen = append(seen, entry.Name) } diff --git a/weed/s3api/s3api_object_handlers_list_versioned_test.go b/weed/s3api/s3api_object_handlers_list_versioned_test.go new file mode 100644 index 000000000..8252dc4a9 --- /dev/null +++ b/weed/s3api/s3api_object_handlers_list_versioned_test.go @@ -0,0 +1,433 @@ +package s3api + +import ( + "context" + "encoding/hex" + "fmt" + "strconv" + "strings" + "testing" + "time" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/stretchr/testify/assert" + grpc "google.golang.org/grpc" +) + +// TestListObjectsWithVersionedObjects tests that versioned objects are properly listed +// This validates the fix for duplicate versioned objects issue +func TestListObjectsWithVersionedObjects(t *testing.T) { + now := time.Now().Unix() + + // Create test filer client with versioned objects + filerClient := &testFilerClient{ + entriesByDir: map[string][]*filer_pb.Entry{ + "/buckets/test-bucket": { + // Regular directory + { + Name: "folder1", + IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{ + Mtime: now, + }, + }, + // .versions directory with metadata for versioned object + { + Name: "file1.txt" + s3_constants.VersionsFolder, + IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{ + Mtime: now, + }, + Extended: map[string][]byte{ + s3_constants.ExtLatestVersionIdKey: []byte("v1-abc123"), + s3_constants.ExtLatestVersionSizeKey: []byte("1234"), + s3_constants.ExtLatestVersionMtimeKey: []byte(strconv.FormatInt(now, 10)), + s3_constants.ExtLatestVersionETagKey: []byte(fmt.Sprintf("\"%s\"", hex.EncodeToString([]byte("test-etag-1")))), + }, + }, + // Another .versions directory + { + Name: "file2.txt" + s3_constants.VersionsFolder, + IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{ + Mtime: now, + }, + Extended: map[string][]byte{ + s3_constants.ExtLatestVersionIdKey: []byte("v2-def456"), + s3_constants.ExtLatestVersionSizeKey: []byte("5678"), + s3_constants.ExtLatestVersionMtimeKey: []byte(strconv.FormatInt(now, 10)), + s3_constants.ExtLatestVersionETagKey: []byte(fmt.Sprintf("\"%s\"", hex.EncodeToString([]byte("test-etag-2")))), + }, + }, + }, + "/buckets/test-bucket/folder1": { + // Versioned object in subdirectory + { + Name: "nested.txt" + s3_constants.VersionsFolder, + IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{ + Mtime: now, + }, + Extended: map[string][]byte{ + s3_constants.ExtLatestVersionIdKey: []byte("v3-ghi789"), + s3_constants.ExtLatestVersionSizeKey: []byte("9012"), + s3_constants.ExtLatestVersionMtimeKey: []byte(strconv.FormatInt(now, 10)), + s3_constants.ExtLatestVersionETagKey: []byte(fmt.Sprintf("\"%s\"", hex.EncodeToString([]byte("test-etag-3")))), + }, + }, + }, + }, + } + + s3a := &S3ApiServer{ + option: &S3ApiServerOption{ + BucketsPath: "/buckets", + }, + } + + tests := []struct { + name string + bucket string + prefix string + delimiter string + expectedCount int + expectedKeys []string + expectedPrefixes []string + }{ + { + name: "List all objects including versioned (no delimiter)", + bucket: "test-bucket", + prefix: "", + delimiter: "", + expectedCount: 3, // file1.txt, file2.txt, folder1/nested.txt + expectedKeys: []string{ + "file1.txt", + "file2.txt", + "folder1/nested.txt", + }, + expectedPrefixes: []string{}, + }, + { + name: "List bucket root with delimiter", + bucket: "test-bucket", + prefix: "", + delimiter: "/", + expectedCount: 2, // file1.txt, file2.txt (folder1/ becomes common prefix) + expectedKeys: []string{ + "file1.txt", + "file2.txt", + }, + expectedPrefixes: []string{ + "folder1/", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Directly call doListFilerEntries with a callback to exercise the versioned objects + // listing logic. The callback mirrors the production listFilerEntries behavior for + // path extraction and accumulation so that this test validates the internal listing + // implementation in isolation from the HTTP layer. + cursor := &ListingCursor{maxKeys: uint16(tt.expectedCount + 10)} + contents := []ListEntry{} + commonPrefixes := []PrefixEntry{} + bucketPrefix := fmt.Sprintf("%s/%s/", s3a.option.BucketsPath, tt.bucket) + + _, err := s3a.doListFilerEntries(filerClient, bucketPrefix[:len(bucketPrefix)-1], tt.prefix, cursor, "", tt.delimiter, false, tt.bucket, func(dir string, entry *filer_pb.Entry) { + if cursor.maxKeys <= 0 { + return + } + + if entry.IsDirectory { + if tt.delimiter == "/" { + // Extract relative path from bucket prefix + relDir := strings.TrimPrefix(dir, bucketPrefix[:len(bucketPrefix)-1]) + if relDir != "" && relDir[0] == '/' { + relDir = relDir[1:] + } + prefix := relDir + if prefix != "" { + prefix += "/" + } + prefix += entry.Name + "/" + + commonPrefixes = append(commonPrefixes, PrefixEntry{ + Prefix: prefix, + }) + cursor.maxKeys-- + } + } else { + // Extract key from dir and entry name + relDir := strings.TrimPrefix(dir, bucketPrefix[:len(bucketPrefix)-1]) + if relDir != "" && relDir[0] == '/' { + relDir = relDir[1:] + } + key := entry.Name + if relDir != "" { + key = relDir + "/" + entry.Name + } + + contents = append(contents, ListEntry{ + Key: key, + }) + cursor.maxKeys-- + } + }) + + assert.NoError(t, err, "doListFilerEntries should not return error") + assert.Equal(t, tt.expectedCount, len(contents), "Should return correct number of objects") + assert.Equal(t, len(tt.expectedPrefixes), len(commonPrefixes), "Should return correct number of common prefixes") + + // Verify keys + actualKeys := make([]string, len(contents)) + for i, entry := range contents { + actualKeys[i] = entry.Key + } + assert.ElementsMatch(t, tt.expectedKeys, actualKeys, "Should return expected keys") + + // Verify common prefixes + actualPrefixes := make([]string, len(commonPrefixes)) + for i, prefix := range commonPrefixes { + actualPrefixes[i] = prefix.Prefix + } + assert.ElementsMatch(t, tt.expectedPrefixes, actualPrefixes, "Should return expected prefixes") + + // Verify each versioned object has correct version metadata + for _, entry := range contents { + assert.NotEmpty(t, entry.Key, "Versioned object should have key") + } + }) + } +} + +// TestVersionedObjectsNoDuplication ensures that .versions directories are only processed once +// This is the core test for the bug fix - previously versioned objects were duplicated 250x +func TestVersionedObjectsNoDuplication(t *testing.T) { + now := time.Now().Unix() + + // Create a single .versions directory + filerClient := &testFilerClient{ + entriesByDir: map[string][]*filer_pb.Entry{ + "/buckets/test-bucket": { + { + Name: "test.txt" + s3_constants.VersionsFolder, + IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{ + Mtime: now, + }, + Extended: map[string][]byte{ + s3_constants.ExtLatestVersionIdKey: []byte("v1-test"), + s3_constants.ExtLatestVersionSizeKey: []byte("100"), + s3_constants.ExtLatestVersionMtimeKey: []byte(strconv.FormatInt(now, 10)), + s3_constants.ExtLatestVersionETagKey: []byte("\"test-etag\""), + }, + }, + }, + }, + } + + s3a := &S3ApiServer{ + option: &S3ApiServerOption{ + BucketsPath: "/buckets", + }, + } + + cursor := &ListingCursor{maxKeys: uint16(1000)} + contents := []ListEntry{} + _, err := s3a.doListFilerEntries(filerClient, "/buckets/test-bucket", "", cursor, "", "", false, "test-bucket", func(dir string, entry *filer_pb.Entry) { + if cursor.maxKeys <= 0 { + return + } + contents = append(contents, ListEntry{Key: entry.Name}) + cursor.maxKeys-- + }) + + assert.NoError(t, err) + assert.Equal(t, 1, len(contents), "Should return exactly 1 object (no duplicates)") + assert.Equal(t, "test.txt", contents[0].Key, "Should return correct key") +} + +// TestVersionedObjectsWithDeleteMarker tests that objects with delete markers are not listed +func TestVersionedObjectsWithDeleteMarker(t *testing.T) { + now := time.Now().Unix() + + filerClient := &testFilerClient{ + entriesByDir: map[string][]*filer_pb.Entry{ + "/buckets/test-bucket": { + // Active versioned object + { + Name: "active.txt" + s3_constants.VersionsFolder, + IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{ + Mtime: now, + }, + Extended: map[string][]byte{ + s3_constants.ExtLatestVersionIdKey: []byte("v1-active"), + s3_constants.ExtLatestVersionSizeKey: []byte("100"), + s3_constants.ExtLatestVersionMtimeKey: []byte(strconv.FormatInt(now, 10)), + s3_constants.ExtLatestVersionETagKey: []byte("\"etag-active\""), + }, + }, + // Deleted object (has delete marker) + { + Name: "deleted.txt" + s3_constants.VersionsFolder, + IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{ + Mtime: now, + }, + Extended: map[string][]byte{ + s3_constants.ExtLatestVersionIdKey: []byte("v1-deleted"), + s3_constants.ExtLatestVersionIsDeleteMarker: []byte("true"), + }, + }, + }, + }, + } + + s3a := &S3ApiServer{ + option: &S3ApiServerOption{ + BucketsPath: "/buckets", + }, + } + + cursor := &ListingCursor{maxKeys: uint16(1000)} + contents := []ListEntry{} + _, err := s3a.doListFilerEntries(filerClient, "/buckets/test-bucket", "", cursor, "", "", false, "test-bucket", func(dir string, entry *filer_pb.Entry) { + if cursor.maxKeys <= 0 { + return + } + contents = append(contents, ListEntry{Key: entry.Name}) + cursor.maxKeys-- + }) + + assert.NoError(t, err) + assert.Equal(t, 1, len(contents), "Should only return active object, not deleted") + assert.Equal(t, "active.txt", contents[0].Key, "Should return the active object") +} + +// TestVersionedObjectsMaxKeys tests pagination with versioned objects +func TestVersionedObjectsMaxKeys(t *testing.T) { + now := time.Now().Unix() + + // Create 5 versioned objects + entries := make([]*filer_pb.Entry, 5) + for i := 0; i < 5; i++ { + entries[i] = &filer_pb.Entry{ + Name: fmt.Sprintf("file%d.txt"+s3_constants.VersionsFolder, i), + IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{ + Mtime: now, + }, + Extended: map[string][]byte{ + s3_constants.ExtLatestVersionIdKey: []byte(fmt.Sprintf("v%d", i)), + s3_constants.ExtLatestVersionSizeKey: []byte("100"), + s3_constants.ExtLatestVersionMtimeKey: []byte(strconv.FormatInt(now, 10)), + s3_constants.ExtLatestVersionETagKey: []byte(fmt.Sprintf("\"etag-%d\"", i)), + }, + } + } + + filerClient := &testFilerClient{ + entriesByDir: map[string][]*filer_pb.Entry{ + "/buckets/test-bucket": entries, + }, + } + + s3a := &S3ApiServer{ + option: &S3ApiServerOption{ + BucketsPath: "/buckets", + }, + } + + cursor := &ListingCursor{maxKeys: uint16(3)} + contents := []ListEntry{} + _, err := s3a.doListFilerEntries(filerClient, "/buckets/test-bucket", "", cursor, "", "", false, "test-bucket", func(dir string, entry *filer_pb.Entry) { + if cursor.maxKeys <= 0 { + return + } + contents = append(contents, ListEntry{Key: entry.Name}) + cursor.maxKeys-- + }) + + assert.NoError(t, err) + assert.Equal(t, 3, len(contents), "Should respect maxKeys limit") + assert.True(t, cursor.isTruncated, "Should set IsTruncated when there are more results") + + // Verify truncation is properly set when maxKeys is exceeded + // (The test mock doesn't implement marker-based pagination, but we can verify + // that the cursor state is correct for actual pagination to work) + assert.True(t, cursor.isTruncated, "IsTruncated should be true when maxKeys is exhausted with more entries available") +} + +// TestVersionsDirectoryNotTraversed ensures .versions directories are never traversed +func TestVersionsDirectoryNotTraversed(t *testing.T) { + now := time.Now().Unix() + traversedDirs := make(map[string]bool) + + // Custom filer client that tracks which directories are accessed + customClient := &customTestFilerClient{ + testFilerClient: testFilerClient{ + entriesByDir: map[string][]*filer_pb.Entry{ + "/buckets/test-bucket": { + { + Name: "object.txt" + s3_constants.VersionsFolder, + IsDirectory: true, + Attributes: &filer_pb.FuseAttributes{ + Mtime: now, + }, + Extended: map[string][]byte{ + s3_constants.ExtLatestVersionIdKey: []byte("v1"), + s3_constants.ExtLatestVersionSizeKey: []byte("100"), + s3_constants.ExtLatestVersionMtimeKey: []byte(strconv.FormatInt(now, 10)), + s3_constants.ExtLatestVersionETagKey: []byte("\"etag\""), + }, + }, + }, + // This directory should NEVER be accessed + "/buckets/test-bucket/object.txt.versions": { + { + Name: "should-not-see-this", + IsDirectory: false, + }, + }, + }, + }, + traversedDirs: &traversedDirs, + } + + s3a := &S3ApiServer{ + option: &S3ApiServerOption{ + BucketsPath: "/buckets", + }, + } + + cursor := &ListingCursor{maxKeys: uint16(1000)} + contents := []ListEntry{} + _, err := s3a.doListFilerEntries(customClient, "/buckets/test-bucket", "", cursor, "", "", false, "test-bucket", func(dir string, entry *filer_pb.Entry) { + if cursor.maxKeys <= 0 { + return + } + contents = append(contents, ListEntry{Key: entry.Name}) + cursor.maxKeys-- + }) + + assert.NoError(t, err) + assert.Equal(t, 1, len(contents)) + + // Verify .versions directory was NEVER traversed + _, wasTraversed := traversedDirs["/buckets/test-bucket/object.txt.versions"] + assert.False(t, wasTraversed, ".versions directory should never be traversed") +} + +// customTestFilerClient tracks which directories are accessed +type customTestFilerClient struct { + testFilerClient + traversedDirs *map[string]bool +} + +func (c *customTestFilerClient) ListEntries(ctx context.Context, in *filer_pb.ListEntriesRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[filer_pb.ListEntriesResponse], error) { + (*c.traversedDirs)[in.Directory] = true + return c.testFilerClient.ListEntries(ctx, in, opts...) +} From aaa6de77126f82b902f5b23fc08b0d2a5e7cd7a9 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 22 Dec 2025 15:57:32 -0800 Subject: [PATCH 09/66] Increase timeout from 5m to 10m for S3 HTTPS test workflow --- .github/workflows/test-s3-over-https-using-awscli.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-s3-over-https-using-awscli.yml b/.github/workflows/test-s3-over-https-using-awscli.yml index 249d0c9e9..cf7efd7ab 100644 --- a/.github/workflows/test-s3-over-https-using-awscli.yml +++ b/.github/workflows/test-s3-over-https-using-awscli.yml @@ -18,7 +18,7 @@ defaults: jobs: awscli-tests: runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - uses: actions/checkout@v6 From a898160e396745f0a37238ba1e91df250696b916 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:58:18 -0800 Subject: [PATCH 10/66] chore(deps): bump golang.org/x/crypto from 0.45.0 to 0.46.0 (#7847) * chore(deps): bump golang.org/x/crypto from 0.45.0 to 0.46.0 Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.45.0 to 0.46.0. - [Commits](https://github.com/golang/crypto/compare/v0.45.0...v0.46.0) --- updated-dependencies: - dependency-name: golang.org/x/crypto dependency-version: 0.46.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * mod --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Chris Lu --- go.mod | 6 +++--- go.sum | 12 ++++++------ test/kafka/go.mod | 24 ++++++++++++------------ test/kafka/go.sum | 48 +++++++++++++++++++++++------------------------ 4 files changed, 45 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index 7c8b320a3..26b492530 100644 --- a/go.mod +++ b/go.mod @@ -96,12 +96,12 @@ require ( gocloud.dev v0.43.0 gocloud.dev/pubsub/natspubsub v0.43.0 gocloud.dev/pubsub/rabbitpubsub v0.43.0 - golang.org/x/crypto v0.45.0 + golang.org/x/crypto v0.46.0 golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 golang.org/x/image v0.34.0 golang.org/x/net v0.47.0 golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sys v0.38.0 + golang.org/x/sys v0.39.0 golang.org/x/text v0.32.0 // indirect golang.org/x/tools v0.39.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect @@ -446,7 +446,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/term v0.37.0 // indirect + golang.org/x/term v0.38.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect diff --git a/go.sum b/go.sum index 8a5a38ef1..ea25bbb2f 100644 --- a/go.sum +++ b/go.sum @@ -1919,8 +1919,8 @@ golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -2220,8 +2220,8 @@ golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -2239,8 +2239,8 @@ golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/test/kafka/go.mod b/test/kafka/go.mod index b8f88dedd..ad02445c2 100644 --- a/test/kafka/go.mod +++ b/test/kafka/go.mod @@ -43,24 +43,24 @@ require ( github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go v1.55.8 // indirect - github.com/aws/aws-sdk-go-v2 v1.40.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect github.com/aws/aws-sdk-go-v2/config v1.31.3 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.3 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.5 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.6 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bradenaw/juniper v0.15.3 // indirect @@ -229,14 +229,14 @@ require ( go.opentelemetry.io/otel/trace v1.38.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.45.0 // indirect + golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 // indirect golang.org/x/image v0.34.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/api v0.247.0 // indirect diff --git a/test/kafka/go.sum b/test/kafka/go.sum index ea71454a8..442fdd985 100644 --- a/test/kafka/go.sum +++ b/test/kafka/go.sum @@ -102,22 +102,22 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= -github.com/aws/aws-sdk-go-v2 v1.40.1 h1:difXb4maDZkRH0x//Qkwcfpdg1XQVXEAEs2DdXldFFc= -github.com/aws/aws-sdk-go-v2 v1.40.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= github.com/aws/aws-sdk-go-v2/config v1.31.3 h1:RIb3yr/+PZ18YYNe6MDiG/3jVoJrPmdoCARwNkMGvco= github.com/aws/aws-sdk-go-v2/config v1.31.3/go.mod h1:jjgx1n7x0FAKl6TnakqrpkHWWKcX3xfWtdnIJs5K9CE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.3 h1:01Ym72hK43hjwDeJUfi1l2oYLXBAOR8gNSZNmXmvuas= -github.com/aws/aws-sdk-go-v2/credentials v1.19.3/go.mod h1:55nWF/Sr9Zvls0bGnWkRxUdhzKqj9uRNlPvgV1vgxKc= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 h1:utxLraaifrSBkeyII9mIbVwXXWrZdlPO7FIKmyLCEcY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15/go.mod h1:hW6zjYUDQwfz3icf4g2O41PHi77u10oAzJ84iSzR/lo= +github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.4 h1:0SzCLoPRSK3qSydsaFQWugP+lOBCTPwfcBOm6222+UA= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.4/go.mod h1:JAet9FsBHjfdI+TnMBX4ModNNaQHAd3dc/Bk+cNsxeM= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 h1:Y5YXgygXwDI5P4RkteB5yF7v35neH7LfJKBG+hzIons= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15/go.mod h1:K+/1EpG42dFSY7CBj+Fruzm8PsCGWTXJ3jdeJ659oGQ= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 h1:AvltKnW9ewxX2hFmQS0FyJH93aSvJVUEFvXfU+HWtSE= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15/go.mod h1:3I4oCdZdmgrREhU74qS1dK9yZ62yumob+58AbFR4cQA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 h1:NLYTEyZmVZo0Qh183sC8nC+ydJXOOeIL/qI/sS3PdLY= @@ -126,18 +126,18 @@ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEd github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 h1:P1MU/SuhadGvg2jtviDXPEejU3jBNhoeeAlRadHzvHI= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6/go.mod h1:5KYaMG6wmVKMFBSfWoyG/zH8pWwzQFnKgpoSRlXHKdQ= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 h1:3/u/4yZOffg5jdNk1sDpOQ4Y+R6Xbh+GzpDrSZjuy3U= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15/go.mod h1:4Zkjq0FKjE78NKjabuM4tRXKFzUJWXgP0ItEZK8l7JU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 h1:wsSQ4SVz5YE1crz0Ap7VBZrV4nNqZt4CIBBT8mnwoNc= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15/go.mod h1:I7sditnFGtYMIqPRU1QoHZAUrXkGp4SczmlLwrNPlD0= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 h1:IrbE3B8O9pm3lsg96AXIN5MXX4pECEuExh/A0Du3AuI= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0/go.mod h1:/sJLzHtiiZvs6C1RbxS/anSAFwZD6oC6M/kotQzOiLw= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.6 h1:8sTTiw+9yuNXcfWeqKF2x01GqCF49CpP4Z9nKrrk/ts= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.6/go.mod h1:8WYg+Y40Sn3X2hioaaWAAIngndR8n1XFdRPPX+7QBaM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 h1:E+KqWoVsSrj1tJ6I/fjDIu5xoS2Zacuu1zT+H7KtiIk= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11/go.mod h1:qyWHz+4lvkXcr3+PoGlGHEI+3DLLiU6/GdrFfMaAhB0= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 h1:tzMkjh0yTChUqJDgGkcDdxvZDSrJ/WB6R6ymI5ehqJI= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.3/go.mod h1:T270C0R5sZNLbWUe8ueiAF42XSZxxPocTaGSgs5c/60= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -730,8 +730,8 @@ golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -890,8 +890,8 @@ golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -904,8 +904,8 @@ golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= From ce71968bad15e0947d21d3de48c50327620d6f42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:58:36 -0800 Subject: [PATCH 11/66] chore(deps): bump golang.org/x/net from 0.47.0 to 0.48.0 (#7849) * chore(deps): bump golang.org/x/net from 0.47.0 to 0.48.0 Bumps [golang.org/x/net](https://github.com/golang/net) from 0.47.0 to 0.48.0. - [Commits](https://github.com/golang/net/compare/v0.47.0...v0.48.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.48.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * mod --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Chris Lu --- go.mod | 2 +- go.sum | 4 ++-- test/kafka/go.mod | 2 +- test/kafka/go.sum | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 26b492530..58edae83a 100644 --- a/go.mod +++ b/go.mod @@ -99,7 +99,7 @@ require ( golang.org/x/crypto v0.46.0 golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 golang.org/x/image v0.34.0 - golang.org/x/net v0.47.0 + golang.org/x/net v0.48.0 golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/sys v0.39.0 golang.org/x/text v0.32.0 // indirect diff --git a/go.sum b/go.sum index ea25bbb2f..9232a08f0 100644 --- a/go.sum +++ b/go.sum @@ -2058,8 +2058,8 @@ golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= diff --git a/test/kafka/go.mod b/test/kafka/go.mod index ad02445c2..b347f6edf 100644 --- a/test/kafka/go.mod +++ b/test/kafka/go.mod @@ -232,7 +232,7 @@ require ( golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 // indirect golang.org/x/image v0.34.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect diff --git a/test/kafka/go.sum b/test/kafka/go.sum index 442fdd985..2cc8f07c1 100644 --- a/test/kafka/go.sum +++ b/test/kafka/go.sum @@ -811,8 +811,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= From 14df5d1bb59e29529742f6ad46982b09427dea37 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 22 Dec 2025 18:10:56 -0800 Subject: [PATCH 12/66] fix: improve worker reconnection robustness and prevent handleOutgoing hang (#7838) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add automatic port detection and fallback for mini command - Added port availability detection using TCP binding tests - Implemented port fallback mechanism searching for available ports - Support for both HTTP and gRPC port handling - IP-aware port checking using actual service bind address - Dual-interface verification (specific IP and wildcard 0.0.0.0) - All services (Master, Volume, Filer, S3, WebDAV, Admin) auto-reallocate to available ports - Enables multiple mini instances to run simultaneously without conflicts * fix: use actual bind IP for service health checks - Previously health checks were hardcoded to localhost (127.0.0.1) - This caused failures when services bind to actual IP (e.g., 10.21.153.8) - Now health checks use the same IP that services are binding to - Fixes Volume and other service health check failures on non-localhost IPs * refactor: improve port detection logic and remove gRPC handling duplication - findAvailablePortOnIP now returns 0 on failure instead of unavailable port Allows callers to detect when port finding fails and handle appropriately - Remove duplicate gRPC port handling from ensureAllPortsAvailableOnIP All gRPC port logic is now centralized in initializeGrpcPortsOnIP - Log final port configuration only after all ports are finalized Both HTTP and gRPC ports are now correctly initialized before logging - Add error logging when port allocation fails Makes debugging easier when ports can't be found * refactor: fix race condition and clean up port detection code - Convert parallel HTTP port checks to sequential to prevent race conditions where multiple goroutines could allocate the same available port - Remove unused 'sync' import since WaitGroup is no longer used - Add documentation to localhost wrapper functions explaining they are kept for backwards compatibility and future use - All gRPC port logic is now exclusively handled in initializeGrpcPortsOnIP eliminating any duplication in ensureAllPortsAvailableOnIP * refactor: address code review comments - constants, helper function, and cleanup - Define GrpcPortOffset constant (10000) to replace magic numbers throughout the code for better maintainability and consistency - Extract bindIp determination logic into getBindIp() helper function to eliminate code duplication between runMini and startMiniServices - Remove redundant 'calculatedPort = calculatedPort' assignment that had no effect - Update all gRPC port calculations to use GrpcPortOffset constant (lines 489, 886 and the error logging at line 501) * refactor: remove unused wrapper functions and update documentation - Remove unused localhost wrapper functions that were never called: - isPortOpen() - wrapper around isPortOpenOnIP with hardcoded 127.0.0.1 - findAvailablePort() - wrapper around findAvailablePortOnIP with hardcoded 127.0.0.1 - ensurePortAvailable() - wrapper around ensurePortAvailableOnIP with hardcoded 127.0.0.1 - ensureAllPortsAvailable() - wrapper around ensureAllPortsAvailableOnIP with hardcoded 127.0.0.1 Since this is new functionality with no backwards compatibility concerns, these wrapper functions were not needed. The comments claiming they were 'kept for future use or backwards compatibility' are no longer valid. - Update documentation to reference GrpcPortOffset constant instead of hardcoded 10000: - Update comment in ensureAllPortsAvailableOnIP to use GrpcPortOffset - Update admin.port.grpc flag help text to reference GrpcPortOffset Note: getBindIp() is actually being used and should be retained (contrary to the review comment suggesting it was unused - it's called in both runMini and startMiniServices functions) * refactor: prevent HTTP/gRPC port collisions and improve error handling - Add upfront reservation of all calculated gRPC ports before allocating HTTP ports to prevent collisions where an HTTP port allocation could use a port that will later be needed for a gRPC port calculation. Example scenario that is now prevented: - Master HTTP reallocated from 9333 to 9334 (original in use) - Filer HTTP search finds 19334 available and assigns it - Master gRPC calculated as 9334 + GrpcPortOffset = 19334 → collision! Now: reserved gRPC ports are tracked upfront and HTTP port search skips them. - Improve admin server gRPC port fallback error handling: - Change from silent V(1) verbose log to Warningf to make the error visible - Update comment to clarify this indicates a problem in the port initialization sequence - Add explanation that the fallback calculation may cause bind failure - Update ensureAllPortsAvailableOnIP comment to clarify it avoids reserved ports * fix: enforce reserved ports in HTTP allocation and improve admin gRPC fallback Critical fixes for port allocation safety: 1. Make findAvailablePortOnIP and ensurePortAvailableOnIP aware of reservedPorts: - Add reservedPorts map parameter to both functions - findAvailablePortOnIP now skips reserved ports when searching for alternatives - ensurePortAvailableOnIP passes reservedPorts through to findAvailablePortOnIP - This prevents HTTP ports from being allocated to ports reserved for gRPC 2. Update ensureAllPortsAvailableOnIP to pass reservedPorts: - Pass the reservedPorts map to ensurePortAvailableOnIP calls - Maintains the map updates (delete/add) for accuracy as ports change 3. Replace blind admin gRPC port fallback with proper availability checks: - Previous code just calculated *miniAdminOptions.port + GrpcPortOffset - New code checks both the calculated port and finds alternatives if needed - Uses the same availability checking logic as initializeGrpcPortsOnIP - Properly logs the fallback process and any port changes - Will fail gracefully if no available ports found (consistent with other services) These changes eliminate two critical vulnerabilities: - HTTP port allocation can no longer accidentally claim gRPC ports - Admin gRPC port fallback no longer blindly uses an unchecked port * fix: prevent gRPC port collisions during multi-service fallback allocation Critical fix for gRPC port allocation safety across multiple services: Problem: When multiple services need gRPC port fallback allocation in sequence (e.g., Master gRPC unavailable → finds alternative, then Filer gRPC unavailable → searches from calculated port), there was no tracking of previously allocated gRPC ports. This could allow two services to claim the same port. Scenario that is now prevented: - Master gRPC: calculated 19333 unavailable → finds 19334 → assigns 19334 - Filer gRPC: calculated 18888 unavailable → searches from 18889, might land on 19334 if consecutive ports in range are unavailable (especially with custom port configurations or in high-port-contention environments) Solution: - Add allocatedGrpcPorts map to track gRPC ports allocated within the function - Check allocatedGrpcPorts before using calculated port for each service - Pass allocatedGrpcPorts to findAvailablePortOnIP when finding fallback ports - Add allocatedGrpcPorts[port] = true after each successful allocation - This ensures no two services can allocate the same gRPC port The fix handles both: 1. Calculated gRPC ports (when grpcPort == 0) 2. Explicitly set gRPC ports (when user provides -service.port.grpc value) While default port spacing makes collision unlikely, this fix is essential for: - Custom port configurations - High-contention environments - Edge cases with many unavailable consecutive ports - Correctness and safety guarantees * feat: enforce hard-fail behavior for explicitly specified ports When users explicitly specify a port via command-line flags (e.g., -s3.port=8333), the server should fail immediately if the port is unavailable, rather than silently falling back to an alternative port. This prevents user confusion and makes misconfiguration failures obvious. Changes: - Modified ensurePortAvailableOnIP() to check if a port was explicitly passed via isFlagPassed() - If an explicit port is unavailable, return error instead of silently allocating alternative - Updated ensureAllPortsAvailableOnIP() to handle the returned error and fail startup - Modified runMini() to check error from ensureAllPortsAvailableOnIP() and return false on failure - Default ports (not explicitly specified) continue to fallback to available alternatives This ensures: - Explicit ports: fail if unavailable (e.g., -s3.port=8333 fails if 8333 is taken) - Default ports: fallback to alternatives (e.g., s3.port without flag falls back to 8334 if 8333 taken) * fix: accurate error messages for explicitly specified unavailable ports When a port is explicitly specified via CLI flags but is unavailable, the error message now correctly reports the originally requested port instead of reporting a fallback port that was calculated internally. The issue was that the config file applied after CLI flag parsing caused isFlagPassed() to return true for ports loaded from the config file (since flag.Visit() was called during config file application), incorrectly marking them as explicitly specified. Solution: Capture which port flags were explicitly passed on the CLI BEFORE the config file is applied, storing them in the explicitPortFlags map. This preserves the accurate distinction between user-specified ports and defaults/config-file ports. Example: - User runs: weed mini -dir=. -s3.port=22 - Now correctly shows: 'port 22 for S3 (specified by flag s3.port) is not available' - Previously incorrectly showed: 'port 8334 for S3...' (some calculated fallback) * fix: respect explicitly specified ports and prevent config file override When a port is explicitly specified via CLI flags (e.g., -s3.port=8333), the config file options should NOT override it. Previously, config file options would be applied if the flag value differed from default, but this check wasn't sufficient to prevent override in all cases. Solution: Check the explicitPortFlags map before applying any config file port options. If a port was explicitly passed on the CLI, skip applying the config file option for that port. This ensures: - Explicit ports take absolute precedence over config file ports - Config file ports are only used if port wasn't specified on CLI - Example: 'weed mini -s3.port=8333' will use 8333, never the config file value * fix: don't print usage on port allocation error When a port allocation fails (e.g., explicit port is unavailable), exit immediately without showing the usage example. This provides cleaner error output when the error is expected (port conflict). * refactor: clean up code quality issues Remove no-op assignment (calculatedPort = calculatedPort) that had no effect. The variable already holds the correct value when no alternative port is found. Improve documentation for the defensive gRPC port initialization fallback in startAdminServer. While this code shouldn't execute in normal flow because ensureAllPortsAvailableOnIP is called earlier in runMini, the fallback handles edge cases where port initialization may have been skipped or failed silently due to configuration changes or error handling paths. * fix: improve worker reconnection robustness and prevent handleOutgoing hang - Add dedicated streamFailed signaling channel to abort registration waits early when stream dies - Add per-connection regWait channel to route RegistrationResponse separately from shared incoming channel, avoiding race where other consumers steal the response - Refactor handleOutgoing() loop to use select on streamExit/errCh, ensuring old handlers exit cleanly on reconnect (prevents stale senders competing with new stream) - Buffer msgCh to reduce shutdown edge cases - Add cleanup of streamFailed and regWait channels on reconnect/disconnect - Fixes registration timeout and potential stream lifecycle hangs on aggressive server max_age recycling * fix: prevent deadlock when stream error occurs - make cmds send non-blocking If managerLoop is blocked (e.g., waiting on regWait), a blocking send to cmds will deadlock handleIncoming. Make the send non-blocking to prevent this. * fix: address code review comments on mini.go port allocation - Remove flawed fallback gRPC port initialization and convert to fatal error (ensures port initialization issues are caught immediately instead of silently failing with an empty reserved ports map) - Extract common port validation logic to eliminate duplication between calculated and explicitly set gRPC port handling * Fix critical race condition and improve error handling in worker client - Capture channel pointers before checking for nil (prevents TOCTOU race with reconnect) - Use async fallback goroutine for cmds send to prevent error loss when manager is busy - Consistently close regWait channel on disconnect (matches streamFailed behavior) - Complete cleanup of channels on failed registration - Improve error messages for clarity (replace 'timeout' with 'failed' where appropriate) * Add debug logging for registration response routing Add glog.V(3) and glog.V(2) logs to track successful and dropped registration responses in handleIncoming, helping diagnose registration issues in production. * Update weed/worker/client.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Ensure stream errors are never lost by using async fallback When handleIncoming detects a stream error, queue ActionStreamError to managerLoop with non-blocking send. If managerLoop is busy and cmds channel is full, spawn an async goroutine to queue the error asynchronously. This ensures the manager is always notified of stream failures, preventing the connection from remaining in an inconsistent state (connected=true while stream is dead). * Refactor handleOutgoing to eliminate duplicate error handling code Extract error handling and cleanup logic into helper functions to avoid duplication in nested select statements. This improves maintainability and reduces the risk of inconsistencies when updating error handling logic. * Prevent goroutine leaks by adding timeouts to blocking cmds sends Add 2-second timeouts to both handleStreamError and the async fallback goroutine when sending ActionStreamError to cmds channel. This prevents the handleOutgoing and handleIncoming goroutines from blocking indefinitely if the managerLoop is no longer receiving (e.g., during shutdown), preventing resource leaks. * Properly close regWait channel in reconnect to prevent resource leaks Close the regWait channel before setting it to nil in reconnect(), matching the pattern used in handleDisconnect(). This ensures any goroutines waiting on this channel during reconnection are properly signaled, preventing them from hanging. * Use non-blocking async pattern in handleOutgoing error reporting Refactor handleStreamError to use non-blocking send with async fallback goroutine, matching the pattern used in handleIncoming. This allows handleOutgoing to exit immediately when errors occur rather than blocking for up to 2 seconds, improving responsiveness and consistency across handlers. * fix: drain regWait channel before closing to prevent message loss - Add drain loop before closing regWait in reconnect() cleanup - Add drain loop before closing regWait in handleDisconnect() cleanup - Ensures no pending RegistrationResponse messages are lost during channel closure * docs: add comments explaining regWait buffered channel design - Document that regWait buffer size 1 prevents race conditions - Explain non-blocking send pattern between sendRegistration and handleIncoming - Clarify timing of registration response handling in handleIncoming * fix: improve error messages and channel handling in sendRegistration - Clarify error message when stream fails before registration sent - Use two-value receive form to properly detect closed channels - Better distinguish between closed channel and nil value scenarios * refactor: extract drain and close channel logic into helper function - Create drainAndCloseRegWaitChannel() helper to eliminate code duplication - Replace 3 copies of drain-and-close logic with single function call - Improves maintainability and consistency across cleanup paths --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- weed/command/mini.go | 69 +++++------------ weed/worker/client.go | 173 +++++++++++++++++++++++++++++++++++------- 2 files changed, 163 insertions(+), 79 deletions(-) diff --git a/weed/command/mini.go b/weed/command/mini.go index fc359f904..d52dc1c21 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -517,39 +517,26 @@ func initializeGrpcPortsOnIP(bindIp string) { continue } - // If gRPC port is 0, calculate it + // If gRPC port is 0, calculate it from HTTP port if *config.grpcPort == 0 { - calculatedPort := *config.httpPort + GrpcPortOffset - // Check if calculated port is available (on both specific IP and all interfaces) - // Also check if it was already allocated to another service in this function - if !isPortOpenOnIP(bindIp, calculatedPort) || !isPortAvailable(calculatedPort) || allocatedGrpcPorts[calculatedPort] { - glog.Warningf("Calculated gRPC port %d for %s is not available, finding alternative...", calculatedPort, config.name) - newPort := findAvailablePortOnIP(bindIp, calculatedPort+1, 100, allocatedGrpcPorts) - if newPort == 0 { - glog.Errorf("Could not find available gRPC port for %s starting from %d, will use calculated %d and fail on binding", config.name, calculatedPort+1, calculatedPort) - } else { - calculatedPort = newPort - glog.Infof("gRPC port %d for %s is available, using it instead of calculated %d", newPort, config.name, *config.httpPort+GrpcPortOffset) - } - } - *config.grpcPort = calculatedPort - allocatedGrpcPorts[calculatedPort] = true - glog.V(1).Infof("%s gRPC port initialized to %d", config.name, calculatedPort) - } else { - // gRPC port was explicitly set, verify it's still available (check on both specific IP and all interfaces) - // Also check if it was already allocated to another service in this function - if !isPortOpenOnIP(bindIp, *config.grpcPort) || !isPortAvailable(*config.grpcPort) || allocatedGrpcPorts[*config.grpcPort] { - glog.Warningf("Explicitly set gRPC port %d for %s is not available, finding alternative...", *config.grpcPort, config.name) - newPort := findAvailablePortOnIP(bindIp, *config.grpcPort+1, 100, allocatedGrpcPorts) - if newPort == 0 { - glog.Errorf("Could not find available gRPC port for %s starting from %d, will use original %d and fail on binding", config.name, *config.grpcPort+1, *config.grpcPort) - } else { - glog.Infof("gRPC port %d for %s is available, using it instead of %d", newPort, config.name, *config.grpcPort) - *config.grpcPort = newPort - } - } - allocatedGrpcPorts[*config.grpcPort] = true + *config.grpcPort = *config.httpPort + GrpcPortOffset } + + // Verify the gRPC port is available (whether calculated or explicitly set) + // Check on both specific IP and all interfaces, and check against already allocated ports + if !isPortOpenOnIP(bindIp, *config.grpcPort) || !isPortAvailable(*config.grpcPort) || allocatedGrpcPorts[*config.grpcPort] { + glog.Warningf("gRPC port %d for %s is not available, finding alternative...", *config.grpcPort, config.name) + originalPort := *config.grpcPort + newPort := findAvailablePortOnIP(bindIp, originalPort+1, 100, allocatedGrpcPorts) + if newPort == 0 { + glog.Errorf("Could not find available gRPC port for %s starting from %d, will use %d and fail on binding", config.name, originalPort+1, originalPort) + } else { + glog.Infof("gRPC port %d for %s is available, using it instead of %d", newPort, config.name, originalPort) + *config.grpcPort = newPort + } + } + allocatedGrpcPorts[*config.grpcPort] = true + glog.V(1).Infof("%s gRPC port set to %d", config.name, *config.grpcPort) } } @@ -934,26 +921,8 @@ func startMiniAdminWithWorker(allServicesReady chan struct{}) { // gRPC port should have been initialized by ensureAllPortsAvailableOnIP in runMini // If it's still 0, that indicates a problem with the port initialization sequence - // This defensive fallback handles edge cases where port initialization may have been skipped - // or failed silently (e.g., due to configuration changes or error handling paths) if *miniAdminOptions.grpcPort == 0 { - glog.Warningf("Admin gRPC port was not initialized before startAdminServer, attempting fallback initialization...") - // Use the same availability checking logic as initializeGrpcPortsOnIP - calculatedPort := *miniAdminOptions.port + GrpcPortOffset - if !isPortOpenOnIP(getBindIp(), calculatedPort) || !isPortAvailable(calculatedPort) { - glog.Warningf("Calculated fallback gRPC port %d is not available, finding alternative...", calculatedPort) - newPort := findAvailablePortOnIP(getBindIp(), calculatedPort+1, 100, make(map[int]bool)) - if newPort == 0 { - glog.Errorf("Could not find available gRPC port for Admin starting from %d, will use calculated %d and fail on binding", calculatedPort+1, calculatedPort) - *miniAdminOptions.grpcPort = calculatedPort - } else { - glog.Infof("Fallback: using gRPC port %d for Admin", newPort) - *miniAdminOptions.grpcPort = newPort - } - } else { - *miniAdminOptions.grpcPort = calculatedPort - glog.Infof("Fallback: Admin gRPC port initialized to %d", calculatedPort) - } + glog.Fatalf("Admin gRPC port was not initialized before startAdminServer. This indicates a problem with the port initialization sequence.") } // Create data directory if specified diff --git a/weed/worker/client.go b/weed/worker/client.go index d562b8703..f4d15e155 100644 --- a/weed/worker/client.go +++ b/weed/worker/client.go @@ -74,6 +74,8 @@ type grpcState struct { lastWorkerInfo *types.WorkerData reconnectStop chan struct{} streamExit chan struct{} + streamFailed chan struct{} // Signals when stream has failed + regWait chan *worker_pb.RegistrationResponse } // NewGrpcAdminClient creates a new gRPC admin client @@ -98,6 +100,25 @@ func NewGrpcAdminClient(adminAddress string, workerID string, dialOption grpc.Di return c } +// drainAndCloseRegWaitChannel drains any pending messages from the regWait channel +// and then safely closes it. This prevents losing RegistrationResponse messages +// that were sent before the channel is closed. +func drainAndCloseRegWaitChannel(ch *chan *worker_pb.RegistrationResponse) { + if ch == nil || *ch == nil { + return + } + for { + select { + case <-*ch: + // continue draining until channel is empty + default: + close(*ch) + *ch = nil + return + } + } +} + // safeCloseChannel safely closes a channel and sets it to nil to prevent double-close panics. // NOTE: This function is NOT thread-safe. It is safe to use in this codebase because all calls // are serialized within the managerLoop goroutine. If this function is used in concurrent contexts @@ -140,7 +161,14 @@ out: req.Resp <- nil continue } - err := c.sendRegistration(req.Worker) + // Capture channel pointers to avoid race condition with reconnect + streamFailedCh := state.streamFailed + regWaitCh := state.regWait + if streamFailedCh == nil || regWaitCh == nil { + req.Resp <- fmt.Errorf("stream not ready for registration") + continue + } + err := c.sendRegistration(req.Worker, streamFailedCh, regWaitCh) req.Resp <- err case ActionQueryConnected: respCh := cmd.data.(chan bool) @@ -225,14 +253,18 @@ func (c *GrpcAdminClient) attemptConnection(s *grpcState) error { // Start stream handlers BEFORE sending registration // This ensures handleIncoming is ready to receive the registration response s.streamExit = make(chan struct{}) + s.streamFailed = make(chan struct{}) + s.regWait = make(chan *worker_pb.RegistrationResponse, 1) go handleOutgoing(s.stream, s.streamExit, c.outgoing, c.cmds) - go handleIncoming(c.workerID, s.stream, s.streamExit, c.incoming, c.cmds) + go handleIncoming(c.workerID, s.stream, s.streamExit, c.incoming, c.cmds, s.streamFailed, s.regWait) // Always check for worker info and send registration immediately as the very first message if s.lastWorkerInfo != nil { // Send registration via the normal outgoing channel and wait for response via incoming - if err := c.sendRegistration(s.lastWorkerInfo); err != nil { + if err := c.sendRegistration(s.lastWorkerInfo, s.streamFailed, s.regWait); err != nil { c.safeCloseChannel(&s.streamExit) + c.safeCloseChannel(&s.streamFailed) + drainAndCloseRegWaitChannel(&s.regWait) s.streamCancel() s.conn.Close() s.connected = false @@ -252,6 +284,8 @@ func (c *GrpcAdminClient) attemptConnection(s *grpcState) error { func (c *GrpcAdminClient) reconnect(s *grpcState) error { // Clean up existing connection completely c.safeCloseChannel(&s.streamExit) + c.safeCloseChannel(&s.streamFailed) + drainAndCloseRegWaitChannel(&s.regWait) if s.streamCancel != nil { s.streamCancel() } @@ -324,32 +358,70 @@ func handleOutgoing( streamExit <-chan struct{}, outgoing <-chan *worker_pb.WorkerMessage, cmds chan<- grpcCommand) { - - msgCh := make(chan *worker_pb.WorkerMessage) + msgCh := make(chan *worker_pb.WorkerMessage, 1) errCh := make(chan error, 1) // Buffered to prevent blocking if the manager is busy - // Goroutine to handle blocking stream.Recv() and simultaneously handle exit - // signals + + // Goroutine that reads from msgCh and performs the blocking stream.Send() calls. go func() { for msg := range msgCh { if err := stream.Send(msg); err != nil { errCh <- err - return // Exit the receiver goroutine on error/EOF + return } } close(errCh) }() - for msg := range outgoing { - select { - case msgCh <- msg: - case err := <-errCh: + // Helper function to handle stream errors and cleanup + handleStreamError := func(err error) { + if err != nil { glog.Errorf("Failed to send message to admin: %v", err) - cmds <- grpcCommand{action: ActionStreamError, data: err} - return + select { + case cmds <- grpcCommand{action: ActionStreamError, data: err}: + // Successfully queued + default: + // Manager busy, queue asynchronously to avoid blocking + glog.V(2).Infof("Manager busy, queuing stream error asynchronously from outgoing handler: %v", err) + go func(e error) { + select { + case cmds <- grpcCommand{action: ActionStreamError, data: e}: + case <-time.After(2 * time.Second): + glog.Warningf("Failed to send stream error to manager from outgoing handler, channel blocked: %v", e) + } + }(err) + } + } + } + + // Helper function to cleanup resources + cleanup := func() { + close(msgCh) + <-errCh + } + + for { + select { case <-streamExit: - close(msgCh) - <-errCh + cleanup() return + case err := <-errCh: + handleStreamError(err) + return + case msg, ok := <-outgoing: + if !ok { + cleanup() + return + } + select { + case msgCh <- msg: + // Message queued successfully + case <-streamExit: + cleanup() + return + case err := <-errCh: + handleStreamError(err) + return + } } } } @@ -360,10 +432,15 @@ func handleIncoming( stream worker_pb.WorkerService_WorkerStreamClient, streamExit <-chan struct{}, incoming chan<- *worker_pb.AdminMessage, - cmds chan<- grpcCommand) { + cmds chan<- grpcCommand, + streamFailed chan<- struct{}, + regWait chan<- *worker_pb.RegistrationResponse) { glog.V(1).Infof("INCOMING HANDLER STARTED: Worker %s incoming message handler started", workerID) msgCh := make(chan *worker_pb.AdminMessage) errCh := make(chan error, 1) // Buffered to prevent blocking if the manager is busy + // regWait is buffered with size 1 so that the registration response can be sent + // even if the receiver goroutine has not yet started waiting on the channel. + // This non-blocking send pattern avoids a race between sendRegistration and handleIncoming. // Goroutine to handle blocking stream.Recv() and simultaneously handle exit // signals go func() { @@ -385,7 +462,19 @@ func handleIncoming( // Message successfully received from the stream glog.V(4).Infof("MESSAGE RECEIVED: Worker %s received message from admin server: %T", workerID, msg.Message) - // Route message to waiting goroutines or general handler (original select logic) + // If this is a registration response, also publish to the registration waiter. + // regWait is buffered (size 1) so that the response can be sent even if sendRegistration + // hasn't started waiting yet, preventing a race condition between the two goroutines. + if rr := msg.GetRegistrationResponse(); rr != nil { + select { + case regWait <- rr: + glog.V(3).Infof("REGISTRATION RESPONSE: Worker %s routed registration response to waiter", workerID) + default: + glog.V(2).Infof("REGISTRATION RESPONSE DROPPED: Worker %s registration response dropped (no waiter)", workerID) + } + } + + // Route message to general handler. select { case incoming <- msg: glog.V(3).Infof("MESSAGE ROUTED: Worker %s successfully routed message to handler", workerID) @@ -401,8 +490,27 @@ func handleIncoming( glog.Errorf("RECEIVE ERROR: Worker %s failed to receive message from admin: %v", workerID, err) } - // Report the failure as a command to the managerLoop (blocking) - cmds <- grpcCommand{action: ActionStreamError, data: err} + // Signal that stream has failed (non-blocking) + select { + case streamFailed <- struct{}{}: + default: + } + + // Report the failure as a command to the managerLoop. + // Try non-blocking first; if the manager is busy and the channel is full, + // fall back to an asynchronous blocking send so the error is not lost. + select { + case cmds <- grpcCommand{action: ActionStreamError, data: err}: + default: + glog.V(2).Infof("Manager busy, queuing stream error asynchronously: %v", err) + go func(e error) { + select { + case cmds <- grpcCommand{action: ActionStreamError, data: e}: + case <-time.After(2 * time.Second): + glog.Warningf("Failed to send stream error to manager, channel blocked: %v", e) + } + }(err) + } // Exit the main handler loop glog.V(1).Infof("INCOMING HANDLER STOPPED: Worker %s stopping incoming handler due to stream error", workerID) @@ -460,6 +568,8 @@ func (c *GrpcAdminClient) handleDisconnect(cmd grpcCommand, s *grpcState) { // Send shutdown signal to stop handlers loop c.safeCloseChannel(&s.streamExit) + c.safeCloseChannel(&s.streamFailed) + drainAndCloseRegWaitChannel(&s.regWait) // Cancel stream context if s.streamCancel != nil { @@ -495,7 +605,7 @@ func (c *GrpcAdminClient) RegisterWorker(worker *types.WorkerData) error { } // sendRegistration sends the registration message and waits for response -func (c *GrpcAdminClient) sendRegistration(worker *types.WorkerData) error { +func (c *GrpcAdminClient) sendRegistration(worker *types.WorkerData, streamFailed <-chan struct{}, regWait <-chan *worker_pb.RegistrationResponse) error { capabilities := make([]string, len(worker.Capabilities)) for i, cap := range worker.Capabilities { capabilities[i] = string(cap) @@ -519,6 +629,8 @@ func (c *GrpcAdminClient) sendRegistration(worker *types.WorkerData) error { case c.outgoing <- msg: case <-time.After(5 * time.Second): return fmt.Errorf("failed to send registration message: timeout") + case <-streamFailed: + return fmt.Errorf("stream failed before registration message could be sent") } // Wait for registration response @@ -528,16 +640,19 @@ func (c *GrpcAdminClient) sendRegistration(worker *types.WorkerData) error { for { select { - case response := <-c.incoming: - if regResp := response.GetRegistrationResponse(); regResp != nil { - if regResp.Success { - glog.Infof("Worker registered successfully: %s", regResp.Message) - return nil - } - return fmt.Errorf("registration failed: %s", regResp.Message) + case regResp, ok := <-regWait: + if !ok || regResp == nil { + return fmt.Errorf("registration failed: channel closed unexpectedly") } + if regResp.Success { + glog.Infof("Worker registered successfully: %s", regResp.Message) + return nil + } + return fmt.Errorf("registration failed: %s", regResp.Message) + case <-streamFailed: + return fmt.Errorf("registration failed: stream closed by server") case <-timeout.C: - return fmt.Errorf("registration timeout") + return fmt.Errorf("registration failed: timeout waiting for response") } } } From 2567be8040f085b9e01fbda32cc591c3409b79a4 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 22 Dec 2025 18:25:21 -0800 Subject: [PATCH 13/66] refactor: remove unused gRPC connection age parameters (#7852) The GrpcMaxConnectionAge and GrpcMaxConnectionAgeGrace constants have a troubled history - they were removed in 2022 due to gRPC issues, reverted later, and recently re-added. However, they are not essential to the core worker reconnection fix which was solved through proper goroutine ordering. The Docker Swarm DNS handling mentioned in the comments is not critical, and these parameters have caused problems in the past. Removing them simplifies the configuration without losing functionality. --- weed/pb/grpc_client_server.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/weed/pb/grpc_client_server.go b/weed/pb/grpc_client_server.go index ebd2220df..4a869bb95 100644 --- a/weed/pb/grpc_client_server.go +++ b/weed/pb/grpc_client_server.go @@ -35,11 +35,6 @@ const ( // gRPC keepalive settings - must be consistent between client and server GrpcKeepAliveTime = 60 * time.Second // ping interval when no activity GrpcKeepAliveTimeout = 20 * time.Second // ping timeout - - // Connection recycling for Docker Swarm environments - // Forces connections to be recycled periodically to handle DNS changes - GrpcMaxConnectionAge = 5 * time.Minute // max time a connection may exist - GrpcMaxConnectionAgeGrace = 30 * time.Second // grace period for RPCs to complete ) var ( @@ -63,10 +58,8 @@ func NewGrpcServer(opts ...grpc.ServerOption) *grpc.Server { var options []grpc.ServerOption options = append(options, grpc.KeepaliveParams(keepalive.ServerParameters{ - Time: GrpcKeepAliveTime, // server pings client if no activity for this long - Timeout: GrpcKeepAliveTimeout, // ping timeout - MaxConnectionAge: GrpcMaxConnectionAge, // max connection age for Docker Swarm DNS refresh - MaxConnectionAgeGrace: GrpcMaxConnectionAgeGrace, // grace period for in-flight RPCs + Time: GrpcKeepAliveTime, // server pings client if no activity for this long + Timeout: GrpcKeepAliveTimeout, // ping timeout }), grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ MinTime: GrpcKeepAliveTime, // min time a client should wait before sending a ping From 683e3d06a44bc22e6c6ef0ca404373c558e62acf Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 22 Dec 2025 18:48:13 -0800 Subject: [PATCH 14/66] go mod tidy --- .github/workflows/kafka-tests.yml | 7 +++++++ test/kafka/go.mod | 9 +++++---- test/kafka/go.sum | 18 ++++++++++-------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.github/workflows/kafka-tests.yml b/.github/workflows/kafka-tests.yml index 7da13a443..1b4bed572 100644 --- a/.github/workflows/kafka-tests.yml +++ b/.github/workflows/kafka-tests.yml @@ -52,6 +52,7 @@ jobs: run: | cd test/kafka go mod download + go mod tidy - name: Run Kafka Gateway Unit Tests run: | @@ -96,6 +97,7 @@ jobs: run: | cd test/kafka go mod download + go mod tidy - name: Run Integration Tests run: | @@ -154,6 +156,7 @@ jobs: cd test/kafka # Use go mod download with timeout to prevent hanging timeout 90s go mod download || echo "Warning: Dependency download timed out, continuing with cached modules" + go mod tidy - name: Build and start SeaweedFS MQ run: | @@ -332,6 +335,7 @@ jobs: cd test/kafka # Use go mod download with timeout to prevent hanging timeout 90s go mod download || echo "Warning: Dependency download timed out, continuing with cached modules" + go mod tidy - name: Build and start SeaweedFS MQ run: | @@ -492,6 +496,7 @@ jobs: run: | cd test/kafka timeout 90s go mod download || echo "Warning: Dependency download timed out, continuing with cached modules" + go mod tidy - name: Build and start SeaweedFS MQ run: | @@ -649,6 +654,7 @@ jobs: run: | cd test/kafka timeout 90s go mod download || echo "Warning: Dependency download timed out, continuing with cached modules" + go mod tidy - name: Build and start SeaweedFS MQ run: | @@ -803,6 +809,7 @@ jobs: run: | cd test/kafka go mod download + go mod tidy - name: Run Protocol Tests run: | diff --git a/test/kafka/go.mod b/test/kafka/go.mod index b347f6edf..900e00518 100644 --- a/test/kafka/go.mod +++ b/test/kafka/go.mod @@ -45,20 +45,21 @@ require ( github.com/aws/aws-sdk-go v1.55.8 // indirect github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect - github.com/aws/aws-sdk-go-v2/config v1.31.3 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.5 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.6 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.4 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect github.com/aws/smithy-go v1.24.0 // indirect diff --git a/test/kafka/go.sum b/test/kafka/go.sum index 2cc8f07c1..b56fc1d8c 100644 --- a/test/kafka/go.sum +++ b/test/kafka/go.sum @@ -106,10 +106,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgP github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= -github.com/aws/aws-sdk-go-v2/config v1.31.3 h1:RIb3yr/+PZ18YYNe6MDiG/3jVoJrPmdoCARwNkMGvco= -github.com/aws/aws-sdk-go-v2/config v1.31.3/go.mod h1:jjgx1n7x0FAKl6TnakqrpkHWWKcX3xfWtdnIJs5K9CE= -github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4= -github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk= +github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= +github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.4 h1:0SzCLoPRSK3qSydsaFQWugP+lOBCTPwfcBOm6222+UA= @@ -118,8 +118,8 @@ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMH github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 h1:NLYTEyZmVZo0Qh183sC8nC+ydJXOOeIL/qI/sS3PdLY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15/go.mod h1:Z803iB3B0bc8oJV8zH2PERLRfQUJ2n2BXISpsA4+O1M= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= @@ -132,8 +132,10 @@ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 h1:wsSQ4SVz5YE1c github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15/go.mod h1:I7sditnFGtYMIqPRU1QoHZAUrXkGp4SczmlLwrNPlD0= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 h1:IrbE3B8O9pm3lsg96AXIN5MXX4pECEuExh/A0Du3AuI= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0/go.mod h1:/sJLzHtiiZvs6C1RbxS/anSAFwZD6oC6M/kotQzOiLw= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= From 289ec5e2f5158e03fdbec7d29ee678fcb9c99ff5 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 22 Dec 2025 23:19:50 -0800 Subject: [PATCH 15/66] Fix SeaweedFS S3 bucket extended attributes handling (#7854) * refactor: Convert versioning to three-state string model matching AWS S3 - Change VersioningEnabled bool to VersioningStatus string in S3Bucket struct - Add GetVersioningStatus() function returning empty string (never enabled), 'Enabled', or 'Suspended' - Update StoreVersioningInExtended() to delete key instead of setting 'Suspended' - Ensures Admin UI and S3 API use consistent versioning state representation * fix: Add validation for bucket quota and Object Lock configuration - Prevent buckets with quota enabled but size=0 (validation check) - Fix Object Lock mode handling to only pass mode when setDefaultRetention is true - Ensures proper extended attribute storage for Object Lock configuration - Matches AWS S3 behavior for Object Lock setup * feat: Handle versioned objects in bucket details view - Recognize .versions directories as versioned objects in listBucketObjects() - Extract size and mtime from extended attribute metadata (ExtLatestVersionSizeKey, ExtLatestVersionMtimeKey) - Add length validation (8 bytes) before parsing extended attribute byte arrays - Update GetBucketDetails() and GetS3Buckets() to use new GetVersioningStatus() - Properly display versioned objects without .versions suffix in bucket details * ui: Update bucket management UI to show three-state versioning and Object Lock - Change versioning display from binary (Enabled/Disabled) to three-state (Not configured/Enabled/Suspended) - Update Object Lock display to show 'Not configured' instead of 'Disabled' - Fix bucket details modal to use bucket.versioning_status instead of bucket.versioning_enabled - Update displayBucketDetails() JavaScript to handle three versioning states * chore: Regenerate template code for bucket UI changes - Generated from updated s3_buckets.templ - Reflects three-state versioning and Object Lock UI improvements --- weed/admin/dash/admin_server.go | 57 +++++++++++++++++++++---- weed/admin/dash/bucket_management.go | 11 ++++- weed/admin/dash/types.go | 2 +- weed/admin/view/app/s3_buckets.templ | 26 ++++++----- weed/admin/view/app/s3_buckets_templ.go | 47 +++++++++++--------- weed/s3api/object_lock_utils.go | 17 +++++++- 6 files changed, 116 insertions(+), 44 deletions(-) diff --git a/weed/admin/dash/admin_server.go b/weed/admin/dash/admin_server.go index 549a431bd..610f2288f 100644 --- a/weed/admin/dash/admin_server.go +++ b/weed/admin/dash/admin_server.go @@ -7,6 +7,7 @@ import ( "net/http" "sort" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -340,7 +341,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) { } // Get versioning, object lock, and owner information from extended attributes - versioningEnabled := false + versioningStatus := "" objectLockEnabled := false objectLockMode := "" var objectLockDuration int32 = 0 @@ -348,7 +349,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) { if resp.Entry.Extended != nil { // Use shared utility to extract versioning information - versioningEnabled = extractVersioningFromEntry(resp.Entry) + versioningStatus = extractVersioningFromEntry(resp.Entry) // Use shared utility to extract Object Lock information objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(resp.Entry) @@ -367,7 +368,7 @@ func (s *AdminServer) GetS3Buckets() ([]S3Bucket, error) { LastModified: time.Unix(resp.Entry.Attributes.Mtime, 0), Quota: quota, QuotaEnabled: quotaEnabled, - VersioningEnabled: versioningEnabled, + VersioningStatus: versioningStatus, ObjectLockEnabled: objectLockEnabled, ObjectLockMode: objectLockMode, ObjectLockDuration: objectLockDuration, @@ -430,7 +431,7 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error details.Bucket.QuotaEnabled = quotaEnabled // Get versioning, object lock, and owner information from extended attributes - versioningEnabled := false + versioningStatus := "" objectLockEnabled := false objectLockMode := "" var objectLockDuration int32 = 0 @@ -438,7 +439,7 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error if bucketResp.Entry.Extended != nil { // Use shared utility to extract versioning information - versioningEnabled = extractVersioningFromEntry(bucketResp.Entry) + versioningStatus = extractVersioningFromEntry(bucketResp.Entry) // Use shared utility to extract Object Lock information objectLockEnabled, objectLockMode, objectLockDuration = extractObjectLockInfoFromEntry(bucketResp.Entry) @@ -449,7 +450,7 @@ func (s *AdminServer) GetBucketDetails(bucketName string) (*BucketDetails, error } } - details.Bucket.VersioningEnabled = versioningEnabled + details.Bucket.VersioningStatus = versioningStatus details.Bucket.ObjectLockEnabled = objectLockEnabled details.Bucket.ObjectLockMode = objectLockMode details.Bucket.ObjectLockDuration = objectLockDuration @@ -491,6 +492,45 @@ func (s *AdminServer) listBucketObjects(client filer_pb.SeaweedFilerClient, buck entry := resp.Entry if entry.IsDirectory { + // Check if this is a .versions directory (represents a versioned object) + if strings.HasSuffix(entry.Name, ".versions") { + // This directory represents an object, add it as an object without the .versions suffix + objectName := strings.TrimSuffix(entry.Name, ".versions") + objectKey := objectName + if directory != bucketBasePath { + relativePath := directory[len(bucketBasePath)+1:] + objectKey = fmt.Sprintf("%s/%s", relativePath, objectName) + } + + // Extract latest version metadata from extended attributes + var size int64 = 0 + var mtime int64 = entry.Attributes.Mtime + if entry.Extended != nil { + // Get size of latest version + if sizeBytes, ok := entry.Extended[s3_constants.ExtLatestVersionSizeKey]; ok && len(sizeBytes) == 8 { + size = int64(util.BytesToUint64(sizeBytes)) + } + // Get mtime of latest version + if mtimeBytes, ok := entry.Extended[s3_constants.ExtLatestVersionMtimeKey]; ok && len(mtimeBytes) == 8 { + mtime = int64(util.BytesToUint64(mtimeBytes)) + } + } + + obj := S3Object{ + Key: objectKey, + Size: size, + LastModified: time.Unix(mtime, 0), + ETag: "", + StorageClass: "STANDARD", + } + + details.Objects = append(details.Objects, obj) + details.TotalCount++ + details.TotalSize += size + // Don't recurse into .versions directories + continue + } + // Recursively list subdirectories subDir := fmt.Sprintf("%s/%s", directory, entry.Name) err := s.listBucketObjects(client, bucketBasePath, subDir, "", details) @@ -1902,9 +1942,8 @@ func extractObjectLockInfoFromEntry(entry *filer_pb.Entry) (bool, string, int32) } // Function to extract versioning information from bucket entry using shared utilities -func extractVersioningFromEntry(entry *filer_pb.Entry) bool { - enabled, _ := s3api.LoadVersioningFromExtended(entry) - return enabled +func extractVersioningFromEntry(entry *filer_pb.Entry) string { + return s3api.GetVersioningStatus(entry) } // GetConfigPersistence returns the config persistence manager diff --git a/weed/admin/dash/bucket_management.go b/weed/admin/dash/bucket_management.go index eb99e9fa4..7104aa8c6 100644 --- a/weed/admin/dash/bucket_management.go +++ b/weed/admin/dash/bucket_management.go @@ -125,6 +125,12 @@ func (s *AdminServer) CreateBucket(c *gin.Context) { // Convert quota to bytes quotaBytes := convertQuotaToBytes(req.QuotaSize, req.QuotaUnit) + // Validate quota: if enabled, size must be greater than 0 + if req.QuotaEnabled && quotaBytes <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Quota size must be greater than 0 when quota is enabled"}) + return + } + // Sanitize owner: trim whitespace and enforce max length owner := strings.TrimSpace(req.Owner) if len(owner) > MaxOwnerNameLength { @@ -466,16 +472,19 @@ func (s *AdminServer) CreateS3BucketWithObjectLock(bucketName string, quotaBytes // Handle Object Lock configuration using shared utilities if objectLockEnabled { var duration int32 = 0 + var mode string = "" + if setDefaultRetention { // Validate Object Lock parameters only when setting default retention if err := s3api.ValidateObjectLockParameters(objectLockEnabled, objectLockMode, objectLockDuration); err != nil { return fmt.Errorf("invalid Object Lock parameters: %w", err) } duration = objectLockDuration + mode = objectLockMode } // Create Object Lock configuration using shared utility - objectLockConfig := s3api.CreateObjectLockConfigurationFromParams(objectLockEnabled, objectLockMode, duration) + objectLockConfig := s3api.CreateObjectLockConfigurationFromParams(objectLockEnabled, mode, duration) // Store Object Lock configuration in extended attributes using shared utility if err := s3api.StoreObjectLockConfigurationInExtended(bucketEntry, objectLockConfig); err != nil { diff --git a/weed/admin/dash/types.go b/weed/admin/dash/types.go index 5c2ac60e8..46fad0a5e 100644 --- a/weed/admin/dash/types.go +++ b/weed/admin/dash/types.go @@ -78,7 +78,7 @@ type S3Bucket struct { LastModified time.Time `json:"last_modified"` Quota int64 `json:"quota"` // Quota in bytes, 0 means no quota QuotaEnabled bool `json:"quota_enabled"` // Whether quota is enabled - VersioningEnabled bool `json:"versioning_enabled"` // Whether versioning is enabled + VersioningStatus string `json:"versioning_status"` // Versioning status: "" (never enabled), "Enabled", or "Suspended" ObjectLockEnabled bool `json:"object_lock_enabled"` // Whether object lock is enabled ObjectLockMode string `json:"object_lock_mode"` // Object lock mode: "GOVERNANCE" or "COMPLIANCE" ObjectLockDuration int32 `json:"object_lock_duration"` // Default retention duration in days diff --git a/weed/admin/view/app/s3_buckets.templ b/weed/admin/view/app/s3_buckets.templ index 19b37899b..890697926 100644 --- a/weed/admin/view/app/s3_buckets.templ +++ b/weed/admin/view/app/s3_buckets.templ @@ -164,14 +164,16 @@ templ S3Buckets(data dash.S3BucketsData) { } - if bucket.VersioningEnabled { + if bucket.VersioningStatus == "Enabled" { Enabled - } else { - - Disabled + } else if bucket.VersioningStatus == "Suspended" { + + Suspended + } else { + Not configured } @@ -185,9 +187,7 @@ templ S3Buckets(data dash.S3BucketsData) { } else { - - Disabled - + Not configured } @@ -1044,9 +1044,11 @@ templ S3Buckets(data dash.S3BucketsData) { '' + 'Versioning:' + '' + - (bucket.versioning_enabled ? + (bucket.versioning_status === 'Enabled' ? 'Enabled' : - 'Disabled' + bucket.versioning_status === 'Suspended' ? + 'Suspended' : + 'Not configured' ) + '' + '' + @@ -1055,8 +1057,10 @@ templ S3Buckets(data dash.S3BucketsData) { '' + (bucket.object_lock_enabled ? 'Enabled' + - '
' + escapeHtml(bucket.object_lock_mode) + ' • ' + bucket.object_lock_duration + ' days' : - 'Disabled' + (bucket.object_lock_mode && bucket.object_lock_duration > 0 ? + '
' + escapeHtml(bucket.object_lock_mode) + ' • ' + bucket.object_lock_duration + ' days' : + '') : + 'Not configured' ) + '' + '' + diff --git a/weed/admin/view/app/s3_buckets_templ.go b/weed/admin/view/app/s3_buckets_templ.go index d0590c5e4..3474a1a48 100644 --- a/weed/admin/view/app/s3_buckets_templ.go +++ b/weed/admin/view/app/s3_buckets_templ.go @@ -253,59 +253,64 @@ func S3Buckets(data dash.S3BucketsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if bucket.VersioningEnabled { + if bucket.VersioningStatus == "Enabled" { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Enabled") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + } else if bucket.VersioningStatus == "Suspended" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Suspended") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Disabled") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "Not configured") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if bucket.ObjectLockEnabled { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
Enabled
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
Enabled
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(bucket.ObjectLockMode) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 184, Col: 82} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 186, Col: 82} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d days", bucket.ObjectLockDuration)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 184, Col: 138} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/s3_buckets.templ`, Line: 186, Col: 138} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Disabled") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Not configured") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" title=\"Delete Bucket\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(data.Buckets) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
No Object Store buckets found

Create your first bucket to get started with S3 storage.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
No Object Store buckets found

Create your first bucket to get started with S3 storage.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
Last updated: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
Last updated: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -433,7 +438,7 @@ func S3Buckets(data dash.S3BucketsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
Create New S3 Bucket
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
The S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Set the maximum storage size for this bucket.
Keep multiple versions of objects in this bucket.
Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.
Governance allows override with special permissions, Compliance is immutable.
Apply default retention to all new objects in this bucket.
Default retention period for new objects (1-36500 days).
Delete Bucket

Are you sure you want to delete the bucket ?

Warning: This action cannot be undone. All objects in the bucket will be permanently deleted.
Manage Bucket Quota
Set the maximum storage size for this bucket. Set to 0 to remove quota.
Bucket Details
Loading...
Loading bucket details...
Manage Bucket Owner
Select the S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Loading users...
Loading users...
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
Create New S3 Bucket
Bucket names must be between 3 and 63 characters, contain only lowercase letters, numbers, dots, and hyphens.
The S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Set the maximum storage size for this bucket.
Keep multiple versions of objects in this bucket.
Prevent objects from being deleted or overwritten for a specified period. Automatically enables versioning.
Governance allows override with special permissions, Compliance is immutable.
Apply default retention to all new objects in this bucket.
Default retention period for new objects (1-36500 days).
Delete Bucket

Are you sure you want to delete the bucket ?

Warning: This action cannot be undone. All objects in the bucket will be permanently deleted.
Manage Bucket Quota
Set the maximum storage size for this bucket. Set to 0 to remove quota.
Bucket Details
Loading...
Loading bucket details...
Manage Bucket Owner
Select the S3 identity that owns this bucket. Non-admin users can only access buckets they own.
Loading users...
Loading users...
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/s3api/object_lock_utils.go b/weed/s3api/object_lock_utils.go index 6b00d8595..9455cb12c 100644 --- a/weed/s3api/object_lock_utils.go +++ b/weed/s3api/object_lock_utils.go @@ -28,7 +28,8 @@ func StoreVersioningInExtended(entry *filer_pb.Entry, enabled bool) error { if enabled { entry.Extended[s3_constants.ExtVersioningKey] = []byte(s3_constants.VersioningEnabled) } else { - entry.Extended[s3_constants.ExtVersioningKey] = []byte(s3_constants.VersioningSuspended) + // Don't set the header when versioning is not enabled + delete(entry.Extended, s3_constants.ExtVersioningKey) } return nil @@ -49,6 +50,20 @@ func LoadVersioningFromExtended(entry *filer_pb.Entry) (bool, bool) { return false, false // not found } +// GetVersioningStatus returns the versioning status as a string: "", "Enabled", or "Suspended" +// Empty string means versioning was never enabled +func GetVersioningStatus(entry *filer_pb.Entry) string { + if entry == nil || entry.Extended == nil { + return "" // Never enabled + } + + if versioningBytes, exists := entry.Extended[s3_constants.ExtVersioningKey]; exists { + return string(versioningBytes) // "Enabled" or "Suspended" + } + + return "" // Never enabled +} + // CreateObjectLockConfiguration creates a new ObjectLockConfiguration with the specified parameters func CreateObjectLockConfiguration(enabled bool, mode string, days int, years int) *ObjectLockConfiguration { if !enabled { From 8d752906012e511bd52c68369bc7c8c8b108b71c Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 22 Dec 2025 23:46:30 -0800 Subject: [PATCH 16/66] 4.04 --- k8s/charts/seaweedfs/Chart.yaml | 4 ++-- weed/util/version/constants.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/k8s/charts/seaweedfs/Chart.yaml b/k8s/charts/seaweedfs/Chart.yaml index 234187cce..6dae918e1 100644 --- a/k8s/charts/seaweedfs/Chart.yaml +++ b/k8s/charts/seaweedfs/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v1 description: SeaweedFS name: seaweedfs -appVersion: "4.03" +appVersion: "4.04" # Dev note: Trigger a helm chart release by `git tag -a helm-` -version: 4.0.403 +version: 4.0.404 diff --git a/weed/util/version/constants.go b/weed/util/version/constants.go index 82836a381..63eb4b3d5 100644 --- a/weed/util/version/constants.go +++ b/weed/util/version/constants.go @@ -9,7 +9,7 @@ import ( var ( MAJOR_VERSION = int32(4) - MINOR_VERSION = int32(03) + MINOR_VERSION = int32(04) VERSION_NUMBER = fmt.Sprintf("%d.%02d", MAJOR_VERSION, MINOR_VERSION) VERSION = util.SizeLimit + " " + VERSION_NUMBER COMMIT = "" From 9c784cf9e2ccdcb9720573bb1bc3f2fbcf526887 Mon Sep 17 00:00:00 2001 From: undefined Date: Wed, 24 Dec 2025 02:11:23 +0800 Subject: [PATCH 17/66] fix: use path to handle urls in weed admin file browser (#7858) * fix: use path instead of filepath to handle urls in weed admin file browser * test: add comprehensive tests for file browser path handling - Test breadcrumb generation for various path scenarios - Test path handling with forward slashes (URL compatibility) - Test parent path calculation for Windows compatibility - Test file extension handling using path.Ext - Test bucket path detection logic These tests verify that the switch from filepath to path package works correctly and handles URLs properly across all platforms. * refactor: simplify fullPath construction using path.Join Replace verbose manual path construction with path.Join which: - Handles trailing slashes automatically - Is more concise and readable - Is more robust for edge cases * fix: normalize path in ShowFileBrowser and rename generateBreadcrumbs parameter Critical fix: - Add util.CleanWindowsPath() normalization to path parameter in ShowFileBrowser handler, matching the pattern used in other file operation handlers (lines 273, 464) - This ensures Windows-style backslashes are converted to forward slashes before processing, fixing path handling issues on Windows Consistency improvement: - Rename path parameter to dir in generateBreadcrumbs function - Aligns with parameter rename in GetFileBrowser for consistent naming throughout the file * test: improve coverage for Windows path handling and production code behavior Address reviewer feedback by enhancing test quality: 1. Improved test documentation: - Added clear comments explaining what each test validates - Clarified that some tests validate expected behavior vs production code - Documented the Windows path normalization flow 2. Enhanced actual production code testing: - TestGenerateBreadcrumbs: Calls actual production function - TestBreadcrumbPathFormatting: Validates production output format - TestDirectoryNavigation: Integration-style test for complete flow 3. Added new test functions for better coverage: - TestPathJoinHandlesEdgeCases: Verifies path.Join behavior - TestWindowsPathNormalizationBehavior: Documents expected normalization - TestDirectoryNavigation: Complete navigation flow test 4. Improved test organization: - Fixed duplicate field naming issues - Better test names for clarity - More comprehensive edge case coverage These improvements ensure the fix for issue #7628 (Windows path handling) is properly validated across the complete flow from handler to path logic. * test: use actual util.CleanWindowsPath function in Windows path normalization test Address reviewer feedback by testing the actual production function: - Import util package for CleanWindowsPath - Call the real util.CleanWindowsPath() instead of reimplementing logic - Ensures test validates actual implementation, not just expected behavior - Added more test cases for edge cases (simple path, deep nesting) This change validates that the Windows path normalization in the ShowFileBrowser handler (handlers/file_browser_handlers.go:64) works correctly with the actual util.CleanWindowsPath function. * style: fix indentation in TestPathJoinHandlesEdgeCases Align t.Errorf statement inside the if block with proper indentation. The error message now correctly aligns with the if block body, maintaining consistent indentation throughout the function. * test: restore backslash validation check in TestPathJoinHandlesEdgeCases --------- Co-authored-by: Chris Lu --- weed/admin/dash/file_browser_data.go | 38 +- weed/admin/dash/file_browser_data_test.go | 502 +++++++++++++++++++ weed/admin/handlers/file_browser_handlers.go | 2 + 3 files changed, 521 insertions(+), 21 deletions(-) create mode 100644 weed/admin/dash/file_browser_data_test.go diff --git a/weed/admin/dash/file_browser_data.go b/weed/admin/dash/file_browser_data.go index 6bb30c469..bd561e5ad 100644 --- a/weed/admin/dash/file_browser_data.go +++ b/weed/admin/dash/file_browser_data.go @@ -2,7 +2,7 @@ package dash import ( "context" - "path/filepath" + "path" "sort" "strings" "time" @@ -47,9 +47,9 @@ type FileBrowserData struct { } // GetFileBrowser retrieves file browser data for a given path -func (s *AdminServer) GetFileBrowser(path string) (*FileBrowserData, error) { - if path == "" { - path = "/" +func (s *AdminServer) GetFileBrowser(dir string) (*FileBrowserData, error) { + if dir == "" { + dir = "/" } var entries []FileEntry @@ -58,7 +58,7 @@ func (s *AdminServer) GetFileBrowser(path string) (*FileBrowserData, error) { // Get directory listing from filer err := s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{ - Directory: path, + Directory: dir, Prefix: "", Limit: 1000, InclusiveStartFrom: false, @@ -81,11 +81,7 @@ func (s *AdminServer) GetFileBrowser(path string) (*FileBrowserData, error) { continue } - fullPath := path - if !strings.HasSuffix(fullPath, "/") { - fullPath += "/" - } - fullPath += entry.Name + fullPath := path.Join(dir, entry.Name) var modTime time.Time if entry.Attributes != nil && entry.Attributes.Mtime > 0 { @@ -121,7 +117,7 @@ func (s *AdminServer) GetFileBrowser(path string) (*FileBrowserData, error) { if entry.IsDirectory { mime = "inode/directory" } else { - ext := strings.ToLower(filepath.Ext(entry.Name)) + ext := strings.ToLower(path.Ext(entry.Name)) switch ext { case ".txt", ".log": mime = "text/plain" @@ -195,12 +191,12 @@ func (s *AdminServer) GetFileBrowser(path string) (*FileBrowserData, error) { }) // Generate breadcrumbs - breadcrumbs := s.generateBreadcrumbs(path) + breadcrumbs := s.generateBreadcrumbs(dir) // Calculate parent path parentPath := "/" - if path != "/" { - parentPath = filepath.Dir(path) + if dir != "/" { + parentPath = path.Dir(dir) if parentPath == "." { parentPath = "/" } @@ -209,16 +205,16 @@ func (s *AdminServer) GetFileBrowser(path string) (*FileBrowserData, error) { // Check if this is a bucket path isBucketPath := false bucketName := "" - if strings.HasPrefix(path, "/buckets/") { + if strings.HasPrefix(dir, "/buckets/") { isBucketPath = true - pathParts := strings.Split(strings.Trim(path, "/"), "/") + pathParts := strings.Split(strings.Trim(dir, "/"), "/") if len(pathParts) >= 2 { bucketName = pathParts[1] } } return &FileBrowserData{ - CurrentPath: path, + CurrentPath: dir, ParentPath: parentPath, Breadcrumbs: breadcrumbs, Entries: entries, @@ -231,7 +227,7 @@ func (s *AdminServer) GetFileBrowser(path string) (*FileBrowserData, error) { } // generateBreadcrumbs creates breadcrumb navigation for the current path -func (s *AdminServer) generateBreadcrumbs(path string) []BreadcrumbItem { +func (s *AdminServer) generateBreadcrumbs(dir string) []BreadcrumbItem { var breadcrumbs []BreadcrumbItem // Always start with root @@ -240,12 +236,12 @@ func (s *AdminServer) generateBreadcrumbs(path string) []BreadcrumbItem { Path: "/", }) - if path == "/" { + if dir == "/" { return breadcrumbs } // Split path and build breadcrumbs - parts := strings.Split(strings.Trim(path, "/"), "/") + parts := strings.Split(strings.Trim(dir, "/"), "/") currentPath := "" for _, part := range parts { @@ -258,7 +254,7 @@ func (s *AdminServer) generateBreadcrumbs(path string) []BreadcrumbItem { displayName := part if len(breadcrumbs) == 1 && part == "buckets" { displayName = "Object Store Buckets" - } else if len(breadcrumbs) == 2 && strings.HasPrefix(path, "/buckets/") { + } else if len(breadcrumbs) == 2 && strings.HasPrefix(dir, "/buckets/") { displayName = "📦 " + part // Add bucket icon to bucket name } diff --git a/weed/admin/dash/file_browser_data_test.go b/weed/admin/dash/file_browser_data_test.go new file mode 100644 index 000000000..0605735af --- /dev/null +++ b/weed/admin/dash/file_browser_data_test.go @@ -0,0 +1,502 @@ +package dash + +import ( + "path" + "strings" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/util" +) + +// TestGenerateBreadcrumbs tests the actual breadcrumb generation function +// from the production code with various path scenarios +func TestGenerateBreadcrumbs(t *testing.T) { + s := &AdminServer{} + + tests := []struct { + name string + path string + expected []BreadcrumbItem + }{ + { + name: "root path", + path: "/", + expected: []BreadcrumbItem{ + {Name: "Root", Path: "/"}, + }, + }, + { + name: "simple path", + path: "/folder", + expected: []BreadcrumbItem{ + {Name: "Root", Path: "/"}, + {Name: "folder", Path: "/folder"}, + }, + }, + { + name: "nested path", + path: "/folder/subfolder", + expected: []BreadcrumbItem{ + {Name: "Root", Path: "/"}, + {Name: "folder", Path: "/folder"}, + {Name: "subfolder", Path: "/folder/subfolder"}, + }, + }, + { + name: "bucket path", + path: "/buckets/mybucket", + expected: []BreadcrumbItem{ + {Name: "Root", Path: "/"}, + {Name: "Object Store Buckets", Path: "/buckets"}, + {Name: "📦 mybucket", Path: "/buckets/mybucket"}, + }, + }, + { + name: "bucket nested path", + path: "/buckets/mybucket/folder", + expected: []BreadcrumbItem{ + {Name: "Root", Path: "/"}, + {Name: "Object Store Buckets", Path: "/buckets"}, + {Name: "📦 mybucket", Path: "/buckets/mybucket"}, + {Name: "folder", Path: "/buckets/mybucket/folder"}, + }, + }, + { + name: "path with trailing slash", + path: "/folder/", + expected: []BreadcrumbItem{ + {Name: "Root", Path: "/"}, + {Name: "folder", Path: "/folder"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Call the actual production function + result := s.generateBreadcrumbs(tt.path) + + if len(result) != len(tt.expected) { + t.Errorf("expected %d breadcrumbs, got %d", len(tt.expected), len(result)) + return + } + + for i, crumb := range result { + if crumb.Name != tt.expected[i].Name { + t.Errorf("breadcrumb %d: expected name %q, got %q", i, tt.expected[i].Name, crumb.Name) + } + if crumb.Path != tt.expected[i].Path { + t.Errorf("breadcrumb %d: expected path %q, got %q", i, tt.expected[i].Path, crumb.Path) + } + } + }) + } +} + +// TestPathHandlingWithForwardSlashes verifies that the production code +// correctly handles paths with forward slashes (not OS-specific backslashes) +func TestPathHandlingWithForwardSlashes(t *testing.T) { + tests := []struct { + name string + path string + hasSlash bool + }{ + { + name: "root", + path: "/", + hasSlash: true, + }, + { + name: "single level", + path: "/test", + hasSlash: true, + }, + { + name: "multiple levels", + path: "/a/b/c", + hasSlash: true, + }, + { + name: "bucket path", + path: "/buckets/mybucket/file", + hasSlash: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Verify no backslashes appear in paths (which would be wrong on Windows) + if strings.Contains(tt.path, "\\") { + t.Errorf("path contains backslash: %q", tt.path) + } + + // Verify forward slashes are used + if tt.hasSlash && !strings.Contains(tt.path, "/") { + t.Errorf("path should contain forward slash: %q", tt.path) + } + }) + } +} + +// TestParentPathCalculationLogic verifies that parent path calculation +// uses path.Dir semantics (forward slashes), not filepath.Dir (OS-specific) +func TestParentPathCalculationLogic(t *testing.T) { + tests := []struct { + name string + currentDir string + expected string + }{ + { + name: "root path", + currentDir: "/", + expected: "/", + }, + { + name: "single level", + currentDir: "/folder", + expected: "/", + }, + { + name: "two levels", + currentDir: "/folder/subfolder", + expected: "/folder", + }, + { + name: "deep nesting", + currentDir: "/a/b/c/d", + expected: "/a/b/c", + }, + { + name: "bucket root", + currentDir: "/buckets", + expected: "/", + }, + { + name: "bucket directory", + currentDir: "/buckets/mybucket", + expected: "/buckets", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Verify using path.Dir (the correct approach for URLs) + // This demonstrates the expected behavior + parentPath := "/" + if tt.currentDir != "/" { + // path.Dir always uses forward slashes + parentPath = path.Dir(tt.currentDir) + if parentPath == "." { + parentPath = "/" + } + } + + if parentPath != tt.expected { + t.Errorf("expected parent %q, got %q for %q", tt.expected, parentPath, tt.currentDir) + } + + // Verify no backslashes in the result + if strings.Contains(parentPath, "\\") { + t.Errorf("parent path contains backslash: %q", parentPath) + } + }) + } +} + +// TestFileExtensionHandlingLogic verifies that file extensions are correctly +// identified using path semantics (always forward slashes) +func TestFileExtensionHandlingLogic(t *testing.T) { + tests := []struct { + filename string + expected string + }{ + {"file.txt", ".txt"}, + {"file.log", ".log"}, + {"archive.tar.gz", ".gz"}, + {"image.jpg", ".jpg"}, + {"document.pdf", ".pdf"}, + {"data.json", ".json"}, + {"noextension", ""}, + {".hidden", ".hidden"}, + {"file.TXT", ".txt"}, + {"file.JPG", ".jpg"}, + } + + for _, tt := range tests { + t.Run(tt.filename, func(t *testing.T) { + // Verify using path.Ext + strings.ToLower (the correct approach) + ext := strings.ToLower(path.Ext(tt.filename)) + if ext != tt.expected { + t.Errorf("expected extension %q for %q, got %q", tt.expected, tt.filename, ext) + } + }) + } +} + +// TestBucketPathDetectionLogic verifies bucket path detection logic +func TestBucketPathDetectionLogic(t *testing.T) { + tests := []struct { + name string + path string + isBucket bool + expectedName string + }{ + { + name: "root is not a bucket path", + path: "/", + isBucket: false, + expectedName: "", + }, + { + name: "buckets root", + path: "/buckets/", + isBucket: true, + expectedName: "", + }, + { + name: "single bucket", + path: "/buckets/mybucket", + isBucket: true, + expectedName: "mybucket", + }, + { + name: "bucket with nested path", + path: "/buckets/mybucket/folder/file", + isBucket: true, + expectedName: "mybucket", + }, + { + name: "non-bucket path", + path: "/data/folder", + isBucket: false, + expectedName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Verify the bucket path detection logic + isBucketPath := false + bucketName := "" + if strings.HasPrefix(tt.path, "/buckets/") { + isBucketPath = true + pathParts := strings.Split(strings.Trim(tt.path, "/"), "/") + if len(pathParts) >= 2 { + bucketName = pathParts[1] + } + } + + if isBucketPath != tt.isBucket { + t.Errorf("expected isBucketPath=%v, got %v for path %q", tt.isBucket, isBucketPath, tt.path) + } + + if bucketName != tt.expectedName { + t.Errorf("expected bucket name %q, got %q for path %q", tt.expectedName, bucketName, tt.path) + } + }) + } +} + +// TestPathJoinHandlesEdgeCases verifies that path.Join handles edge cases +// properly for URL path construction (unlike filepath.Join which is OS-specific) +func TestPathJoinHandlesEdgeCases(t *testing.T) { + tests := []struct { + testName string + dir string + filename string + expected string + }{ + { + testName: "root directory", + dir: "/", + filename: "file.txt", + expected: "/file.txt", + }, + { + testName: "simple directory", + dir: "/folder", + filename: "file.txt", + expected: "/folder/file.txt", + }, + { + testName: "nested directory", + dir: "/a/b/c", + filename: "file.txt", + expected: "/a/b/c/file.txt", + }, + { + testName: "handles trailing slash", + dir: "/folder/", + filename: "file.txt", + expected: "/folder/file.txt", + }, + { + testName: "handles empty name", + dir: "/folder", + filename: "", + expected: "/folder", + }, + } + + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + // Verify path.Join behavior for URL paths + result := path.Join(tt.dir, tt.filename) + if result != tt.expected { + t.Errorf("path.Join(%q, %q) = %q, expected %q", tt.dir, tt.filename, result, tt.expected) + } + + // Verify no backslashes in the result + if strings.Contains(result, "\\") { + t.Errorf("result contains backslash: %q", result) + } + }) + } +} + +// TestWindowsPathNormalizationBehavior validates that Windows-style paths +// are correctly converted to forward slashes for URL compatibility. +// This test verifies the actual util.CleanWindowsPath() function used in +// the ShowFileBrowser handler. +func TestWindowsPathNormalizationBehavior(t *testing.T) { + tests := []struct { + name string + windowsPath string + expectedNormPath string + }{ + { + name: "backslash separator", + windowsPath: "\\folder\\subfolder", + expectedNormPath: "/folder/subfolder", + }, + { + name: "mixed separators", + windowsPath: "/folder\\subfolder/file", + expectedNormPath: "/folder/subfolder/file", + }, + { + name: "already normalized", + windowsPath: "/folder/file", + expectedNormPath: "/folder/file", + }, + { + name: "simple backslash path", + windowsPath: "\\data", + expectedNormPath: "/data", + }, + { + name: "deep nested path", + windowsPath: "\\a\\b\\c\\d", + expectedNormPath: "/a/b/c/d", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test the actual production function + normalized := util.CleanWindowsPath(tt.windowsPath) + if normalized != tt.expectedNormPath { + t.Errorf("CleanWindowsPath(%q): expected %q, got %q", + tt.windowsPath, tt.expectedNormPath, normalized) + } + }) + } +} + +// TestBreadcrumbPathFormatting validates that breadcrumb paths always +// use forward slashes and maintain proper URL format +func TestBreadcrumbPathFormatting(t *testing.T) { + s := &AdminServer{} + + testPaths := []string{ + "/", + "/folder", + "/folder/subfolder", + "/buckets/mybucket", + "/buckets/mybucket/data", + } + + for _, testPath := range testPaths { + t.Run("breadcrumbs_for_"+testPath, func(t *testing.T) { + breadcrumbs := s.generateBreadcrumbs(testPath) + + // Verify all breadcrumb paths use forward slashes + for i, crumb := range breadcrumbs { + if strings.Contains(crumb.Path, "\\") { + t.Errorf("breadcrumb %d has backslash in path: %q", i, crumb.Path) + } + // Verify paths start with / (except when empty) + if crumb.Path != "" && !strings.HasPrefix(crumb.Path, "/") { + t.Errorf("breadcrumb %d path should start with /: %q", i, crumb.Path) + } + } + }) + } +} + +// TestDirectoryNavigation validates the complete navigation flow +// for various path scenarios +func TestDirectoryNavigation(t *testing.T) { + s := &AdminServer{} + + tests := []struct { + name string + currentPath string + expectedParent string + expectedCrumbs int + }{ + { + name: "navigate from root", + currentPath: "/", + expectedParent: "/", + expectedCrumbs: 1, + }, + { + name: "navigate from single folder", + currentPath: "/documents", + expectedParent: "/", + expectedCrumbs: 2, + }, + { + name: "navigate from nested folder", + currentPath: "/documents/projects/current", + expectedParent: "/documents/projects", + expectedCrumbs: 4, + }, + { + name: "navigate bucket contents", + currentPath: "/buckets/data/files", + expectedParent: "/buckets/data", + expectedCrumbs: 4, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Verify breadcrumbs are generated correctly + breadcrumbs := s.generateBreadcrumbs(tt.currentPath) + if len(breadcrumbs) != tt.expectedCrumbs { + t.Errorf("expected %d breadcrumbs, got %d", tt.expectedCrumbs, len(breadcrumbs)) + } + + // Verify parent path calculation + expectedParent := "/" + if tt.currentPath != "/" { + expectedParent = path.Dir(tt.currentPath) + if expectedParent == "." { + expectedParent = "/" + } + } + if expectedParent != tt.expectedParent { + t.Errorf("expected parent %q, got %q", tt.expectedParent, expectedParent) + } + + // Verify all paths use forward slashes + for i, crumb := range breadcrumbs { + if strings.Contains(crumb.Path, "\\") { + t.Errorf("breadcrumb %d contains backslash: %q", i, crumb.Path) + } + } + }) + } +} diff --git a/weed/admin/handlers/file_browser_handlers.go b/weed/admin/handlers/file_browser_handlers.go index eeb8e2d85..d8f79337d 100644 --- a/weed/admin/handlers/file_browser_handlers.go +++ b/weed/admin/handlers/file_browser_handlers.go @@ -60,6 +60,8 @@ func (h *FileBrowserHandlers) newClientWithTimeout(timeout time.Duration) http.C func (h *FileBrowserHandlers) ShowFileBrowser(c *gin.Context) { // Get path from query parameter, default to root path := c.DefaultQuery("path", "/") + // Normalize Windows-style paths for consistency + path = util.CleanWindowsPath(path) // Get file browser data browserData, err := h.adminServer.GetFileBrowser(path) From 621ff124f0aab89fbbee23dd13a16179ef2c08a1 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 23 Dec 2025 10:33:21 -0800 Subject: [PATCH 18/66] fix: ensure Helm chart is published only after container images are available (#7859) fix: consolidate Helm chart release with container image build Resolve issue #7855 by consolidating the Helm chart release workflow with the container image build workflow. This ensures perfect alignment: 1. Container images are built and pushed to GHCR 2. Images are copied from GHCR to Docker Hub 3. Helm chart is published only after step 2 completes Previously, the Helm chart was published immediately on tag push before images were available in Docker Hub, causing deployment failures. Changes: - Added helm-release job to container_release_unified.yml that depends on copy-to-dockerhub job - Removed helm_chart_release.yml workflow (consolidated into unified release) Benefits: - No race conditions between image push and chart publication - Users can deploy immediately after release - Single source of truth for release process - Clearer job dependencies and execution flow --- .../workflows/container_release_unified.yml | 18 +++++++++++++++ .github/workflows/helm_chart_release.yml | 23 ------------------- 2 files changed, 18 insertions(+), 23 deletions(-) delete mode 100644 .github/workflows/helm_chart_release.yml diff --git a/.github/workflows/container_release_unified.yml b/.github/workflows/container_release_unified.yml index 9e3a0a451..8dedfd26f 100644 --- a/.github/workflows/container_release_unified.yml +++ b/.github/workflows/container_release_unified.yml @@ -223,5 +223,23 @@ jobs: echo "✓ Successfully copied ${{ matrix.variant }} to Docker Hub" + helm-release: + runs-on: ubuntu-latest + needs: [copy-to-dockerhub] + permissions: + contents: write + pages: write + steps: + - uses: actions/checkout@v6 + - name: Publish Helm charts + uses: stefanprodan/helm-gh-pages@v1.7.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + charts_dir: k8s/charts + target_dir: helm + branch: gh-pages + helm_version: "3.18.4" + + diff --git a/.github/workflows/helm_chart_release.yml b/.github/workflows/helm_chart_release.yml deleted file mode 100644 index d21b00451..000000000 --- a/.github/workflows/helm_chart_release.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: "helm: publish charts" -on: - push: - tags: - - '*' - -permissions: - contents: write - pages: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - - name: Publish Helm charts - uses: stefanprodan/helm-gh-pages@v1.7.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - charts_dir: k8s/charts - target_dir: helm - branch: gh-pages - helm_version: "3.18.4" From 88ed187c276e5dd021b4edcb54d9384f93b7ed45 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 23 Dec 2025 11:46:34 -0800 Subject: [PATCH 19/66] fix(worker): add metrics HTTP server and health checks for Kubernetes (#7860) * feat(worker): add metrics HTTP server and debug profiling support - Add -metricsPort flag to enable Prometheus metrics endpoint - Add -metricsIp flag to configure metrics server bind address - Implement /metrics endpoint for Prometheus-compatible metrics - Implement /health endpoint for Kubernetes readiness/liveness probes - Add -debug flag to enable pprof debugging server - Add -debug.port flag to configure debug server port - Fix stats package import naming conflict by using alias - Update usage examples to show new flags Fixes #7843 * feat(helm): add worker metrics and health check support - Update worker readiness probe to use httpGet on /health endpoint - Update worker liveness probe to use httpGet on /health endpoint - Add metricsPort flag to worker command in deployment template - Support both httpGet and tcpSocket probe types for backward compatibility - Update values.yaml with health check configuration This enables Kubernetes pod lifecycle management for worker components through proper health checks on the new metrics HTTP endpoint. * feat(mini): align all services to share single debug and metrics servers - Disable S3's separate debug server in mini mode (port 6060 now shared by all) - Add metrics server startup to embedded worker for health monitoring - All services now share the single metrics port (9327) and single debug port (6060) - Consistent pattern with master, filer, volume, webdav services * fix(worker): fix variable shadowing in health check handler - Rename http.ResponseWriter parameter from 'w' to 'rw' to avoid shadowing the outer 'w *worker.Worker' parameter - Prevents potential bugs if future code tries to use worker state in handler - Improves code clarity and follows Go best practices * fix(worker): remove unused worker parameter in metrics server - Change 'w *worker.Worker' parameter to '_' as it's not used - Clarifies intent that parameter is intentionally unused - Follows Go best practices and improves code clarity * fix(helm): fix trailing backslash syntax errors in worker command - Fix conditional backslash placement to prevent shell syntax errors - Only add backslash when metricsPort OR extraArgs are present - Prevents worker pod startup failures due to malformed command arguments - Ensures proper shell command parsing regardless of configuration state * refactor(worker): use standard stats.StartMetricsServer for consistency - Replace custom metrics server implementation with stats.StartMetricsServer to match pattern used in master, volume, s3, filer_sync components - Simplifies code and improves maintainability - Uses glog.Fatal for errors (consistent with other SeaweedFS components) - Remove unused net/http and prometheus/promhttp imports - Automatically provides /metrics and /health endpoints via standard implementation --- .../templates/worker/worker-deployment.yaml | 21 ++++++++++++---- k8s/charts/seaweedfs/values.yaml | 8 +++--- weed/command/mini.go | 9 +++++-- weed/command/worker.go | 25 ++++++++++++++++++- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/k8s/charts/seaweedfs/templates/worker/worker-deployment.yaml b/k8s/charts/seaweedfs/templates/worker/worker-deployment.yaml index 60f608702..d6b94564c 100644 --- a/k8s/charts/seaweedfs/templates/worker/worker-deployment.yaml +++ b/k8s/charts/seaweedfs/templates/worker/worker-deployment.yaml @@ -138,7 +138,10 @@ spec: {{- end }} -capabilities={{ .Values.worker.capabilities }} \ -maxConcurrent={{ .Values.worker.maxConcurrent }} \ - -workingDir={{ .Values.worker.workingDir }}{{- if .Values.worker.extraArgs }} \{{ end }} + -workingDir={{ .Values.worker.workingDir }}{{- if or .Values.worker.metricsPort .Values.worker.extraArgs }} \{{ end }} + {{- if .Values.worker.metricsPort }} + -metricsPort={{ .Values.worker.metricsPort }}{{- if .Values.worker.extraArgs }} \{{ end }} + {{- end }} {{- range $index, $arg := .Values.worker.extraArgs }} {{ $arg }}{{- if lt $index (sub (len $.Values.worker.extraArgs) 1) }} \{{ end }} {{- end }} @@ -187,9 +190,13 @@ spec: {{- end }} {{- if .Values.worker.livenessProbe.enabled }} livenessProbe: - {{- with .Values.worker.livenessProbe.tcpSocket }} + {{- if .Values.worker.livenessProbe.httpGet }} + httpGet: + path: {{ .Values.worker.livenessProbe.httpGet.path }} + port: {{ .Values.worker.livenessProbe.httpGet.port }} + {{- else if .Values.worker.livenessProbe.tcpSocket }} tcpSocket: - port: {{ .port }} + port: {{ .Values.worker.livenessProbe.tcpSocket.port }} {{- end }} initialDelaySeconds: {{ .Values.worker.livenessProbe.initialDelaySeconds }} periodSeconds: {{ .Values.worker.livenessProbe.periodSeconds }} @@ -199,9 +206,13 @@ spec: {{- end }} {{- if .Values.worker.readinessProbe.enabled }} readinessProbe: - {{- with .Values.worker.readinessProbe.tcpSocket }} + {{- if .Values.worker.readinessProbe.httpGet }} + httpGet: + path: {{ .Values.worker.readinessProbe.httpGet.path }} + port: {{ .Values.worker.readinessProbe.httpGet.port }} + {{- else if .Values.worker.readinessProbe.tcpSocket }} tcpSocket: - port: {{ .port }} + port: {{ .Values.worker.readinessProbe.tcpSocket.port }} {{- end }} initialDelaySeconds: {{ .Values.worker.readinessProbe.initialDelaySeconds }} periodSeconds: {{ .Values.worker.readinessProbe.periodSeconds }} diff --git a/k8s/charts/seaweedfs/values.yaml b/k8s/charts/seaweedfs/values.yaml index b03e66c40..dd14f1ca0 100644 --- a/k8s/charts/seaweedfs/values.yaml +++ b/k8s/charts/seaweedfs/values.yaml @@ -1302,10 +1302,11 @@ worker: extraEnvironmentVars: {} # Health checks for worker pods - # Since workers do not have an HTTP endpoint, a tcpSocket probe on the metrics port is recommended. + # Workers expose metrics on the metricsPort with a /health endpoint for readiness checks. livenessProbe: enabled: true - tcpSocket: + httpGet: + path: /health port: metrics initialDelaySeconds: 30 periodSeconds: 60 @@ -1315,7 +1316,8 @@ worker: readinessProbe: enabled: true - tcpSocket: + httpGet: + path: /health port: metrics initialDelaySeconds: 20 periodSeconds: 15 diff --git a/weed/command/mini.go b/weed/command/mini.go index d52dc1c21..e7bdd5625 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -233,8 +233,9 @@ func initMiniS3Flags() { miniS3Options.iamConfig = miniIamConfig miniS3Options.auditLogConfig = cmdMini.Flag.String("s3.auditLogConfig", "", "path to the audit log config file") miniS3Options.allowDeleteBucketNotEmpty = miniS3AllowDeleteBucketNotEmpty - miniS3Options.debug = cmdMini.Flag.Bool("s3.debug", false, "serves runtime profiling data via pprof") - miniS3Options.debugPort = cmdMini.Flag.Int("s3.debug.port", 6060, "http port for debugging") + // In mini mode, S3 uses the shared debug server started at line 681, not its own separate debug server + miniS3Options.debug = new(bool) // explicitly false + miniS3Options.debugPort = cmdMini.Flag.Int("s3.debug.port", 6060, "http port for debugging (unused in mini mode)") } // initMiniWebDAVFlags initializes WebDAV server flag options @@ -1060,6 +1061,10 @@ func startMiniWorker() { // Set admin client workerInstance.SetAdminClient(adminClient) + // Start metrics server for health checks and monitoring (uses shared metrics port like other services) + // This allows Kubernetes probes to check worker health via /health endpoint + go stats_collect.StartMetricsServer(*miniMetricsHttpIp, *miniMetricsHttpPort) + // Start the worker err = workerInstance.Start() if err != nil { diff --git a/weed/command/worker.go b/weed/command/worker.go index 7b14dab8d..84ea55a0d 100644 --- a/weed/command/worker.go +++ b/weed/command/worker.go @@ -10,7 +10,9 @@ import ( "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/security" + statsCollect "github.com/seaweedfs/seaweedfs/weed/stats" "github.com/seaweedfs/seaweedfs/weed/util" + "github.com/seaweedfs/seaweedfs/weed/util/grace" "github.com/seaweedfs/seaweedfs/weed/worker" "github.com/seaweedfs/seaweedfs/weed/worker/tasks" "github.com/seaweedfs/seaweedfs/weed/worker/types" @@ -25,7 +27,7 @@ import ( ) var cmdWorker = &Command{ - UsageLine: "worker -admin= [-capabilities=] [-maxConcurrent=] [-workingDir=]", + UsageLine: "worker -admin= [-capabilities=] [-maxConcurrent=] [-workingDir=] [-metricsPort=] [-debug]", Short: "start a maintenance worker to process cluster maintenance tasks", Long: `Start a maintenance worker that connects to an admin server to process maintenance tasks like vacuum, erasure coding, remote upload, and replication fixes. @@ -39,6 +41,8 @@ Examples: weed worker -admin=localhost:23646 -capabilities=vacuum,replication weed worker -admin=localhost:23646 -maxConcurrent=4 weed worker -admin=localhost:23646 -workingDir=/tmp/worker + weed worker -admin=localhost:23646 -metricsPort=9327 + weed worker -admin=localhost:23646 -debug -debug.port=6060 `, } @@ -49,6 +53,10 @@ var ( workerHeartbeatInterval = cmdWorker.Flag.Duration("heartbeat", 30*time.Second, "heartbeat interval") workerTaskRequestInterval = cmdWorker.Flag.Duration("taskInterval", 5*time.Second, "task request interval") workerWorkingDir = cmdWorker.Flag.String("workingDir", "", "working directory for the worker") + workerMetricsPort = cmdWorker.Flag.Int("metricsPort", 0, "Prometheus metrics listen port") + workerMetricsIp = cmdWorker.Flag.String("metricsIp", "0.0.0.0", "Prometheus metrics listen IP") + workerDebug = cmdWorker.Flag.Bool("debug", false, "serves runtime profiling data via pprof on the port specified by -debug.port") + workerDebugPort = cmdWorker.Flag.Int("debug.port", 6060, "http port for debugging") ) func init() { @@ -60,6 +68,10 @@ func init() { } func runWorker(cmd *Command, args []string) bool { + if *workerDebug { + grace.StartDebugServer(*workerDebugPort) + } + util.LoadConfiguration("security", false) glog.Infof("Starting maintenance worker") @@ -153,6 +165,11 @@ func runWorker(cmd *Command, args []string) bool { glog.Infof("Current working directory: %s", wd) } + // Start metrics HTTP server if port is specified + if *workerMetricsPort > 0 { + go startWorkerMetricsServer(*workerMetricsIp, *workerMetricsPort, workerInstance) + } + // Start the worker err = workerInstance.Start() if err != nil { @@ -239,3 +256,9 @@ type WorkerStatus struct { TasksCompleted int `json:"tasks_completed"` TasksFailed int `json:"tasks_failed"` } + +// startWorkerMetricsServer starts the HTTP metrics server for the worker +func startWorkerMetricsServer(ip string, port int, _ *worker.Worker) { + // Use the standard SeaweedFS metrics server for consistency with other components + statsCollect.StartMetricsServer(ip, port) +} From 1261e93ef2acfd6c6d618d67ed2cd110269b65ac Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 23 Dec 2025 14:48:50 -0800 Subject: [PATCH 20/66] fix: comprehensive go vet error fixes and add CI enforcement (#7861) * fix: use keyed fields in struct literals - Replace unsafe reflect.StringHeader/SliceHeader with safe unsafe.String/Slice (weed/query/sqltypes/unsafe.go) - Add field names to Type_ScalarType struct literals (weed/mq/schema/schema_builder.go) - Add Duration field name to FlexibleDuration struct literals across test files - Add field names to bson.D struct literals (weed/filer/mongodb/mongodb_store_kv.go) Fixes go vet warnings about unkeyed struct literals. * fix: remove unreachable code - Remove unreachable return statements after infinite for loops - Remove unreachable code after if/else blocks where all paths return - Simplify recursive logic by removing unnecessary for loop (inode_to_path.go) - Fix Type_ScalarType literal to use enum value directly (schema_builder.go) - Call onCompletionFn on stream error (subscribe_session.go) Files fixed: - weed/query/sqltypes/unsafe.go - weed/mq/schema/schema_builder.go - weed/mq/client/sub_client/connect_to_sub_coordinator.go - weed/filer/redis3/ItemList.go - weed/mq/client/agent_client/subscribe_session.go - weed/mq/broker/broker_grpc_pub_balancer.go - weed/mount/inode_to_path.go - weed/util/skiplist/name_list.go * fix: avoid copying lock values in protobuf messages - Use proto.Merge() instead of direct assignment to avoid copying sync.Mutex in S3ApiConfiguration (iamapi_server.go) - Add explicit comments noting that channel-received values are already copies before taking addresses (volume_grpc_client_to_master.go) The protobuf messages contain sync.Mutex fields from the message state, which should not be copied. Using proto.Merge() properly merges messages without copying the embedded mutex. * fix: correct byte array size for uint32 bit shift operations The generateAccountId() function only needs 4 bytes to create a uint32 value. Changed from allocating 8 bytes to 4 bytes to match the actual usage. This fixes go vet warning about shifting 8-bit values (bytes) by more than 8 bits. * fix: ensure context cancellation on all error paths In broker_client_subscribe.go, ensure subscriberCancel() is called on all error return paths: - When stream creation fails - When partition assignment fails - When sending initialization message fails This prevents context leaks when an error occurs during subscriber creation. * fix: ensure subscriberCancel called for CreateFreshSubscriber stream.Send error Ensure subscriberCancel() is called when stream.Send fails in CreateFreshSubscriber. * ci: add go vet step to prevent future lint regressions - Add go vet step to GitHub Actions workflow - Filter known protobuf lock warnings (MessageState sync.Mutex) These are expected in generated protobuf code and are safe - Prevents accumulation of go vet errors in future PRs - Step runs before build to catch issues early * fix: resolve remaining syntax and logic errors in vet fixes - Fixed syntax errors in filer_sync.go caused by missing closing braces - Added missing closing brace for if block and function - Synchronized fixes to match previous commits on branch * fix: add missing return statements to daemon functions - Add 'return false' after infinite loops in filer_backup.go and filer_meta_backup.go - Satisfies declared bool return type signatures - Maintains consistency with other daemon functions (runMaster, runFilerSynchronize, runWorker) - While unreachable, explicitly declares the return satisfies function signature contract * fix: add nil check for onCompletionFn in SubscribeMessageRecord - Check if onCompletionFn is not nil before calling it - Prevents potential panic if nil function is passed - Matches pattern used in other callback functions * docs: clarify unreachable return statements in daemon functions - Add comments documenting that return statements satisfy function signature - Explains that these returns follow infinite loops and are unreachable - Improves code clarity for future maintainers --- .github/workflows/go.yml | 9 ++++++++ weed/admin/dash/user_management.go | 5 +++-- weed/command/filer_backup.go | 11 +++++----- weed/command/filer_meta_backup.go | 13 +++++------ weed/command/filer_sync.go | 2 -- weed/filer/mongodb/mongodb_store_kv.go | 11 ++++++++-- weed/filer/redis3/ItemList.go | 3 +-- weed/iam/integration/iam_integration_test.go | 4 ++-- weed/iam/integration/role_store_test.go | 4 ++-- weed/iamapi/iamapi_server.go | 22 ++++++++++--------- weed/mount/inode_to_path.go | 16 ++++++-------- weed/mq/broker/broker_grpc_pub_balancer.go | 3 +-- .../client/agent_client/subscribe_session.go | 8 +++---- .../sub_client/connect_to_sub_coordinator.go | 5 ++--- .../integration/broker_client_subscribe.go | 8 ++++++- weed/mq/schema/schema_builder.go | 22 +++++++++---------- weed/query/sqltypes/unsafe.go | 13 ++--------- weed/s3api/s3_end_to_end_test.go | 4 ++-- weed/s3api/s3_iam_simple_test.go | 4 ++-- weed/s3api/s3_jwt_auth_test.go | 4 ++-- weed/s3api/s3_multipart_iam_test.go | 4 ++-- weed/s3api/s3_presigned_url_iam_test.go | 4 ++-- weed/s3api/s3_token_differentiation_test.go | 4 ++-- weed/server/volume_grpc_client_to_master.go | 8 +++---- weed/util/skiplist/name_list.go | 2 -- 25 files changed, 100 insertions(+), 93 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 34c393cee..8dcc829a8 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -33,6 +33,15 @@ jobs: run: | cd weed; go get -v -t -d ./... + - name: Go Vet (excluding protobuf lock copying) + run: | + cd weed + # Run go vet and filter out known protobuf MessageState lock copying warnings + # These are expected in generated protobuf code with embedded sync.Mutex and are safe in practice + go vet -v ./... 2>&1 | grep -v "MessageState contains sync.Mutex" | grep -v "IdentityAccessManagement contains sync.RWMutex" | tee vet-output.txt + # Fail only if there are actual vet errors (not counting the filtered lock warnings) + if grep -q "vet:" vet-output.txt; then exit 1; fi + - name: Build run: cd weed; go build -tags "elastic gocdk sqlite ydb tarantool tikv rclone" -v . diff --git a/weed/admin/dash/user_management.go b/weed/admin/dash/user_management.go index 747c398d7..3a74a675f 100644 --- a/weed/admin/dash/user_management.go +++ b/weed/admin/dash/user_management.go @@ -326,9 +326,10 @@ func generateSecretKey() string { func generateAccountId() string { // Generate 12-digit account ID - b := make([]byte, 8) + b := make([]byte, 4) rand.Read(b) - return fmt.Sprintf("%012d", b[0]<<24|b[1]<<16|b[2]<<8|b[3]) + val := (uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])) + return fmt.Sprintf("%012d", val) } func randomInt(max int) int { diff --git a/weed/command/filer_backup.go b/weed/command/filer_backup.go index 380540fd9..996260c1e 100644 --- a/weed/command/filer_backup.go +++ b/weed/command/filer_backup.go @@ -3,6 +3,10 @@ package command import ( "errors" "fmt" + "regexp" + "strings" + "time" + "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" @@ -11,9 +15,6 @@ import ( "github.com/seaweedfs/seaweedfs/weed/util" "github.com/seaweedfs/seaweedfs/weed/util/http" "google.golang.org/grpc" - "regexp" - "strings" - "time" ) type FilerBackupOptions struct { @@ -82,8 +83,8 @@ func runFilerBackup(cmd *Command, args []string) bool { time.Sleep(1747 * time.Millisecond) } } - - return true + // Unreachable: satisfies bool return type signature for daemon function + return false } const ( diff --git a/weed/command/filer_meta_backup.go b/weed/command/filer_meta_backup.go index f77f758ab..89ef5b4bb 100644 --- a/weed/command/filer_meta_backup.go +++ b/weed/command/filer_meta_backup.go @@ -3,13 +3,14 @@ package command import ( "context" "fmt" + "reflect" + "strings" + "time" + "github.com/seaweedfs/seaweedfs/weed/filer" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/spf13/viper" "google.golang.org/grpc" - "reflect" - "strings" - "time" "github.com/seaweedfs/seaweedfs/weed/pb" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" @@ -100,8 +101,8 @@ func runFilerMetaBackup(cmd *Command, args []string) bool { time.Sleep(1747 * time.Millisecond) } } - - return true + // Unreachable: satisfies bool return type signature for daemon function + return false } func (metaBackup *FilerMetaBackupOptions) initStore(v *viper.Viper) error { @@ -186,8 +187,6 @@ func (metaBackup *FilerMetaBackupOptions) streamMetadataBackup() error { println("+", util.FullPath(message.NewParentPath).Child(message.NewEntry.Name)) return store.InsertEntry(ctx, filer.FromPbEntry(message.NewParentPath, message.NewEntry)) } - - return nil } processEventFnWithOffset := pb.AddOffsetFunc(eachEntryFunc, 3*time.Second, func(counter int64, lastTsNs int64) error { diff --git a/weed/command/filer_sync.go b/weed/command/filer_sync.go index 8f752b6d7..5663558f2 100644 --- a/weed/command/filer_sync.go +++ b/weed/command/filer_sync.go @@ -262,8 +262,6 @@ func runFilerSynchronize(cmd *Command, args []string) bool { } select {} - - return true } // initOffsetFromTsMs Initialize offset diff --git a/weed/filer/mongodb/mongodb_store_kv.go b/weed/filer/mongodb/mongodb_store_kv.go index 13d2dd08c..193bb247b 100644 --- a/weed/filer/mongodb/mongodb_store_kv.go +++ b/weed/filer/mongodb/mongodb_store_kv.go @@ -18,8 +18,15 @@ func (store *MongodbStore) KvPut(ctx context.Context, key []byte, value []byte) c := store.connect.Database(store.database).Collection(store.collectionName) opts := options.Update().SetUpsert(true) - filter := bson.D{{"directory", dir}, {"name", name}} - update := bson.D{{"$set", bson.D{{"meta", value}}}} + filter := bson.D{ + {Key: "directory", Value: dir}, + {Key: "name", Value: name}, + } + update := bson.D{ + {Key: "$set", Value: bson.D{ + {Key: "meta", Value: value}, + }}, + } _, err = c.UpdateOne(ctx, filter, update, opts) diff --git a/weed/filer/redis3/ItemList.go b/weed/filer/redis3/ItemList.go index 9e38089a7..05457e596 100644 --- a/weed/filer/redis3/ItemList.go +++ b/weed/filer/redis3/ItemList.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "github.com/redis/go-redis/v9" "github.com/seaweedfs/seaweedfs/weed/util/skiplist" ) @@ -313,8 +314,6 @@ func (nl *ItemList) DeleteName(name string) error { // no action to take return nil } - - return nil } func (nl *ItemList) ListNames(startFrom string, visitNamesFn func(name string) bool) error { diff --git a/weed/iam/integration/iam_integration_test.go b/weed/iam/integration/iam_integration_test.go index d413c3936..830fc50de 100644 --- a/weed/iam/integration/iam_integration_test.go +++ b/weed/iam/integration/iam_integration_test.go @@ -378,8 +378,8 @@ func setupIntegratedIAMSystem(t *testing.T) *IAMManager { // Configure and initialize config := &IAMConfig{ STS: &sts.STSConfig{ - TokenDuration: sts.FlexibleDuration{time.Hour}, - MaxSessionLength: sts.FlexibleDuration{time.Hour * 12}, + TokenDuration: sts.FlexibleDuration{Duration: time.Hour}, + MaxSessionLength: sts.FlexibleDuration{Duration: time.Hour * 12}, Issuer: "test-sts", SigningKey: []byte("test-signing-key-32-characters-long"), }, diff --git a/weed/iam/integration/role_store_test.go b/weed/iam/integration/role_store_test.go index 716eef3c2..597577a42 100644 --- a/weed/iam/integration/role_store_test.go +++ b/weed/iam/integration/role_store_test.go @@ -89,8 +89,8 @@ func TestDistributedIAMManagerWithRoleStore(t *testing.T) { // Create IAM manager with role store configuration config := &IAMConfig{ STS: &sts.STSConfig{ - TokenDuration: sts.FlexibleDuration{time.Duration(3600) * time.Second}, - MaxSessionLength: sts.FlexibleDuration{time.Duration(43200) * time.Second}, + TokenDuration: sts.FlexibleDuration{Duration: time.Duration(3600) * time.Second}, + MaxSessionLength: sts.FlexibleDuration{Duration: time.Duration(43200) * time.Second}, Issuer: "test-issuer", SigningKey: []byte("test-signing-key-32-characters-long"), }, diff --git a/weed/iamapi/iamapi_server.go b/weed/iamapi/iamapi_server.go index e3979e416..602c2f28e 100644 --- a/weed/iamapi/iamapi_server.go +++ b/weed/iamapi/iamapi_server.go @@ -23,6 +23,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/util" "github.com/seaweedfs/seaweedfs/weed/wdclient" "google.golang.org/grpc" + "google.golang.org/protobuf/proto" ) type IamS3ApiConfig interface { @@ -46,11 +47,11 @@ type IamServerOption struct { } type IamApiServer struct { - s3ApiConfig IamS3ApiConfig - iam *s3api.IdentityAccessManagement - shutdownContext context.Context - shutdownCancel context.CancelFunc - masterClient *wdclient.MasterClient + s3ApiConfig IamS3ApiConfig + iam *s3api.IdentityAccessManagement + shutdownContext context.Context + shutdownCancel context.CancelFunc + masterClient *wdclient.MasterClient } var s3ApiConfigure IamS3ApiConfig @@ -63,19 +64,19 @@ func NewIamApiServerWithStore(router *mux.Router, option *IamServerOption, expli if len(option.Filers) == 0 { return nil, fmt.Errorf("at least one filer address is required") } - + masterClient := wdclient.NewMasterClient(option.GrpcDialOption, "", "iam", "", "", "", *pb.NewServiceDiscoveryFromMap(option.Masters)) - + // Create a cancellable context for the master client connection // This allows graceful shutdown via Shutdown() method shutdownCtx, shutdownCancel := context.WithCancel(context.Background()) - + // Start KeepConnectedToMaster for volume location lookups // IAM config files are typically small and inline, but if they ever have chunks, // ReadEntry→StreamContent needs masterClient for volume lookups glog.V(0).Infof("IAM API starting master client connection for volume location lookups") go masterClient.KeepConnectedToMaster(shutdownCtx) - + configure := &IamS3ApiConfigure{ option: option, masterClient: masterClient, @@ -143,7 +144,8 @@ func (iama *IamS3ApiConfigure) GetS3ApiConfigurationFromCredentialManager(s3cfg if err != nil { return fmt.Errorf("failed to load configuration from credential manager: %w", err) } - *s3cfg = *config + // Use proto.Merge to avoid copying the sync.Mutex embedded in the message + proto.Merge(s3cfg, config) return nil } diff --git a/weed/mount/inode_to_path.go b/weed/mount/inode_to_path.go index 4a01e30e7..444c1930a 100644 --- a/weed/mount/inode_to_path.go +++ b/weed/mount/inode_to_path.go @@ -59,15 +59,13 @@ func NewInodeToPath(root util.FullPath, ttlSec int) *InodeToPath { // EnsurePath make sure the full path is tracked, used by symlink. func (i *InodeToPath) EnsurePath(path util.FullPath, isDirectory bool) bool { - for { - dir, _ := path.DirAndName() - if dir == "/" { - return true - } - if i.EnsurePath(util.FullPath(dir), true) { - i.Lookup(path, time.Now().Unix(), isDirectory, false, 0, false) - return true - } + dir, _ := path.DirAndName() + if dir == "/" { + return true + } + if i.EnsurePath(util.FullPath(dir), true) { + i.Lookup(path, time.Now().Unix(), isDirectory, false, 0, false) + return true } return false } diff --git a/weed/mq/broker/broker_grpc_pub_balancer.go b/weed/mq/broker/broker_grpc_pub_balancer.go index 8327ead7d..0234d3b2e 100644 --- a/weed/mq/broker/broker_grpc_pub_balancer.go +++ b/weed/mq/broker/broker_grpc_pub_balancer.go @@ -2,6 +2,7 @@ package broker import ( "fmt" + "github.com/seaweedfs/seaweedfs/weed/mq/pub_balancer" "github.com/seaweedfs/seaweedfs/weed/pb/mq_pb" "google.golang.org/grpc/codes" @@ -44,6 +45,4 @@ func (b *MessageQueueBroker) PublisherToPubBalancer(stream mq_pb.SeaweedMessagin // glog.V(4).Infof("received from %v: %+v", initMessage.Broker, receivedStats) } } - - return nil } diff --git a/weed/mq/client/agent_client/subscribe_session.go b/weed/mq/client/agent_client/subscribe_session.go index f9803b66b..9632f9e25 100644 --- a/weed/mq/client/agent_client/subscribe_session.go +++ b/weed/mq/client/agent_client/subscribe_session.go @@ -3,6 +3,7 @@ package agent_client import ( "context" "fmt" + "github.com/seaweedfs/seaweedfs/weed/mq/topic" "github.com/seaweedfs/seaweedfs/weed/pb/mq_agent_pb" "github.com/seaweedfs/seaweedfs/weed/pb/schema_pb" @@ -76,12 +77,11 @@ func (a *SubscribeSession) SubscribeMessageRecord( for { resp, err := a.stream.Recv() if err != nil { + if onCompletionFn != nil { + onCompletionFn() + } return err } onEachMessageFn(resp.Key, resp.Value) } - if onCompletionFn != nil { - onCompletionFn() - } - return nil } diff --git a/weed/mq/client/sub_client/connect_to_sub_coordinator.go b/weed/mq/client/sub_client/connect_to_sub_coordinator.go index e88aaca2f..6ca205c69 100644 --- a/weed/mq/client/sub_client/connect_to_sub_coordinator.go +++ b/weed/mq/client/sub_client/connect_to_sub_coordinator.go @@ -1,10 +1,11 @@ package sub_client import ( + "time" + "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb" "github.com/seaweedfs/seaweedfs/weed/pb/mq_pb" - "time" ) func (sub *TopicSubscriber) doKeepConnectedToSubCoordinator() { @@ -94,8 +95,6 @@ func (sub *TopicSubscriber) doKeepConnectedToSubCoordinator() { sub.brokerPartitionAssignmentChan <- resp glog.V(0).Infof("Received assignment: %+v", resp) } - - return nil }) } glog.V(0).Infof("subscriber %s/%s waiting for more assignments", sub.ContentConfig.Topic, sub.SubscriberConfig.ConsumerGroup) diff --git a/weed/mq/kafka/integration/broker_client_subscribe.go b/weed/mq/kafka/integration/broker_client_subscribe.go index e9884ea4d..129b77844 100644 --- a/weed/mq/kafka/integration/broker_client_subscribe.go +++ b/weed/mq/kafka/integration/broker_client_subscribe.go @@ -44,12 +44,14 @@ func (bc *BrokerClient) CreateFreshSubscriber(topic string, partition int32, sta stream, err := bc.client.SubscribeMessage(subscriberCtx) if err != nil { + subscriberCancel() return nil, fmt.Errorf("failed to create subscribe stream: %v", err) } // Get the actual partition assignment from the broker actualPartition, err := bc.getActualPartitionAssignment(topic, partition) if err != nil { + subscriberCancel() return nil, fmt.Errorf("failed to get actual partition assignment for subscribe: %v", err) } @@ -63,6 +65,7 @@ func (bc *BrokerClient) CreateFreshSubscriber(topic string, partition int32, sta topic, partition, startOffset, offsetType, consumerGroup, consumerID) if err := stream.Send(initReq); err != nil { + subscriberCancel() return nil, fmt.Errorf("failed to send subscribe init: %v", err) } @@ -163,12 +166,14 @@ func (bc *BrokerClient) GetOrCreateSubscriber(topic string, partition int32, sta stream, err := bc.client.SubscribeMessage(subscriberCtx) if err != nil { + subscriberCancel() return nil, fmt.Errorf("failed to create subscribe stream: %v", err) } - // Get the actual partition assignment from the broker instead of using Kafka partition mapping + // Get the actual partition assignment from the broker actualPartition, err := bc.getActualPartitionAssignment(topic, partition) if err != nil { + subscriberCancel() return nil, fmt.Errorf("failed to get actual partition assignment for subscribe: %v", err) } @@ -198,6 +203,7 @@ func (bc *BrokerClient) GetOrCreateSubscriber(topic string, partition int32, sta // Send init message using the actual partition structure that the broker allocated initReq := createSubscribeInitMessage(topic, actualPartition, offsetValue, offsetType, consumerGroup, consumerID) if err := stream.Send(initReq); err != nil { + subscriberCancel() return nil, fmt.Errorf("failed to send subscribe init: %v", err) } diff --git a/weed/mq/schema/schema_builder.go b/weed/mq/schema/schema_builder.go index 13f8af185..6d8852a3e 100644 --- a/weed/mq/schema/schema_builder.go +++ b/weed/mq/schema/schema_builder.go @@ -8,19 +8,19 @@ import ( var ( // Basic scalar types - TypeBoolean = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{schema_pb.ScalarType_BOOL}} - TypeInt32 = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{schema_pb.ScalarType_INT32}} - TypeInt64 = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{schema_pb.ScalarType_INT64}} - TypeFloat = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{schema_pb.ScalarType_FLOAT}} - TypeDouble = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{schema_pb.ScalarType_DOUBLE}} - TypeBytes = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{schema_pb.ScalarType_BYTES}} - TypeString = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{schema_pb.ScalarType_STRING}} + TypeBoolean = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_BOOL}} + TypeInt32 = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_INT32}} + TypeInt64 = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_INT64}} + TypeFloat = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_FLOAT}} + TypeDouble = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_DOUBLE}} + TypeBytes = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_BYTES}} + TypeString = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_STRING}} // Parquet logical types - TypeTimestamp = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{schema_pb.ScalarType_TIMESTAMP}} - TypeDate = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{schema_pb.ScalarType_DATE}} - TypeDecimal = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{schema_pb.ScalarType_DECIMAL}} - TypeTime = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{schema_pb.ScalarType_TIME}} + TypeTimestamp = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_TIMESTAMP}} + TypeDate = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_DATE}} + TypeDecimal = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_DECIMAL}} + TypeTime = &schema_pb.Type{Kind: &schema_pb.Type_ScalarType{ScalarType: schema_pb.ScalarType_TIME}} ) type RecordTypeBuilder struct { diff --git a/weed/query/sqltypes/unsafe.go b/weed/query/sqltypes/unsafe.go index e322c92ce..a387b7525 100644 --- a/weed/query/sqltypes/unsafe.go +++ b/weed/query/sqltypes/unsafe.go @@ -1,7 +1,6 @@ package sqltypes import ( - "reflect" "unsafe" ) @@ -10,11 +9,7 @@ func BytesToString(b []byte) (s string) { if len(b) == 0 { return "" } - - bh := (*reflect.SliceHeader)(unsafe.Pointer(&b)) - sh := reflect.StringHeader{Data: bh.Data, Len: bh.Len} - - return *(*string)(unsafe.Pointer(&sh)) + return unsafe.String(unsafe.SliceData(b), len(b)) } // StringToBytes casts string to slice without copy @@ -22,9 +17,5 @@ func StringToBytes(s string) []byte { if len(s) == 0 { return []byte{} } - - sh := (*reflect.StringHeader)(unsafe.Pointer(&s)) - bh := reflect.SliceHeader{Data: sh.Data, Len: sh.Len, Cap: sh.Len} - - return *(*[]byte)(unsafe.Pointer(&bh)) + return unsafe.Slice(unsafe.StringData(s), len(s)) } diff --git a/weed/s3api/s3_end_to_end_test.go b/weed/s3api/s3_end_to_end_test.go index 75c76b278..5c08551c7 100644 --- a/weed/s3api/s3_end_to_end_test.go +++ b/weed/s3api/s3_end_to_end_test.go @@ -310,8 +310,8 @@ func setupCompleteS3IAMSystem(t *testing.T) (http.Handler, *integration.IAMManag // Initialize with test configuration config := &integration.IAMConfig{ STS: &sts.STSConfig{ - TokenDuration: sts.FlexibleDuration{time.Hour}, - MaxSessionLength: sts.FlexibleDuration{time.Hour * 12}, + TokenDuration: sts.FlexibleDuration{Duration: time.Hour}, + MaxSessionLength: sts.FlexibleDuration{Duration: time.Hour * 12}, Issuer: "test-sts", SigningKey: []byte("test-signing-key-32-characters-long"), }, diff --git a/weed/s3api/s3_iam_simple_test.go b/weed/s3api/s3_iam_simple_test.go index 41dbbbed8..f0f6a8f62 100644 --- a/weed/s3api/s3_iam_simple_test.go +++ b/weed/s3api/s3_iam_simple_test.go @@ -25,8 +25,8 @@ func TestS3IAMMiddleware(t *testing.T) { // Initialize with test configuration config := &integration.IAMConfig{ STS: &sts.STSConfig{ - TokenDuration: sts.FlexibleDuration{time.Hour}, - MaxSessionLength: sts.FlexibleDuration{time.Hour * 12}, + TokenDuration: sts.FlexibleDuration{Duration: time.Hour}, + MaxSessionLength: sts.FlexibleDuration{Duration: time.Hour * 12}, Issuer: "test-sts", SigningKey: []byte("test-signing-key-32-characters-long"), }, diff --git a/weed/s3api/s3_jwt_auth_test.go b/weed/s3api/s3_jwt_auth_test.go index 0e74aea01..b2b169ae7 100644 --- a/weed/s3api/s3_jwt_auth_test.go +++ b/weed/s3api/s3_jwt_auth_test.go @@ -292,8 +292,8 @@ func setupTestIAMManager(t *testing.T) *integration.IAMManager { // Initialize with test configuration config := &integration.IAMConfig{ STS: &sts.STSConfig{ - TokenDuration: sts.FlexibleDuration{time.Hour}, - MaxSessionLength: sts.FlexibleDuration{time.Hour * 12}, + TokenDuration: sts.FlexibleDuration{Duration: time.Hour}, + MaxSessionLength: sts.FlexibleDuration{Duration: time.Hour * 12}, Issuer: "test-sts", SigningKey: []byte("test-signing-key-32-characters-long"), }, diff --git a/weed/s3api/s3_multipart_iam_test.go b/weed/s3api/s3_multipart_iam_test.go index 725bd0304..7169891c0 100644 --- a/weed/s3api/s3_multipart_iam_test.go +++ b/weed/s3api/s3_multipart_iam_test.go @@ -480,8 +480,8 @@ func setupTestIAMManagerForMultipart(t *testing.T) *integration.IAMManager { // Initialize with test configuration config := &integration.IAMConfig{ STS: &sts.STSConfig{ - TokenDuration: sts.FlexibleDuration{time.Hour}, - MaxSessionLength: sts.FlexibleDuration{time.Hour * 12}, + TokenDuration: sts.FlexibleDuration{Duration: time.Hour}, + MaxSessionLength: sts.FlexibleDuration{Duration: time.Hour * 12}, Issuer: "test-sts", SigningKey: []byte("test-signing-key-32-characters-long"), }, diff --git a/weed/s3api/s3_presigned_url_iam_test.go b/weed/s3api/s3_presigned_url_iam_test.go index b8da33053..2a2686f7b 100644 --- a/weed/s3api/s3_presigned_url_iam_test.go +++ b/weed/s3api/s3_presigned_url_iam_test.go @@ -444,8 +444,8 @@ func setupTestIAMManagerForPresigned(t *testing.T) *integration.IAMManager { // Initialize with test configuration config := &integration.IAMConfig{ STS: &sts.STSConfig{ - TokenDuration: sts.FlexibleDuration{time.Hour}, - MaxSessionLength: sts.FlexibleDuration{time.Hour * 12}, + TokenDuration: sts.FlexibleDuration{Duration: time.Hour}, + MaxSessionLength: sts.FlexibleDuration{Duration: time.Hour * 12}, Issuer: "test-sts", SigningKey: []byte("test-signing-key-32-characters-long"), }, diff --git a/weed/s3api/s3_token_differentiation_test.go b/weed/s3api/s3_token_differentiation_test.go index cf61703ad..0fc520293 100644 --- a/weed/s3api/s3_token_differentiation_test.go +++ b/weed/s3api/s3_token_differentiation_test.go @@ -19,8 +19,8 @@ func TestS3IAMIntegration_isSTSIssuer(t *testing.T) { stsConfig := &sts.STSConfig{ Issuer: testIssuer, SigningKey: []byte("test-signing-key-32-characters-long"), - TokenDuration: sts.FlexibleDuration{time.Hour}, - MaxSessionLength: sts.FlexibleDuration{12 * time.Hour}, // Required field + TokenDuration: sts.FlexibleDuration{Duration: time.Hour}, + MaxSessionLength: sts.FlexibleDuration{Duration: 12 * time.Hour}, // Required field } // Initialize STS service with config (this sets the Config field) diff --git a/weed/server/volume_grpc_client_to_master.go b/weed/server/volume_grpc_client_to_master.go index 9c2f8b213..5022a9ede 100644 --- a/weed/server/volume_grpc_client_to_master.go +++ b/weed/server/volume_grpc_client_to_master.go @@ -219,7 +219,7 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp DataCenter: dataCenter, Rack: rack, NewVolumes: []*master_pb.VolumeShortInformationMessage{ - &volumeMessage, + &volumeMessage, // volumeMessage is already a copy from the channel receive }, } glog.V(0).Infof("volume server %s:%d adds volume %d", vs.store.Ip, vs.store.Port, volumeMessage.Id) @@ -234,7 +234,7 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp DataCenter: dataCenter, Rack: rack, NewEcShards: []*master_pb.VolumeEcShardInformationMessage{ - &ecShardMessage, + &ecShardMessage, // ecShardMessage is already a copy from the channel receive }, } glog.V(0).Infof("volume server %s:%d adds ec shard %d:%d", vs.store.Ip, vs.store.Port, ecShardMessage.Id, @@ -250,7 +250,7 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp DataCenter: dataCenter, Rack: rack, DeletedVolumes: []*master_pb.VolumeShortInformationMessage{ - &volumeMessage, + &volumeMessage, // volumeMessage is already a copy from the channel receive }, } glog.V(0).Infof("volume server %s:%d deletes volume %d", vs.store.Ip, vs.store.Port, volumeMessage.Id) @@ -265,7 +265,7 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp DataCenter: dataCenter, Rack: rack, DeletedEcShards: []*master_pb.VolumeEcShardInformationMessage{ - &ecShardMessage, + &ecShardMessage, // ecShardMessage is already a copy from the channel receive }, } glog.V(0).Infof("volume server %s:%d deletes ec shard %d:%d", vs.store.Ip, vs.store.Port, ecShardMessage.Id, diff --git a/weed/util/skiplist/name_list.go b/weed/util/skiplist/name_list.go index c291484fb..517173424 100644 --- a/weed/util/skiplist/name_list.go +++ b/weed/util/skiplist/name_list.go @@ -272,8 +272,6 @@ func (nl *NameList) DeleteName(name string) error { // case 3.2 update prevNode return nl.skipList.ChangeValue(prevNode, prevNameBatch.ToBytes()) } - - return nil } func (nl *NameList) ListNames(startFrom string, visitNamesFn func(name string) bool) error { From 5469b7c58f389ae95b9352c07a772386fd7e3048 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Wed, 24 Dec 2025 10:29:30 -0800 Subject: [PATCH 21/66] fix: resolve inconsistent S3 API authorization for DELETE operations (issue #7864) (#7865) * fix(iam): add support for fine-grained S3 actions in IAM policies Add support for fine-grained S3 actions like s3:DeleteObject, s3:PutObject, and other specific S3 actions in IAM policy mapping. Previously, only coarse-grained action patterns (Put*, Get*, etc.) were supported, causing IAM policies with specific actions to be rejected with 'not a valid action' error. Fixes issue #7864 part 2: s3:DeleteObject IAM action is now supported. Changes: - Extended MapToStatementAction() to handle fine-grained S3 actions - Maps S3-specific actions to appropriate internal action constants - Supports 30+ S3 actions including DeleteObject, PutObject, GetObject, etc. * fix(s3api): correct resource ARN generation for subpath permissions Fix convertSingleAction() to properly handle subpath patterns in legacy actions. Previously, when a user was granted Write permission to a subpath (e.g., Write:bucket/sub_path/*), the resource ARN was incorrectly generated, causing DELETE operations to be denied even though s3:DeleteObject was included in the Write action. The fix: - Extract bucket name and prefix path separately from patterns like 'bucket/prefix/*' - Generate correct S3 ARN format: arn:aws:s3:::bucket/prefix/* - Ensure all permission checks (Read, Write, List, Tagging, etc.) work correctly with subpaths - Support nested paths (e.g., bucket/a/b/c/*) Fixes issue #7864 part 1: Write permission on subpath now allows DELETE. Example: - Permission: Write:mybucket/documents/* - Objects can now be: PUT, DELETE, or ACL operations on mybucket/documents/* - Objects outside this path are still denied * test(iam): add tests for fine-grained S3 action mappings Extend TestMapToStatementAction with test cases for fine-grained S3 actions: - s3:DeleteObject - s3:PutObject - s3:GetObject - s3:ListBucket - s3:PutObjectAcl - s3:GetObjectAcl Ensures the new action mapping support is working correctly. * test(s3api): add comprehensive tests for subpath permission handling Add new test file with comprehensive tests for convertSingleAction(): 1. TestConvertSingleActionDeleteObject: Verifies s3:DeleteObject is included in Write actions (fixes issue #7864 part 2) 2. TestConvertSingleActionSubpath: Tests proper resource ARN generation for different permission patterns: - Bucket-level: Write:mybucket -> arn:aws:s3:::mybucket - Wildcard: Write:mybucket/* -> arn:aws:s3:::mybucket/* - Subpath: Write:mybucket/sub_path/* -> arn:aws:s3:::mybucket/sub_path/* - Nested: Read:mybucket/documents/* -> arn:aws:s3:::mybucket/documents/* 3. TestConvertSingleActionSubpathDeleteAllowed: Specifically validates that subpath Write permissions allow DELETE operations 4. TestConvertSingleActionNestedPaths: Tests deeply nested path handling (e.g., bucket/a/b/c/*) All tests pass and validate the fixes for issue #7864. * fix: address review comments from PR #7865 - Fix critical bug: use parsed 'bucket' instead of 'resourcePattern' for GetObjectRetention, GetObjectLegalHold, and PutObjectLegalHold actions to avoid malformed ARNs like arn:aws:s3:::bucket/*/* - Refactor large switch statement in MapToStatementAction() into a map-based lookup for better performance and maintainability * fmt * refactor: extract extractBucketAndPrefix helper and simplify convertSingleAction - Extract extractBucketAndPrefix as a package-level function for better testability and reusability - Remove unused bucketName parameter from convertSingleAction signature - Update GetResourcesFromLegacyAction to use the extracted helper for consistent ARN generation - Update all call sites in tests to match new function signature - All tests pass and module compiles without errors * fix: use extracted bucket variable consistently in all ARN generation branches Replace resourcePattern with extracted bucket variable in else branches and bucket-level cases to avoid malformed ARNs like 'arn:aws:s3:::mybucket/*/*': - Read case: bucket-level else branch - Write case: bucket-level else branch - Admin case: both bucket and object ARNs - List case: bucket-level else branch - GetBucketObjectLockConfiguration: bucket extraction - PutBucketObjectLockConfiguration: bucket extraction This ensures consistent ARN format: arn:aws:s3:::bucket or arn:aws:s3:::bucket/* * fix: address remaining review comments from PR #7865 High priority fixes: - Write action on bucket-level now generates arn:aws:s3:::mybucket/* instead of arn:aws:s3:::mybucket to enable object-level S3 actions (s3:PutObject, s3:DeleteObject) - GetResourcesFromLegacyAction now generates both bucket and object ARNs for /* patterns to maintain backward compatibility with mixed action groups Medium priority improvements: - Remove unused 'bucket' field from TestConvertSingleActionSubpath test struct - Update test to use assert.ElementsMatch instead of assert.Contains for more comprehensive resource ARN validation - Clarify test expectations with expectedResources slice instead of single expectedResource All tests pass, compilation verified * test: improve TestConvertSingleActionNestedPaths with comprehensive assertions Update test to use assert.ElementsMatch for more robust resource ARN verification: - Change struct from single expectedResource to expectedResources slice - Update Read nested path test to expect both bucket and prefix ARNs - Use assert.ElementsMatch to verify all generated resources match exactly - Provides complete coverage for nested path handling This matches the improvement pattern used in TestConvertSingleActionSubpath * refactor: simplify S3 action map and improve resource ARN detection - Refactor fineGrainedActionMap to use init() function for programmatic population of both prefixed (s3:Action) and unprefixed (Action) variants, eliminating 70+ duplicate entries - Add buildObjectResourceArn() helper to eliminate duplicated resource ARN generation logic across switch cases - Fix bucket vs object-level access detection: only use HasSuffix(/*) check instead of Contains('/') which incorrectly matched patterns like 'bucket/prefix' without wildcard - Apply buildObjectResourceArn() consistently to Tagging, BypassGovernanceRetention, GetObjectRetention, PutObjectRetention, GetObjectLegalHold, and PutObjectLegalHold cases * fmt * fix: generate object-level ARNs for bucket-level read access When bucket-level read access is granted (e.g., 'Read:mybucket'), generate both bucket and object ARNs so that object-level actions like s3:GetObject can properly authorize. Similarly, in GetResourcesFromLegacyAction, bucket-level patterns should generate both ARN levels for consistency with patterns that include wildcards. This ensures that users with bucket-level permissions can read objects, not just the bucket itself. * fix: address Copilot code review comments - Remove unused bucketName parameter from ConvertIdentityToPolicy signature - Update all callers in examples.go and engine_test.go - Bucket is now extracted from action string itself - Update extractBucketAndPrefix documentation - Add nested path example (bucket/a/b/c/*) - Clarify that prefix can contain multiple path segments - Make GetResourcesFromLegacyAction action-aware - Different action types have different resource requirements - List actions only need bucket ARN (bucket-only operations) - Read/Write/Tagging actions need both bucket and object ARNs - Aligns with convertSingleAction logic for consistency All tests pass successfully * test: add comprehensive tests for GetResourcesFromLegacyAction consistency - Add TestGetResourcesFromLegacyAction to verify action-aware resource generation - Validate consistency with convertSingleAction for all action types: * List actions: bucket-only ARNs (s3:ListBucket is bucket-level operation) * Read actions: both bucket and object ARNs * Write actions: object-only ARNs (subpaths) or object ARNs (bucket-level) * Admin actions: both bucket and object ARNs - Update GetResourcesFromLegacyAction to generate Admin ARNs consistent with convertSingleAction - All tests pass (35+ test cases across integration_test.go) * refactor: eliminate code duplication in GetResourcesFromLegacyAction - Simplify GetResourcesFromLegacyAction to delegate to convertSingleAction - Eliminates ~50 lines of duplicated action-type-specific logic - Ensures single source of truth for resource ARN generation - Improves maintainability: changes to ARN logic only need to be made in one place - All tests pass: any inconsistencies would be caught immediately - Addresses Gemini Code Assist review comment about code duplication * fix: remove fragile 'dummy' action type in CreatePolicyFromLegacyIdentity - Replace hardcoded 'dummy:' prefix with proper representative action type - Use first valid action type from the action list to determine resource requirements - Ensures GetResourcesFromLegacyAction receives a valid action type - Prevents silent failures when convertSingleAction encounters unknown action - Improves code clarity: explains why representative action type is needed - All tests pass: policy engine tests verify correct behavior * security: prevent privilege escalation in Admin action subpath handling - Admin action with subpath (e.g., Admin:bucket/admin/*) now correctly restricts to the specified subpath instead of granting full bucket access - If prefix exists: resources restricted to bucket + bucket/prefix/* - If no prefix: full bucket access (unchanged behavior for root Admin) - Added test case Admin_on_subpath to validate the security fix - All 40+ policy engine tests pass * refactor: address Copilot code review comments on S3 authorization - Fix GetObjectTagging mapping: change from ACTION_READ to ACTION_TAGGING (tagging operations should not be classified as general read operations) - Enhance extractBucketAndPrefix edge case handling: - Add input validation (reject empty strings, whitespace, slash-only) - Normalize double slashes and trailing slashes - Return empty bucket/prefix for invalid patterns - Prevent generation of malformed ARNs - Separate Read action from ListBucket (AWS S3 IAM semantics): - ListBucket is a bucket-level operation, not object-level - Read action now only includes s3:GetObject, s3:GetObjectVersion - This aligns with AWS S3 IAM policy best practices - Update buildObjectResourceArn to handle invalid bucket names gracefully: - Return empty slice if bucket is empty after validation - Prevents malformed ARN generation - Add comprehensive TestExtractBucketAndPrefixEdgeCases with 8 test cases: - Validates empty strings, whitespace, special characters - Confirms proper normalization of double/trailing slashes - Ensures robust parsing of nested paths - Update existing tests to reflect removed ListBucket from Read action All 40+ policy engine tests pass * fix: aggregate resource ARNs from all action types in CreatePolicyFromLegacyIdentity CRITICAL FIX: The previous implementation incorrectly used a single representative action type to determine resource ARNs when multiple legacy actions targeted the same resource pattern. This caused incorrect policy generation when action types with different resource requirements (e.g., List vs Write) were grouped together. Example of the bug: - Input: List:mybucket/path/*, Write:mybucket/path/* - Old behavior: Used only List's resources (bucket-level ARN) - Result: Policy had Write actions (s3:PutObject) but only bucket ARN - Consequence: s3:PutObject would be denied due to missing object-level ARN Solution: - Iterate through all action types for a given resource pattern - For each action type, call GetResourcesFromLegacyAction to get required ARNs - Aggregate all ARNs into a set to eliminate duplicates - Use the merged set for the final policy statement - Admin action short-circuits (always includes full permissions) Example of correct behavior: - Input: List:mybucket/path/*, Write:mybucket/path/* - New behavior: Aggregates both List and Write resource requirements - Result: Policy has Write actions with BOTH bucket and object-level ARNs - Outcome: s3:PutObject works correctly on mybucket/path/* Added TestCreatePolicyFromLegacyIdentityMultipleActions with 3 test cases: 1. List + Write on subpath: verifies bucket + object ARN aggregation 2. Read + Tagging on bucket: verifies action-specific ARN combinations 3. Admin with other actions: verifies Admin dominates resource ARNs All 45+ policy engine tests pass * fix: remove bucket-level ARN from Read action for consistency ISSUE: The Read action was including bucket-level ARNs (arn:aws:s3:::bucket) even though the only S3 actions in Read are s3:GetObject and s3:GetObjectVersion, which are object-level operations. This created a mismatch between the actions and resources in the policy statement. ROOT CAUSE: s3:ListBucket was previously removed from the Read action, but the bucket-level ARN was not removed, creating an inconsistency. SOLUTION: Update Read action to only generate object-level ARNs using buildObjectResourceArn, consistent with how Write and Tagging actions work. This ensures: - Read:mybucket generates arn:aws:s3:::mybucket/* (not bucket ARN) - Read:bucket/prefix/* generates arn:aws:s3:::bucket/prefix/* (object-level only) - Consistency: same actions, same resources, same logic across all object operations Updated test expectations: - TestConvertSingleActionSubpath: Read_on_subpath now expects only object ARN - TestConvertSingleActionNestedPaths: Read nested path now expects only object ARN - TestConvertIdentityToPolicy: Read resources now 1 instead of 2 - TestCreatePolicyFromLegacyIdentityMultipleActions: Read+Tagging aggregates to 1 ARN All 45+ policy engine tests pass * doc * fmt * fix: address Copilot code review on Read action consistency and missing S3 action mappings - Clarify MapToStatementAction comment to reflect exact lookup (not pattern matching) - Add missing S3 actions to baseS3ActionMap: - ListBucketVersions, ListAllMyBuckets for bucket operations - GetBucketCors, PutBucketCors, DeleteBucketCors for CORS - GetBucketNotification, PutBucketNotification for notifications - GetBucketObjectLockConfiguration, PutBucketObjectLockConfiguration for object lock - GetObjectVersionTagging for version tagging - GetObjectVersionAcl, PutBucketAcl for ACL operations - PutBucketTagging, DeleteBucketTagging for bucket tagging - Fix Read action scope inconsistency with GetActionMappings(): - Previously: only included GetObject, GetObjectVersion - Now: includes full Read set (14 actions) from GetActionMappings - Includes both bucket-level (ListBucket*, GetBucket*) and object-level (GetObject*) ARNs - Bucket ARN enables ListBucket operations, object ARN enables GetObject operations - Update all test expectations: - TestConvertSingleActionSubpath: Read now returns 2 ARNs (bucket + objects) - TestConvertSingleActionNestedPaths: Read nested path now includes bucket ARN - TestGetResourcesFromLegacyAction: Read test cases updated for consistency - TestCreatePolicyFromLegacyIdentityMultipleActions: Read_and_Tagging now returns 2 ARNs - TestConvertIdentityToPolicy: Updated to expect 14 Read actions and 2 resources Fixes: Inconsistency between convertSingleAction Read case and GetActionMappings function * fmt * fix: align convertSingleAction with GetActionMappings and add bucket validation - Fix Write action: now includes all 16 actions from GetActionMappings (object and bucket operations) - Includes PutBucketVersioning, PutBucketCors, PutBucketAcl, PutBucketTagging, etc. - Generates both bucket and object ARNs to support bucket-level operations - Fix List action: add ListAllMyBuckets from GetActionMappings - Previously: ListBucket, ListBucketVersions - Now: ListBucket, ListBucketVersions, ListAllMyBuckets - Add bucket validation to prevent malformed ARNs with empty bucket - Fix Tagging action: include bucket-level tagging operations - Previously: only object-level (GetObjectTagging, PutObjectTagging, DeleteObjectTagging) - Now: includes bucket-level (GetBucketTagging, PutBucketTagging, DeleteBucketTagging) - Generates both bucket and object ARNs to support bucket-level operations - Add bucket validation to prevent malformed ARNs: - Admin: return error if bucket is empty - List: generate empty resources if bucket is empty - Tagging: check bucket before generating ARNs - GetBucketObjectLockConfiguration, PutBucketObjectLockConfiguration: validate bucket - Fix TrimRight issue in extractBucketAndPrefix: - Change from strings.TrimRight(pattern, "/") to remove only one trailing slash - Prevents loss of prefix when pattern has multiple trailing slashes - Update all test cases: - TestConvertSingleActionSubpath: Write now returns 16 actions and bucket+object ARNs - TestConvertSingleActionNestedPaths: Write includes bucket ARN - TestGetResourcesFromLegacyAction: Updated Write and Tagging expectations - TestCreatePolicyFromLegacyIdentityMultipleActions: Updated action/resource counts Fixes: Inconsistencies between convertSingleAction and GetActionMappings for Write/List/Tagging actions * fmt * fix: resolve ListMultipartUploads/ListParts mapping inconsistency and add action validation - Fix ListMultipartUploads and ListParts mapping in helpers.go: - Changed from ACTION_LIST to ACTION_WRITE for consistency with GetActionMappings - These operations are part of the multipart write workflow and should map to Write action - Prevents inconsistent behavior when same actions processed through different code paths - Add documentation to clarify multipart operations in Write action: - Explain why ListMultipartUploads and ListParts are part of Write permissions - These are required for meaningful multipart upload workflow management - Add action validation to CreatePolicyFromLegacyIdentity: - Validates action format before processing using ValidateActionMapping - Logs warnings for invalid actions instead of silently skipping them - Provides clearer error messages when invalid action types are used - Ensures users know when their intended permissions weren't applied - Consistent with ConvertLegacyActions validation approach Fixes: Inconsistent action type mappings and silent failure for invalid actions --- weed/iam/helpers.go | 108 ++++-- weed/iam/helpers_test.go | 17 +- weed/s3api/policy_engine/engine_test.go | 24 +- weed/s3api/policy_engine/examples.go | 2 +- weed/s3api/policy_engine/integration.go | 312 +++++++++++----- weed/s3api/policy_engine/integration_test.go | 373 +++++++++++++++++++ 6 files changed, 708 insertions(+), 128 deletions(-) create mode 100644 weed/s3api/policy_engine/integration_test.go diff --git a/weed/iam/helpers.go b/weed/iam/helpers.go index e05219149..ef94940af 100644 --- a/weed/iam/helpers.go +++ b/weed/iam/helpers.go @@ -68,30 +68,94 @@ func StringSlicesEqual(a, b []string) bool { return true } -// MapToStatementAction converts a policy statement action to an S3 action constant. -func MapToStatementAction(action string) string { - switch action { - case StatementActionAdmin: - return s3_constants.ACTION_ADMIN - case StatementActionWrite: - return s3_constants.ACTION_WRITE - case StatementActionWriteAcp: - return s3_constants.ACTION_WRITE_ACP - case StatementActionRead: - return s3_constants.ACTION_READ - case StatementActionReadAcp: - return s3_constants.ACTION_READ_ACP - case StatementActionList: - return s3_constants.ACTION_LIST - case StatementActionTagging: - return s3_constants.ACTION_TAGGING - case StatementActionDelete: - return s3_constants.ACTION_DELETE_BUCKET - default: - return "" +// fineGrainedActionMap maps S3 IAM action names to internal S3 action constants. +// Supports both prefixed (e.g., "s3:DeleteObject") and unprefixed (e.g., "DeleteObject") formats. +// Populated in init() to avoid duplication of prefixed/unprefixed variants. +var fineGrainedActionMap = map[string]string{ + // Coarse-grained actions (populated statically) + StatementActionAdmin: s3_constants.ACTION_ADMIN, + StatementActionWrite: s3_constants.ACTION_WRITE, + StatementActionWriteAcp: s3_constants.ACTION_WRITE_ACP, + StatementActionRead: s3_constants.ACTION_READ, + StatementActionReadAcp: s3_constants.ACTION_READ_ACP, + StatementActionList: s3_constants.ACTION_LIST, + StatementActionTagging: s3_constants.ACTION_TAGGING, + StatementActionDelete: s3_constants.ACTION_DELETE_BUCKET, +} + +// baseS3ActionMap defines the base S3 actions that will be populated with both +// prefixed (s3:Action) and unprefixed (Action) variants in init(). +var baseS3ActionMap = map[string]string{ + // Object operations + "DeleteObject": s3_constants.ACTION_WRITE, + "PutObject": s3_constants.ACTION_WRITE, + "GetObject": s3_constants.ACTION_READ, + "DeleteObjectVersion": s3_constants.ACTION_WRITE, + "GetObjectVersion": s3_constants.ACTION_READ, + // Tagging operations + "GetObjectTagging": s3_constants.ACTION_TAGGING, + "GetObjectVersionTagging": s3_constants.ACTION_TAGGING, + "PutObjectTagging": s3_constants.ACTION_TAGGING, + "DeleteObjectTagging": s3_constants.ACTION_TAGGING, + "GetBucketTagging": s3_constants.ACTION_TAGGING, + "PutBucketTagging": s3_constants.ACTION_TAGGING, + "DeleteBucketTagging": s3_constants.ACTION_TAGGING, + // ACL operations + "PutObjectAcl": s3_constants.ACTION_WRITE_ACP, + "GetObjectAcl": s3_constants.ACTION_READ_ACP, + "GetObjectVersionAcl": s3_constants.ACTION_READ_ACP, + "PutBucketAcl": s3_constants.ACTION_WRITE_ACP, + "GetBucketAcl": s3_constants.ACTION_READ_ACP, + // Bucket operations + "DeleteBucket": s3_constants.ACTION_DELETE_BUCKET, + "DeleteBucketPolicy": s3_constants.ACTION_ADMIN, + "ListBucket": s3_constants.ACTION_LIST, + "ListBucketVersions": s3_constants.ACTION_LIST, + "ListAllMyBuckets": s3_constants.ACTION_LIST, + "GetBucketLocation": s3_constants.ACTION_READ, + "GetBucketVersioning": s3_constants.ACTION_READ, + "PutBucketVersioning": s3_constants.ACTION_WRITE, + "GetBucketCors": s3_constants.ACTION_READ, + "PutBucketCors": s3_constants.ACTION_WRITE, + "DeleteBucketCors": s3_constants.ACTION_WRITE, + "GetBucketNotification": s3_constants.ACTION_READ, + "PutBucketNotification": s3_constants.ACTION_WRITE, + "GetBucketObjectLockConfiguration": s3_constants.ACTION_READ, + "PutBucketObjectLockConfiguration": s3_constants.ACTION_WRITE, + // Multipart upload operations + "CreateMultipartUpload": s3_constants.ACTION_WRITE, + "UploadPart": s3_constants.ACTION_WRITE, + "CompleteMultipartUpload": s3_constants.ACTION_WRITE, + "AbortMultipartUpload": s3_constants.ACTION_WRITE, + "ListMultipartUploads": s3_constants.ACTION_WRITE, + "ListParts": s3_constants.ACTION_WRITE, + // Retention and legal hold operations + "GetObjectRetention": s3_constants.ACTION_READ, + "PutObjectRetention": s3_constants.ACTION_WRITE, + "GetObjectLegalHold": s3_constants.ACTION_READ, + "PutObjectLegalHold": s3_constants.ACTION_WRITE, + "BypassGovernanceRetention": s3_constants.ACTION_WRITE, +} + +func init() { + // Populate both prefixed and unprefixed variants for all base S3 actions. + // This avoids duplication and makes it easy to add new actions in one place. + for action, constant := range baseS3ActionMap { + fineGrainedActionMap[action] = constant // unprefixed: "DeleteObject" + fineGrainedActionMap["s3:"+action] = constant // prefixed: "s3:DeleteObject" } } +// MapToStatementAction converts a policy statement action to an S3 action constant. +// It handles both coarse-grained statement actions (e.g., "Put*", "Get*") and +// fine-grained S3 actions (e.g., "s3:DeleteObject", "s3:PutObject") via exact lookup. +func MapToStatementAction(action string) string { + if val, ok := fineGrainedActionMap[action]; ok { + return val + } + return "" +} + // MapToIdentitiesAction converts an S3 action constant to a policy statement action. func MapToIdentitiesAction(action string) string { switch action { @@ -123,5 +187,3 @@ func MaskAccessKey(accessKeyId string) string { } return accessKeyId } - - diff --git a/weed/iam/helpers_test.go b/weed/iam/helpers_test.go index 6b6744475..f2da389c3 100644 --- a/weed/iam/helpers_test.go +++ b/weed/iam/helpers_test.go @@ -88,12 +88,25 @@ func TestMapToStatementAction(t *testing.T) { {StatementActionRead, s3_constants.ACTION_READ}, {StatementActionList, s3_constants.ACTION_LIST}, {StatementActionDelete, s3_constants.ACTION_DELETE_BUCKET}, + // Test fine-grained S3 action mappings (Issue #7864) + {"DeleteObject", s3_constants.ACTION_WRITE}, + {"s3:DeleteObject", s3_constants.ACTION_WRITE}, + {"PutObject", s3_constants.ACTION_WRITE}, + {"s3:PutObject", s3_constants.ACTION_WRITE}, + {"GetObject", s3_constants.ACTION_READ}, + {"s3:GetObject", s3_constants.ACTION_READ}, + {"ListBucket", s3_constants.ACTION_LIST}, + {"s3:ListBucket", s3_constants.ACTION_LIST}, + {"PutObjectAcl", s3_constants.ACTION_WRITE_ACP}, + {"s3:PutObjectAcl", s3_constants.ACTION_WRITE_ACP}, + {"GetObjectAcl", s3_constants.ACTION_READ_ACP}, + {"s3:GetObjectAcl", s3_constants.ACTION_READ_ACP}, {"unknown", ""}, } for _, test := range tests { result := MapToStatementAction(test.input) - assert.Equal(t, test.expected, result) + assert.Equal(t, test.expected, result, "Failed for input: %s", test.input) } } @@ -132,5 +145,3 @@ func TestMaskAccessKey(t *testing.T) { assert.Equal(t, test.expected, result) } } - - diff --git a/weed/s3api/policy_engine/engine_test.go b/weed/s3api/policy_engine/engine_test.go index 4de537ac1..f8297b56b 100644 --- a/weed/s3api/policy_engine/engine_test.go +++ b/weed/s3api/policy_engine/engine_test.go @@ -232,7 +232,7 @@ func TestConvertIdentityToPolicy(t *testing.T) { "Admin:bucket2", } - policy, err := ConvertIdentityToPolicy(identityActions, "bucket1") + policy, err := ConvertIdentityToPolicy(identityActions) if err != nil { t.Fatalf("Failed to convert identity to policy: %v", err) } @@ -252,13 +252,17 @@ func TestConvertIdentityToPolicy(t *testing.T) { } actions := normalizeToStringSlice(stmt.Action) - if len(actions) != 3 { - t.Errorf("Expected 3 read actions, got %d", len(actions)) + // Read action now includes: GetObject, GetObjectVersion, ListBucket, ListBucketVersions, + // GetObjectAcl, GetObjectVersionAcl, GetObjectTagging, GetObjectVersionTagging, + // GetBucketLocation, GetBucketVersioning, GetBucketAcl, GetBucketCors, GetBucketTagging, GetBucketNotification + if len(actions) != 14 { + t.Errorf("Expected 14 read actions, got %d: %v", len(actions), actions) } resources := normalizeToStringSlice(stmt.Resource) + // Read action now includes both bucket ARN (for ListBucket*) and object ARN (for GetObject*) if len(resources) != 2 { - t.Errorf("Expected 2 resources, got %d", len(resources)) + t.Errorf("Expected 2 resources (bucket and bucket/*), got %d: %v", len(resources), resources) } } @@ -797,8 +801,8 @@ func TestExistingObjectTagCondition(t *testing.T) { t.Run(tt.name, func(t *testing.T) { args := &PolicyEvaluationArgs{ Action: "s3:GetObject", - Resource: "arn:aws:s3:::test-bucket/test-object", - Principal: "*", + Resource: "arn:aws:s3:::test-bucket/test-object", + Principal: "*", ObjectEntry: tagsToEntry(tt.objectTags), } @@ -874,8 +878,8 @@ func TestExistingObjectTagConditionMultipleTags(t *testing.T) { t.Run(tt.name, func(t *testing.T) { args := &PolicyEvaluationArgs{ Action: "s3:GetObject", - Resource: "arn:aws:s3:::test-bucket/test-object", - Principal: "*", + Resource: "arn:aws:s3:::test-bucket/test-object", + Principal: "*", ObjectEntry: tagsToEntry(tt.objectTags), } @@ -946,8 +950,8 @@ func TestExistingObjectTagDenyPolicy(t *testing.T) { t.Run(tt.name, func(t *testing.T) { args := &PolicyEvaluationArgs{ Action: "s3:GetObject", - Resource: "arn:aws:s3:::test-bucket/test-object", - Principal: "*", + Resource: "arn:aws:s3:::test-bucket/test-object", + Principal: "*", ObjectEntry: tagsToEntry(tt.objectTags), } diff --git a/weed/s3api/policy_engine/examples.go b/weed/s3api/policy_engine/examples.go index 6f14127f3..c4cea6512 100644 --- a/weed/s3api/policy_engine/examples.go +++ b/weed/s3api/policy_engine/examples.go @@ -391,7 +391,7 @@ func ExampleLegacyIntegration() { } // Convert to policy - policy, err := ConvertIdentityToPolicy(legacyActions, "bucket1") + policy, err := ConvertIdentityToPolicy(legacyActions) if err != nil { fmt.Printf("Error converting identity to policy: %v\n", err) return diff --git a/weed/s3api/policy_engine/integration.go b/weed/s3api/policy_engine/integration.go index 17bcec112..8c9495088 100644 --- a/weed/s3api/policy_engine/integration.go +++ b/weed/s3api/policy_engine/integration.go @@ -123,12 +123,70 @@ func (p *PolicyBackedIAM) evaluateUsingPolicyConversion(action, bucketName, obje return false } +// extractBucketAndPrefix extracts bucket name and prefix from a resource pattern. +// Examples: +// +// "bucket" -> bucket="bucket", prefix="" +// "bucket/*" -> bucket="bucket", prefix="" +// "bucket/prefix/*" -> bucket="bucket", prefix="prefix" +// "bucket/a/b/c/*" -> bucket="bucket", prefix="a/b/c" +func extractBucketAndPrefix(pattern string) (string, string) { + // Validate input + pattern = strings.TrimSpace(pattern) + if pattern == "" || pattern == "/" { + return "", "" + } + + // Remove trailing /* if present + pattern = strings.TrimSuffix(pattern, "/*") + + // Remove a single trailing slash to avoid empty path segments + if strings.HasSuffix(pattern, "/") { + pattern = pattern[:len(pattern)-1] + } + if pattern == "" { + return "", "" + } + + // Split on the first / + parts := strings.SplitN(pattern, "/", 2) + bucket := strings.TrimSpace(parts[0]) + if bucket == "" { + return "", "" + } + + if len(parts) == 1 { + // No slash, entire pattern is bucket + return bucket, "" + } + // Has slash, first part is bucket, rest is prefix + prefix := strings.Trim(parts[1], "/") + return bucket, prefix +} + +// buildObjectResourceArn generates ARNs for object-level access. +// It properly handles both bucket-level (all objects) and prefix-level access. +// Returns empty slice if bucket is invalid to prevent generating malformed ARNs. +func buildObjectResourceArn(resourcePattern string) []string { + bucket, prefix := extractBucketAndPrefix(resourcePattern) + // If bucket is empty, the pattern is invalid; avoid generating malformed ARNs + if bucket == "" { + return []string{} + } + if prefix != "" { + // Prefix-based access: restrict to objects under this prefix + return []string{fmt.Sprintf("arn:aws:s3:::%s/%s/*", bucket, prefix)} + } + // Bucket-level access: all objects in bucket + return []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)} +} + // ConvertIdentityToPolicy converts a legacy identity action to an AWS policy -func ConvertIdentityToPolicy(identityActions []string, bucketName string) (*PolicyDocument, error) { +func ConvertIdentityToPolicy(identityActions []string) (*PolicyDocument, error) { statements := make([]PolicyStatement, 0) for _, action := range identityActions { - stmt, err := convertSingleAction(action, bucketName) + stmt, err := convertSingleAction(action) if err != nil { glog.Warningf("Failed to convert action %s: %v", action, err) continue @@ -148,8 +206,9 @@ func ConvertIdentityToPolicy(identityActions []string, bucketName string) (*Poli }, nil } -// convertSingleAction converts a single legacy action to a policy statement -func convertSingleAction(action, bucketName string) (*PolicyStatement, error) { +// convertSingleAction converts a single legacy action to a policy statement. +// action format: "ActionType:ResourcePattern" (e.g., "Write:bucket/prefix/*") +func convertSingleAction(action string) (*PolicyStatement, error) { parts := strings.Split(action, ":") if len(parts) != 2 { return nil, fmt.Errorf("invalid action format: %s", action) @@ -163,111 +222,158 @@ func convertSingleAction(action, bucketName string) (*PolicyStatement, error) { switch actionType { case "Read": - s3Actions = []string{"s3:GetObject", "s3:GetObjectVersion", "s3:ListBucket"} - if strings.HasSuffix(resourcePattern, "/*") { - // Object-level read access - bucket := strings.TrimSuffix(resourcePattern, "/*") - resources = []string{ - fmt.Sprintf("arn:aws:s3:::%s", bucket), - fmt.Sprintf("arn:aws:s3:::%s/*", bucket), - } + // Read includes both object-level (GetObject, GetObjectAcl, GetObjectTagging, GetObjectVersions) + // and bucket-level operations (ListBucket, GetBucketLocation, GetBucketVersioning, GetBucketCors, etc.) + s3Actions = []string{ + "s3:GetObject", + "s3:GetObjectVersion", + "s3:GetObjectAcl", + "s3:GetObjectVersionAcl", + "s3:GetObjectTagging", + "s3:GetObjectVersionTagging", + "s3:ListBucket", + "s3:ListBucketVersions", + "s3:GetBucketLocation", + "s3:GetBucketVersioning", + "s3:GetBucketAcl", + "s3:GetBucketCors", + "s3:GetBucketTagging", + "s3:GetBucketNotification", + } + bucket, _ := extractBucketAndPrefix(resourcePattern) + objectResources := buildObjectResourceArn(resourcePattern) + // Include both bucket ARN (for ListBucket* and Get*Bucket operations) and object ARNs (for GetObject* operations) + if bucket != "" { + resources = append([]string{fmt.Sprintf("arn:aws:s3:::%s", bucket)}, objectResources...) } else { - // Bucket-level read access - resources = []string{fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)} + resources = objectResources } case "Write": - s3Actions = []string{"s3:PutObject", "s3:DeleteObject", "s3:PutObjectAcl"} - if strings.HasSuffix(resourcePattern, "/*") { - // Object-level write access - bucket := strings.TrimSuffix(resourcePattern, "/*") - resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)} + // Write includes object-level writes (PutObject, DeleteObject, PutObjectAcl, DeleteObjectVersion, DeleteObjectTagging, PutObjectTagging) + // and bucket-level writes (PutBucketVersioning, PutBucketCors, DeleteBucketCors, PutBucketAcl, PutBucketTagging, DeleteBucketTagging, PutBucketNotification) + // and multipart upload operations (AbortMultipartUpload, ListMultipartUploads, ListParts). + // ListMultipartUploads and ListParts are included because they are part of the multipart upload workflow + // and require Write permissions to be meaningful (no point listing uploads if you can't abort/complete them). + s3Actions = []string{ + "s3:PutObject", + "s3:PutObjectAcl", + "s3:PutObjectTagging", + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:DeleteObjectTagging", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploads", + "s3:ListParts", + "s3:PutBucketAcl", + "s3:PutBucketCors", + "s3:PutBucketTagging", + "s3:PutBucketNotification", + "s3:PutBucketVersioning", + "s3:DeleteBucketTagging", + "s3:DeleteBucketCors", + } + bucket, _ := extractBucketAndPrefix(resourcePattern) + objectResources := buildObjectResourceArn(resourcePattern) + // Include bucket ARN so bucket-level write operations (e.g., PutBucketVersioning, PutBucketCors) + // have the correct resource, while still allowing object-level writes. + if bucket != "" { + resources = append([]string{fmt.Sprintf("arn:aws:s3:::%s", bucket)}, objectResources...) } else { - // Bucket-level write access - resources = []string{fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)} + resources = objectResources } case "Admin": s3Actions = []string{"s3:*"} - resources = []string{ - fmt.Sprintf("arn:aws:s3:::%s", resourcePattern), - fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern), + bucket, prefix := extractBucketAndPrefix(resourcePattern) + if bucket == "" { + // Invalid pattern, return error + return nil, fmt.Errorf("Admin action requires a valid bucket name") } - - case "List": - s3Actions = []string{"s3:ListBucket", "s3:ListBucketVersions"} - if strings.HasSuffix(resourcePattern, "/*") { - // Object-level list access - extract bucket from "bucket/prefix/*" pattern - patternWithoutWildcard := strings.TrimSuffix(resourcePattern, "/*") - parts := strings.SplitN(patternWithoutWildcard, "/", 2) - bucket := parts[0] + if prefix != "" { + // Subpath admin access: restrict to objects under this prefix + resources = []string{ + fmt.Sprintf("arn:aws:s3:::%s", bucket), + fmt.Sprintf("arn:aws:s3:::%s/%s/*", bucket, prefix), + } + } else { + // Bucket-level admin access: full bucket permissions resources = []string{ fmt.Sprintf("arn:aws:s3:::%s", bucket), fmt.Sprintf("arn:aws:s3:::%s/*", bucket), } + } + + case "List": + // List includes bucket listing operations and also ListAllMyBuckets + s3Actions = []string{"s3:ListBucket", "s3:ListBucketVersions", "s3:ListAllMyBuckets"} + // ListBucket actions only require bucket ARN, not object-level ARNs + bucket, _ := extractBucketAndPrefix(resourcePattern) + if bucket != "" { + resources = []string{fmt.Sprintf("arn:aws:s3:::%s", bucket)} } else { - // Bucket-level list access - resources = []string{fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)} + // Invalid pattern, return empty resources to fail validation + resources = []string{} } case "Tagging": - s3Actions = []string{"s3:GetObjectTagging", "s3:PutObjectTagging", "s3:DeleteObjectTagging"} - resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern)} + // Tagging includes both object-level and bucket-level tagging operations + s3Actions = []string{ + "s3:GetObjectTagging", + "s3:PutObjectTagging", + "s3:DeleteObjectTagging", + "s3:GetBucketTagging", + "s3:PutBucketTagging", + "s3:DeleteBucketTagging", + } + bucket, _ := extractBucketAndPrefix(resourcePattern) + objectResources := buildObjectResourceArn(resourcePattern) + // Include bucket ARN so bucket-level tagging operations have the correct resource + if bucket != "" { + resources = append([]string{fmt.Sprintf("arn:aws:s3:::%s", bucket)}, objectResources...) + } else { + resources = objectResources + } case "BypassGovernanceRetention": s3Actions = []string{"s3:BypassGovernanceRetention"} - if strings.HasSuffix(resourcePattern, "/*") { - // Object-level bypass governance access - bucket := strings.TrimSuffix(resourcePattern, "/*") - resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)} - } else { - // Bucket-level bypass governance access - resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern)} - } + resources = buildObjectResourceArn(resourcePattern) case "GetObjectRetention": s3Actions = []string{"s3:GetObjectRetention"} - if strings.HasSuffix(resourcePattern, "/*") { - bucket := strings.TrimSuffix(resourcePattern, "/*") - resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)} - } else { - resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern)} - } + resources = buildObjectResourceArn(resourcePattern) case "PutObjectRetention": s3Actions = []string{"s3:PutObjectRetention"} - if strings.HasSuffix(resourcePattern, "/*") { - bucket := strings.TrimSuffix(resourcePattern, "/*") - resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)} - } else { - resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern)} - } + resources = buildObjectResourceArn(resourcePattern) case "GetObjectLegalHold": s3Actions = []string{"s3:GetObjectLegalHold"} - if strings.HasSuffix(resourcePattern, "/*") { - bucket := strings.TrimSuffix(resourcePattern, "/*") - resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)} - } else { - resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern)} - } + resources = buildObjectResourceArn(resourcePattern) case "PutObjectLegalHold": s3Actions = []string{"s3:PutObjectLegalHold"} - if strings.HasSuffix(resourcePattern, "/*") { - bucket := strings.TrimSuffix(resourcePattern, "/*") - resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", bucket)} - } else { - resources = []string{fmt.Sprintf("arn:aws:s3:::%s/*", resourcePattern)} - } + resources = buildObjectResourceArn(resourcePattern) case "GetBucketObjectLockConfiguration": s3Actions = []string{"s3:GetBucketObjectLockConfiguration"} - resources = []string{fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)} + bucket, _ := extractBucketAndPrefix(resourcePattern) + if bucket != "" { + resources = []string{fmt.Sprintf("arn:aws:s3:::%s", bucket)} + } else { + // Invalid pattern, return empty resources to fail validation + resources = []string{} + } case "PutBucketObjectLockConfiguration": s3Actions = []string{"s3:PutBucketObjectLockConfiguration"} - resources = []string{fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)} + bucket, _ := extractBucketAndPrefix(resourcePattern) + if bucket != "" { + resources = []string{fmt.Sprintf("arn:aws:s3:::%s", bucket)} + } else { + // Invalid pattern, return empty resources to fail validation + resources = []string{} + } default: return nil, fmt.Errorf("unknown action type: %s", actionType) @@ -416,27 +522,15 @@ func ConvertLegacyActions(legacyActions []string) ([]string, error) { return uniqueActions, nil } -// GetResourcesFromLegacyAction extracts resources from a legacy action +// GetResourcesFromLegacyAction extracts resources from a legacy action. +// It delegates to convertSingleAction to ensure consistent resource ARN generation +// across the codebase and avoid duplicating action-type-specific logic. func GetResourcesFromLegacyAction(legacyAction string) ([]string, error) { - parts := strings.Split(legacyAction, ":") - if len(parts) != 2 { - return nil, fmt.Errorf("invalid action format: %s", legacyAction) + stmt, err := convertSingleAction(legacyAction) + if err != nil { + return nil, err } - - resourcePattern := parts[1] - resources := make([]string, 0) - - if strings.HasSuffix(resourcePattern, "/*") { - // Object-level access - bucket := strings.TrimSuffix(resourcePattern, "/*") - resources = append(resources, fmt.Sprintf("arn:aws:s3:::%s", bucket)) - resources = append(resources, fmt.Sprintf("arn:aws:s3:::%s/*", bucket)) - } else { - // Bucket-level access - resources = append(resources, fmt.Sprintf("arn:aws:s3:::%s", resourcePattern)) - } - - return resources, nil + return stmt.Resource.Strings(), nil } // CreatePolicyFromLegacyIdentity creates a policy document from legacy identity actions @@ -447,6 +541,12 @@ func CreatePolicyFromLegacyIdentity(identityName string, actions []string) (*Pol resourceActions := make(map[string][]string) for _, action := range actions { + // Validate action format before processing + if err := ValidateActionMapping(action); err != nil { + glog.Warningf("Skipping invalid action %q for identity %q: %v", action, identityName, err) + continue + } + parts := strings.Split(action, ":") if len(parts) != 2 { continue @@ -464,23 +564,53 @@ func CreatePolicyFromLegacyIdentity(identityName string, actions []string) (*Pol // Create statements for each resource pattern for resourcePattern, actionTypes := range resourceActions { s3Actions := make([]string, 0) + resourceSet := make(map[string]struct{}) + // Collect S3 actions and aggregate resource ARNs from all action types. + // Different action types have different resource ARN requirements: + // - List: bucket-level ARNs only + // - Read/Write/Tagging: object-level ARNs + // - Admin: full bucket access + // We must merge all required ARNs for the combined policy statement. for _, actionType := range actionTypes { if actionType == "Admin" { s3Actions = []string{"s3:*"} + // Admin action determines the resources, so we can break after processing it. + res, err := GetResourcesFromLegacyAction(fmt.Sprintf("Admin:%s", resourcePattern)) + if err != nil { + glog.Warningf("Failed to get resources for Admin action on %s: %v", resourcePattern, err) + resourceSet = nil // Invalidate to skip this statement + break + } + for _, r := range res { + resourceSet[r] = struct{}{} + } break } if mapped, exists := GetActionMappings()[actionType]; exists { s3Actions = append(s3Actions, mapped...) + res, err := GetResourcesFromLegacyAction(fmt.Sprintf("%s:%s", actionType, resourcePattern)) + if err != nil { + glog.Warningf("Failed to get resources for %s action on %s: %v", actionType, resourcePattern, err) + resourceSet = nil // Invalidate to skip this statement + break + } + for _, r := range res { + resourceSet[r] = struct{}{} + } } } - resources, err := GetResourcesFromLegacyAction(fmt.Sprintf("dummy:%s", resourcePattern)) - if err != nil { + if resourceSet == nil || len(s3Actions) == 0 { continue } + resources := make([]string, 0, len(resourceSet)) + for r := range resourceSet { + resources = append(resources, r) + } + statement := PolicyStatement{ Sid: fmt.Sprintf("%s-%s", identityName, strings.ReplaceAll(resourcePattern, "/", "-")), Effect: PolicyEffectAllow, diff --git a/weed/s3api/policy_engine/integration_test.go b/weed/s3api/policy_engine/integration_test.go new file mode 100644 index 000000000..6e74e51cb --- /dev/null +++ b/weed/s3api/policy_engine/integration_test.go @@ -0,0 +1,373 @@ +package policy_engine + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestConvertSingleActionDeleteObject tests support for s3:DeleteObject action (Issue #7864) +func TestConvertSingleActionDeleteObject(t *testing.T) { + // Test that Write action includes DeleteObject S3 action + stmt, err := convertSingleAction("Write:bucket") + assert.NoError(t, err) + assert.NotNil(t, stmt) + + // Check that s3:DeleteObject is included in the actions + actions := stmt.Action.Strings() + assert.Contains(t, actions, "s3:DeleteObject", "Write action should include s3:DeleteObject") + assert.Contains(t, actions, "s3:PutObject", "Write action should include s3:PutObject") +} + +// TestConvertSingleActionSubpath tests subpath handling for legacy actions (Issue #7864) +func TestConvertSingleActionSubpath(t *testing.T) { + testCases := []struct { + name string + action string + expectedActions []string + expectedResources []string + description string + }{ + { + name: "Write_on_bucket", + action: "Write:mybucket", + expectedActions: []string{"s3:PutObject", "s3:DeleteObject", "s3:PutObjectAcl", "s3:DeleteObjectVersion", "s3:PutObjectTagging", "s3:DeleteObjectTagging", "s3:AbortMultipartUpload", "s3:ListMultipartUploads", "s3:ListParts", "s3:PutBucketAcl", "s3:PutBucketCors", "s3:PutBucketTagging", "s3:PutBucketNotification", "s3:PutBucketVersioning", "s3:DeleteBucketTagging", "s3:DeleteBucketCors"}, + expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/*"}, + description: "Write permission on bucket should include bucket and object ARNs", + }, + { + name: "Write_on_bucket_with_wildcard", + action: "Write:mybucket/*", + expectedActions: []string{"s3:PutObject", "s3:DeleteObject", "s3:PutObjectAcl", "s3:DeleteObjectVersion", "s3:PutObjectTagging", "s3:DeleteObjectTagging", "s3:AbortMultipartUpload", "s3:ListMultipartUploads", "s3:ListParts", "s3:PutBucketAcl", "s3:PutBucketCors", "s3:PutBucketTagging", "s3:PutBucketNotification", "s3:PutBucketVersioning", "s3:DeleteBucketTagging", "s3:DeleteBucketCors"}, + expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/*"}, + description: "Write permission with /* should include bucket and object ARNs", + }, + { + name: "Write_on_subpath", + action: "Write:mybucket/sub_path/*", + expectedActions: []string{"s3:PutObject", "s3:DeleteObject", "s3:PutObjectAcl", "s3:DeleteObjectVersion", "s3:PutObjectTagging", "s3:DeleteObjectTagging", "s3:AbortMultipartUpload", "s3:ListMultipartUploads", "s3:ListParts", "s3:PutBucketAcl", "s3:PutBucketCors", "s3:PutBucketTagging", "s3:PutBucketNotification", "s3:PutBucketVersioning", "s3:DeleteBucketTagging", "s3:DeleteBucketCors"}, + expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/sub_path/*"}, + description: "Write permission on subpath should include bucket and subpath objects ARNs", + }, + { + name: "Read_on_subpath", + action: "Read:mybucket/documents/*", + expectedActions: []string{"s3:GetObject", "s3:GetObjectVersion", "s3:ListBucket", "s3:ListBucketVersions", "s3:GetObjectAcl", "s3:GetObjectVersionAcl", "s3:GetObjectTagging", "s3:GetObjectVersionTagging", "s3:GetBucketLocation", "s3:GetBucketVersioning", "s3:GetBucketAcl", "s3:GetBucketCors", "s3:GetBucketTagging", "s3:GetBucketNotification"}, + expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/documents/*"}, + description: "Read permission on subpath should include bucket ARN and subpath objects", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + stmt, err := convertSingleAction(tc.action) + assert.NoError(t, err, tc.description) + assert.NotNil(t, stmt) + + // Check actions + actions := stmt.Action.Strings() + for _, expectedAction := range tc.expectedActions { + assert.Contains(t, actions, expectedAction, + "Action %s should be included for %s", expectedAction, tc.action) + } + + // Check resources - verify all expected resources are present + resources := stmt.Resource.Strings() + assert.ElementsMatch(t, resources, tc.expectedResources, + "Resources should match exactly for %s. Got %v, expected %v", tc.action, resources, tc.expectedResources) + }) + } +} + +// TestConvertSingleActionSubpathDeleteAllowed tests that DeleteObject works on subpaths +func TestConvertSingleActionSubpathDeleteAllowed(t *testing.T) { + // This test specifically addresses Issue #7864 part 1: + // "when a user is granted permission to a subpath, eg s3.configure -user someuser + // -actions Write -buckets some_bucket/sub_path/* -apply + // the user will only be able to put, but not delete object under somebucket/sub_path" + + stmt, err := convertSingleAction("Write:some_bucket/sub_path/*") + assert.NoError(t, err) + + // The fix: s3:DeleteObject should be in the allowed actions + actions := stmt.Action.Strings() + assert.Contains(t, actions, "s3:DeleteObject", + "Write permission on subpath should allow deletion of objects in that path") + + // The resource should be restricted to the subpath + resources := stmt.Resource.Strings() + assert.Contains(t, resources, "arn:aws:s3:::some_bucket/sub_path/*", + "Delete permission should apply to objects under the subpath") +} + +// TestConvertSingleActionNestedPaths tests deeply nested paths +func TestConvertSingleActionNestedPaths(t *testing.T) { + testCases := []struct { + action string + expectedResources []string + }{ + { + action: "Write:bucket/a/b/c/*", + expectedResources: []string{"arn:aws:s3:::bucket", "arn:aws:s3:::bucket/a/b/c/*"}, + }, + { + action: "Read:bucket/data/documents/2024/*", + expectedResources: []string{"arn:aws:s3:::bucket", "arn:aws:s3:::bucket/data/documents/2024/*"}, + }, + } + + for _, tc := range testCases { + stmt, err := convertSingleAction(tc.action) + assert.NoError(t, err) + + resources := stmt.Resource.Strings() + assert.ElementsMatch(t, resources, tc.expectedResources) + } +} + +// TestGetResourcesFromLegacyAction tests that GetResourcesFromLegacyAction generates +// action-appropriate resources consistent with convertSingleAction +func TestGetResourcesFromLegacyAction(t *testing.T) { + testCases := []struct { + name string + action string + expectedResources []string + description string + }{ + // List actions - bucket-only (no object ARNs) + { + name: "List_on_bucket", + action: "List:mybucket", + expectedResources: []string{"arn:aws:s3:::mybucket"}, + description: "List action should only have bucket ARN", + }, + { + name: "List_on_bucket_with_wildcard", + action: "List:mybucket/*", + expectedResources: []string{"arn:aws:s3:::mybucket"}, + description: "List action should only have bucket ARN regardless of wildcard", + }, + // Read actions - bucket and object-level ARNs (includes List* and Get* operations) + { + name: "Read_on_bucket", + action: "Read:mybucket", + expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/*"}, + description: "Read action should have both bucket and object ARNs", + }, + { + name: "Read_on_subpath", + action: "Read:mybucket/documents/*", + expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/documents/*"}, + description: "Read action on subpath should have bucket ARN and object ARN for subpath", + }, + // Write actions - bucket and object ARNs (includes bucket-level operations) + { + name: "Write_on_subpath", + action: "Write:mybucket/sub_path/*", + expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/sub_path/*"}, + description: "Write action should have bucket and object ARNs", + }, + // Admin actions - both bucket and object ARNs + { + name: "Admin_on_bucket", + action: "Admin:mybucket", + expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/*"}, + description: "Admin action should have both bucket and object ARNs", + }, + { + name: "Admin_on_subpath", + action: "Admin:mybucket/admin/section/*", + expectedResources: []string{"arn:aws:s3:::mybucket", "arn:aws:s3:::mybucket/admin/section/*"}, + description: "Admin action on subpath should restrict to subpath, preventing privilege escalation", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resources, err := GetResourcesFromLegacyAction(tc.action) + assert.NoError(t, err, tc.description) + assert.ElementsMatch(t, resources, tc.expectedResources, + "Resources should match expected. Got %v, expected %v", resources, tc.expectedResources) + + // Also verify consistency with convertSingleAction where applicable + stmt, err := convertSingleAction(tc.action) + assert.NoError(t, err) + + stmtResources := stmt.Resource.Strings() + assert.ElementsMatch(t, resources, stmtResources, + "GetResourcesFromLegacyAction should match convertSingleAction resources for %s", tc.action) + }) + } +} + +// TestExtractBucketAndPrefixEdgeCases validates edge case handling in extractBucketAndPrefix +func TestExtractBucketAndPrefixEdgeCases(t *testing.T) { + testCases := []struct { + name string + pattern string + expectedBucket string + expectedPrefix string + description string + }{ + { + name: "Empty string", + pattern: "", + expectedBucket: "", + expectedPrefix: "", + description: "Empty pattern should return empty strings", + }, + { + name: "Whitespace only", + pattern: " ", + expectedBucket: "", + expectedPrefix: "", + description: "Whitespace-only pattern should return empty strings", + }, + { + name: "Slash only", + pattern: "/", + expectedBucket: "", + expectedPrefix: "", + description: "Slash-only pattern should return empty strings", + }, + { + name: "Double slash prefix", + pattern: "bucket//prefix/*", + expectedBucket: "bucket", + expectedPrefix: "prefix", + description: "Double slash should be normalized (trailing slashes removed)", + }, + { + name: "Normal bucket", + pattern: "mybucket", + expectedBucket: "mybucket", + expectedPrefix: "", + description: "Bucket-only pattern should work correctly", + }, + { + name: "Bucket with prefix", + pattern: "mybucket/myprefix/*", + expectedBucket: "mybucket", + expectedPrefix: "myprefix", + description: "Bucket with prefix should be parsed correctly", + }, + { + name: "Nested prefix", + pattern: "mybucket/a/b/c/*", + expectedBucket: "mybucket", + expectedPrefix: "a/b/c", + description: "Nested prefix should be preserved", + }, + { + name: "Bucket with trailing slash", + pattern: "mybucket/", + expectedBucket: "mybucket", + expectedPrefix: "", + description: "Trailing slash on bucket should be normalized", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + bucket, prefix := extractBucketAndPrefix(tc.pattern) + assert.Equal(t, tc.expectedBucket, bucket, tc.description) + assert.Equal(t, tc.expectedPrefix, prefix, tc.description) + }) + } +} + +// TestCreatePolicyFromLegacyIdentityMultipleActions validates correct resource ARN aggregation +// when multiple action types target the same resource pattern +func TestCreatePolicyFromLegacyIdentityMultipleActions(t *testing.T) { + testCases := []struct { + name string + identityName string + actions []string + expectedStatements int + expectedActionsInStmt1 []string + expectedResourcesInStmt1 []string + description string + }{ + { + name: "List_and_Write_on_subpath", + identityName: "data-manager", + actions: []string{"List:mybucket/data/*", "Write:mybucket/data/*"}, + expectedStatements: 1, + expectedActionsInStmt1: []string{ + "s3:ListBucket", "s3:ListBucketVersions", "s3:ListAllMyBuckets", + "s3:PutObject", "s3:DeleteObject", "s3:PutObjectAcl", "s3:DeleteObjectVersion", + "s3:PutObjectTagging", "s3:DeleteObjectTagging", "s3:AbortMultipartUpload", + "s3:ListMultipartUploads", "s3:ListParts", "s3:PutBucketAcl", "s3:PutBucketCors", + "s3:PutBucketTagging", "s3:PutBucketNotification", "s3:PutBucketVersioning", + "s3:DeleteBucketTagging", "s3:DeleteBucketCors", + }, + expectedResourcesInStmt1: []string{ + "arn:aws:s3:::mybucket", // From List and Write actions + "arn:aws:s3:::mybucket/data/*", // From Write action + }, + description: "List + Write on same subpath should aggregate all actions and both bucket and object ARNs", + }, + { + name: "Read_and_Tagging_on_bucket", + identityName: "tag-reader", + actions: []string{"Read:mybucket", "Tagging:mybucket"}, + expectedStatements: 1, + expectedActionsInStmt1: []string{ + "s3:GetObject", "s3:GetObjectVersion", + "s3:ListBucket", "s3:ListBucketVersions", + "s3:GetObjectAcl", "s3:GetObjectVersionAcl", + "s3:GetObjectTagging", "s3:GetObjectVersionTagging", + "s3:PutObjectTagging", "s3:DeleteObjectTagging", + "s3:GetBucketLocation", "s3:GetBucketVersioning", + "s3:GetBucketAcl", "s3:GetBucketCors", "s3:GetBucketTagging", + "s3:GetBucketNotification", "s3:PutBucketTagging", "s3:DeleteBucketTagging", + }, + expectedResourcesInStmt1: []string{ + "arn:aws:s3:::mybucket", + "arn:aws:s3:::mybucket/*", + }, + description: "Read + Tagging on same bucket should aggregate all bucket and object-level actions and ARNs", + }, + { + name: "Admin_with_other_actions", + identityName: "admin-user", + actions: []string{"Admin:mybucket/admin/*", "Write:mybucket/admin/*"}, + expectedStatements: 1, + expectedActionsInStmt1: []string{"s3:*"}, + expectedResourcesInStmt1: []string{ + "arn:aws:s3:::mybucket", + "arn:aws:s3:::mybucket/admin/*", + }, + description: "Admin action should dominate and set s3:*, other actions still processed for resources", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + policy, err := CreatePolicyFromLegacyIdentity(tc.identityName, tc.actions) + assert.NoError(t, err, tc.description) + assert.NotNil(t, policy) + + // Check statement count + assert.Equal(t, tc.expectedStatements, len(policy.Statement), + "Expected %d statement(s), got %d", tc.expectedStatements, len(policy.Statement)) + + if tc.expectedStatements > 0 { + stmt := policy.Statement[0] + + // Check actions + actualActions := stmt.Action.Strings() + for _, expectedAction := range tc.expectedActionsInStmt1 { + assert.Contains(t, actualActions, expectedAction, + "Action %s should be included in statement", expectedAction) + } + + // Check resources - all expected resources should be present + actualResources := stmt.Resource.Strings() + assert.ElementsMatch(t, tc.expectedResourcesInStmt1, actualResources, + "Statement should aggregate all required resource ARNs. Got %v, expected %v", + actualResources, tc.expectedResourcesInStmt1) + } + }) + } +} From 26acebdef1ffa45da9bff392bef71394b273454c Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Wed, 24 Dec 2025 10:50:05 -0800 Subject: [PATCH 22/66] fix: restore TimeToFirstByte metric for S3 GetObject operations (issue #7869) (#7870) * fix(iam): add support for fine-grained S3 actions in IAM policies Add support for fine-grained S3 actions like s3:DeleteObject, s3:PutObject, and other specific S3 actions in IAM policy mapping. Previously, only coarse-grained action patterns (Put*, Get*, etc.) were supported, causing IAM policies with specific actions to be rejected with 'not a valid action' error. Fixes issue #7864 part 2: s3:DeleteObject IAM action is now supported. Changes: - Extended MapToStatementAction() to handle fine-grained S3 actions - Maps S3-specific actions to appropriate internal action constants - Supports 30+ S3 actions including DeleteObject, PutObject, GetObject, etc. * fix(s3api): correct resource ARN generation for subpath permissions Fix convertSingleAction() to properly handle subpath patterns in legacy actions. Previously, when a user was granted Write permission to a subpath (e.g., Write:bucket/sub_path/*), the resource ARN was incorrectly generated, causing DELETE operations to be denied even though s3:DeleteObject was included in the Write action. The fix: - Extract bucket name and prefix path separately from patterns like 'bucket/prefix/*' - Generate correct S3 ARN format: arn:aws:s3:::bucket/prefix/* - Ensure all permission checks (Read, Write, List, Tagging, etc.) work correctly with subpaths - Support nested paths (e.g., bucket/a/b/c/*) Fixes issue #7864 part 1: Write permission on subpath now allows DELETE. Example: - Permission: Write:mybucket/documents/* - Objects can now be: PUT, DELETE, or ACL operations on mybucket/documents/* - Objects outside this path are still denied * test(s3api): add comprehensive tests for subpath permission handling Add new test file with comprehensive tests for convertSingleAction(): 1. TestConvertSingleActionDeleteObject: Verifies s3:DeleteObject is included in Write actions (fixes issue #7864 part 2) 2. TestConvertSingleActionSubpath: Tests proper resource ARN generation for different permission patterns: - Bucket-level: Write:mybucket -> arn:aws:s3:::mybucket - Wildcard: Write:mybucket/* -> arn:aws:s3:::mybucket/* - Subpath: Write:mybucket/sub_path/* -> arn:aws:s3:::mybucket/sub_path/* - Nested: Read:mybucket/documents/* -> arn:aws:s3:::mybucket/documents/* 3. TestConvertSingleActionSubpathDeleteAllowed: Specifically validates that subpath Write permissions allow DELETE operations 4. TestConvertSingleActionNestedPaths: Tests deeply nested path handling (e.g., bucket/a/b/c/*) All tests pass and validate the fixes for issue #7864. * fix: address review comments from PR #7865 - Fix critical bug: use parsed 'bucket' instead of 'resourcePattern' for GetObjectRetention, GetObjectLegalHold, and PutObjectLegalHold actions to avoid malformed ARNs like arn:aws:s3:::bucket/*/* - Refactor large switch statement in MapToStatementAction() into a map-based lookup for better performance and maintainability * fmt * refactor: extract extractBucketAndPrefix helper and simplify convertSingleAction - Extract extractBucketAndPrefix as a package-level function for better testability and reusability - Remove unused bucketName parameter from convertSingleAction signature - Update GetResourcesFromLegacyAction to use the extracted helper for consistent ARN generation - Update all call sites in tests to match new function signature - All tests pass and module compiles without errors * fix: use extracted bucket variable consistently in all ARN generation branches Replace resourcePattern with extracted bucket variable in else branches and bucket-level cases to avoid malformed ARNs like 'arn:aws:s3:::mybucket/*/*': - Read case: bucket-level else branch - Write case: bucket-level else branch - Admin case: both bucket and object ARNs - List case: bucket-level else branch - GetBucketObjectLockConfiguration: bucket extraction - PutBucketObjectLockConfiguration: bucket extraction This ensures consistent ARN format: arn:aws:s3:::bucket or arn:aws:s3:::bucket/* * fix: address remaining review comments from PR #7865 High priority fixes: - Write action on bucket-level now generates arn:aws:s3:::mybucket/* instead of arn:aws:s3:::mybucket to enable object-level S3 actions (s3:PutObject, s3:DeleteObject) - GetResourcesFromLegacyAction now generates both bucket and object ARNs for /* patterns to maintain backward compatibility with mixed action groups Medium priority improvements: - Remove unused 'bucket' field from TestConvertSingleActionSubpath test struct - Update test to use assert.ElementsMatch instead of assert.Contains for more comprehensive resource ARN validation - Clarify test expectations with expectedResources slice instead of single expectedResource All tests pass, compilation verified * test: improve TestConvertSingleActionNestedPaths with comprehensive assertions Update test to use assert.ElementsMatch for more robust resource ARN verification: - Change struct from single expectedResource to expectedResources slice - Update Read nested path test to expect both bucket and prefix ARNs - Use assert.ElementsMatch to verify all generated resources match exactly - Provides complete coverage for nested path handling This matches the improvement pattern used in TestConvertSingleActionSubpath * refactor: simplify S3 action map and improve resource ARN detection - Refactor fineGrainedActionMap to use init() function for programmatic population of both prefixed (s3:Action) and unprefixed (Action) variants, eliminating 70+ duplicate entries - Add buildObjectResourceArn() helper to eliminate duplicated resource ARN generation logic across switch cases - Fix bucket vs object-level access detection: only use HasSuffix(/*) check instead of Contains('/') which incorrectly matched patterns like 'bucket/prefix' without wildcard - Apply buildObjectResourceArn() consistently to Tagging, BypassGovernanceRetention, GetObjectRetention, PutObjectRetention, GetObjectLegalHold, and PutObjectLegalHold cases * fmt * fix: generate object-level ARNs for bucket-level read access When bucket-level read access is granted (e.g., 'Read:mybucket'), generate both bucket and object ARNs so that object-level actions like s3:GetObject can properly authorize. Similarly, in GetResourcesFromLegacyAction, bucket-level patterns should generate both ARN levels for consistency with patterns that include wildcards. This ensures that users with bucket-level permissions can read objects, not just the bucket itself. * fix: address Copilot code review comments - Remove unused bucketName parameter from ConvertIdentityToPolicy signature - Update all callers in examples.go and engine_test.go - Bucket is now extracted from action string itself - Update extractBucketAndPrefix documentation - Add nested path example (bucket/a/b/c/*) - Clarify that prefix can contain multiple path segments - Make GetResourcesFromLegacyAction action-aware - Different action types have different resource requirements - List actions only need bucket ARN (bucket-only operations) - Read/Write/Tagging actions need both bucket and object ARNs - Aligns with convertSingleAction logic for consistency All tests pass successfully * test: add comprehensive tests for GetResourcesFromLegacyAction consistency - Add TestGetResourcesFromLegacyAction to verify action-aware resource generation - Validate consistency with convertSingleAction for all action types: * List actions: bucket-only ARNs (s3:ListBucket is bucket-level operation) * Read actions: both bucket and object ARNs * Write actions: object-only ARNs (subpaths) or object ARNs (bucket-level) * Admin actions: both bucket and object ARNs - Update GetResourcesFromLegacyAction to generate Admin ARNs consistent with convertSingleAction - All tests pass (35+ test cases across integration_test.go) * refactor: eliminate code duplication in GetResourcesFromLegacyAction - Simplify GetResourcesFromLegacyAction to delegate to convertSingleAction - Eliminates ~50 lines of duplicated action-type-specific logic - Ensures single source of truth for resource ARN generation - Improves maintainability: changes to ARN logic only need to be made in one place - All tests pass: any inconsistencies would be caught immediately - Addresses Gemini Code Assist review comment about code duplication * fix: remove fragile 'dummy' action type in CreatePolicyFromLegacyIdentity - Replace hardcoded 'dummy:' prefix with proper representative action type - Use first valid action type from the action list to determine resource requirements - Ensures GetResourcesFromLegacyAction receives a valid action type - Prevents silent failures when convertSingleAction encounters unknown action - Improves code clarity: explains why representative action type is needed - All tests pass: policy engine tests verify correct behavior * security: prevent privilege escalation in Admin action subpath handling - Admin action with subpath (e.g., Admin:bucket/admin/*) now correctly restricts to the specified subpath instead of granting full bucket access - If prefix exists: resources restricted to bucket + bucket/prefix/* - If no prefix: full bucket access (unchanged behavior for root Admin) - Added test case Admin_on_subpath to validate the security fix - All 40+ policy engine tests pass * refactor: address Copilot code review comments on S3 authorization - Fix GetObjectTagging mapping: change from ACTION_READ to ACTION_TAGGING (tagging operations should not be classified as general read operations) - Enhance extractBucketAndPrefix edge case handling: - Add input validation (reject empty strings, whitespace, slash-only) - Normalize double slashes and trailing slashes - Return empty bucket/prefix for invalid patterns - Prevent generation of malformed ARNs - Separate Read action from ListBucket (AWS S3 IAM semantics): - ListBucket is a bucket-level operation, not object-level - Read action now only includes s3:GetObject, s3:GetObjectVersion - This aligns with AWS S3 IAM policy best practices - Update buildObjectResourceArn to handle invalid bucket names gracefully: - Return empty slice if bucket is empty after validation - Prevents malformed ARN generation - Add comprehensive TestExtractBucketAndPrefixEdgeCases with 8 test cases: - Validates empty strings, whitespace, special characters - Confirms proper normalization of double/trailing slashes - Ensures robust parsing of nested paths - Update existing tests to reflect removed ListBucket from Read action All 40+ policy engine tests pass * fix: aggregate resource ARNs from all action types in CreatePolicyFromLegacyIdentity CRITICAL FIX: The previous implementation incorrectly used a single representative action type to determine resource ARNs when multiple legacy actions targeted the same resource pattern. This caused incorrect policy generation when action types with different resource requirements (e.g., List vs Write) were grouped together. Example of the bug: - Input: List:mybucket/path/*, Write:mybucket/path/* - Old behavior: Used only List's resources (bucket-level ARN) - Result: Policy had Write actions (s3:PutObject) but only bucket ARN - Consequence: s3:PutObject would be denied due to missing object-level ARN Solution: - Iterate through all action types for a given resource pattern - For each action type, call GetResourcesFromLegacyAction to get required ARNs - Aggregate all ARNs into a set to eliminate duplicates - Use the merged set for the final policy statement - Admin action short-circuits (always includes full permissions) Example of correct behavior: - Input: List:mybucket/path/*, Write:mybucket/path/* - New behavior: Aggregates both List and Write resource requirements - Result: Policy has Write actions with BOTH bucket and object-level ARNs - Outcome: s3:PutObject works correctly on mybucket/path/* Added TestCreatePolicyFromLegacyIdentityMultipleActions with 3 test cases: 1. List + Write on subpath: verifies bucket + object ARN aggregation 2. Read + Tagging on bucket: verifies action-specific ARN combinations 3. Admin with other actions: verifies Admin dominates resource ARNs All 45+ policy engine tests pass * fix: remove bucket-level ARN from Read action for consistency ISSUE: The Read action was including bucket-level ARNs (arn:aws:s3:::bucket) even though the only S3 actions in Read are s3:GetObject and s3:GetObjectVersion, which are object-level operations. This created a mismatch between the actions and resources in the policy statement. ROOT CAUSE: s3:ListBucket was previously removed from the Read action, but the bucket-level ARN was not removed, creating an inconsistency. SOLUTION: Update Read action to only generate object-level ARNs using buildObjectResourceArn, consistent with how Write and Tagging actions work. This ensures: - Read:mybucket generates arn:aws:s3:::mybucket/* (not bucket ARN) - Read:bucket/prefix/* generates arn:aws:s3:::bucket/prefix/* (object-level only) - Consistency: same actions, same resources, same logic across all object operations Updated test expectations: - TestConvertSingleActionSubpath: Read_on_subpath now expects only object ARN - TestConvertSingleActionNestedPaths: Read nested path now expects only object ARN - TestConvertIdentityToPolicy: Read resources now 1 instead of 2 - TestCreatePolicyFromLegacyIdentityMultipleActions: Read+Tagging aggregates to 1 ARN All 45+ policy engine tests pass * doc * fmt * fix: address Copilot code review on Read action consistency and missing S3 action mappings - Clarify MapToStatementAction comment to reflect exact lookup (not pattern matching) - Add missing S3 actions to baseS3ActionMap: - ListBucketVersions, ListAllMyBuckets for bucket operations - GetBucketCors, PutBucketCors, DeleteBucketCors for CORS - GetBucketNotification, PutBucketNotification for notifications - GetBucketObjectLockConfiguration, PutBucketObjectLockConfiguration for object lock - GetObjectVersionTagging for version tagging - GetObjectVersionAcl, PutBucketAcl for ACL operations - PutBucketTagging, DeleteBucketTagging for bucket tagging - Fix Read action scope inconsistency with GetActionMappings(): - Previously: only included GetObject, GetObjectVersion - Now: includes full Read set (14 actions) from GetActionMappings - Includes both bucket-level (ListBucket*, GetBucket*) and object-level (GetObject*) ARNs - Bucket ARN enables ListBucket operations, object ARN enables GetObject operations - Update all test expectations: - TestConvertSingleActionSubpath: Read now returns 2 ARNs (bucket + objects) - TestConvertSingleActionNestedPaths: Read nested path now includes bucket ARN - TestGetResourcesFromLegacyAction: Read test cases updated for consistency - TestCreatePolicyFromLegacyIdentityMultipleActions: Read_and_Tagging now returns 2 ARNs - TestConvertIdentityToPolicy: Updated to expect 14 Read actions and 2 resources Fixes: Inconsistency between convertSingleAction Read case and GetActionMappings function * fmt * fix: align convertSingleAction with GetActionMappings and add bucket validation - Fix Write action: now includes all 16 actions from GetActionMappings (object and bucket operations) - Includes PutBucketVersioning, PutBucketCors, PutBucketAcl, PutBucketTagging, etc. - Generates both bucket and object ARNs to support bucket-level operations - Fix List action: add ListAllMyBuckets from GetActionMappings - Previously: ListBucket, ListBucketVersions - Now: ListBucket, ListBucketVersions, ListAllMyBuckets - Add bucket validation to prevent malformed ARNs with empty bucket - Fix Tagging action: include bucket-level tagging operations - Previously: only object-level (GetObjectTagging, PutObjectTagging, DeleteObjectTagging) - Now: includes bucket-level (GetBucketTagging, PutBucketTagging, DeleteBucketTagging) - Generates both bucket and object ARNs to support bucket-level operations - Add bucket validation to prevent malformed ARNs: - Admin: return error if bucket is empty - List: generate empty resources if bucket is empty - Tagging: check bucket before generating ARNs - GetBucketObjectLockConfiguration, PutBucketObjectLockConfiguration: validate bucket - Fix TrimRight issue in extractBucketAndPrefix: - Change from strings.TrimRight(pattern, "/") to remove only one trailing slash - Prevents loss of prefix when pattern has multiple trailing slashes - Update all test cases: - TestConvertSingleActionSubpath: Write now returns 16 actions and bucket+object ARNs - TestConvertSingleActionNestedPaths: Write includes bucket ARN - TestGetResourcesFromLegacyAction: Updated Write and Tagging expectations - TestCreatePolicyFromLegacyIdentityMultipleActions: Updated action/resource counts Fixes: Inconsistencies between convertSingleAction and GetActionMappings for Write/List/Tagging actions * fmt * fix: resolve ListMultipartUploads/ListParts mapping inconsistency and add action validation - Fix ListMultipartUploads and ListParts mapping in helpers.go: - Changed from ACTION_LIST to ACTION_WRITE for consistency with GetActionMappings - These operations are part of the multipart write workflow and should map to Write action - Prevents inconsistent behavior when same actions processed through different code paths - Add documentation to clarify multipart operations in Write action: - Explain why ListMultipartUploads and ListParts are part of Write permissions - These are required for meaningful multipart upload workflow management - Add action validation to CreatePolicyFromLegacyIdentity: - Validates action format before processing using ValidateActionMapping - Logs warnings for invalid actions instead of silently skipping them - Provides clearer error messages when invalid action types are used - Ensures users know when their intended permissions weren't applied - Consistent with ConvertLegacyActions validation approach Fixes: Inconsistent action type mappings and silent failure for invalid actions * fix: restore TimeToFirstByte metric for S3 GetObject operations (issue #7869) --- weed/s3api/s3api_object_handlers.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/weed/s3api/s3api_object_handlers.go b/weed/s3api/s3api_object_handlers.go index 9b1495128..054a26264 100644 --- a/weed/s3api/s3api_object_handlers.go +++ b/weed/s3api/s3api_object_handlers.go @@ -1028,6 +1028,9 @@ func (s3a *S3ApiServer) streamFromVolumeServers(w http.ResponseWriter, r *http.R w.WriteHeader(http.StatusOK) } + // Track time to first byte metric + TimeToFirstByte(r.Method, t0, r) + // Stream directly to response with counting wrapper tStreamExec := time.Now() glog.V(4).Infof("streamFromVolumeServers: starting streamFn, offset=%d, size=%d", offset, size) @@ -1236,8 +1239,13 @@ func (s3a *S3ApiServer) streamFromVolumeServersWithSSE(w http.ResponseWriter, r // Now write status code (headers are all set) if isRangeRequest { w.WriteHeader(http.StatusPartialContent) + } else { + w.WriteHeader(http.StatusOK) } + // Track time to first byte metric + TimeToFirstByte(r.Method, t0, r) + // Full Range Optimization: Use ViewFromChunks to only fetch/decrypt needed chunks tDecryptSetup := time.Now() From 911aca74f3294592c0b900ded1af47c0143be173 Mon Sep 17 00:00:00 2001 From: Sheya Bernstein Date: Wed, 24 Dec 2025 18:52:40 +0000 Subject: [PATCH 23/66] Support volume server ID in Helm chart (#7867) helm: Support volume server ID --- k8s/charts/seaweedfs/templates/volume/volume-statefulset.yaml | 3 +++ k8s/charts/seaweedfs/values.yaml | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/k8s/charts/seaweedfs/templates/volume/volume-statefulset.yaml b/k8s/charts/seaweedfs/templates/volume/volume-statefulset.yaml index 6a551a6c9..045b95c2e 100644 --- a/k8s/charts/seaweedfs/templates/volume/volume-statefulset.yaml +++ b/k8s/charts/seaweedfs/templates/volume/volume-statefulset.yaml @@ -176,6 +176,9 @@ spec: {{- if $volume.dataCenter }} -dataCenter={{ $volume.dataCenter }} \ {{- end }} + {{- if $volume.id }} + -id={{ $volume.id }} \ + {{- end }} -ip.bind={{ $volume.ipBind }} \ -readMode={{ $volume.readMode }} \ {{- if $volume.whiteList }} diff --git a/k8s/charts/seaweedfs/values.yaml b/k8s/charts/seaweedfs/values.yaml index dd14f1ca0..c4fd3a841 100644 --- a/k8s/charts/seaweedfs/values.yaml +++ b/k8s/charts/seaweedfs/values.yaml @@ -401,6 +401,10 @@ volume: # Volume server's rack name rack: null + # Stable identifier for the volume server, independent of IP address + # Useful for Kubernetes environments with hostPath volumes to maintain stable identity + id: null + # Volume server's data center name dataCenter: null From 71cc233fac438a44ac30232be929f2dcb857258a Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Wed, 24 Dec 2025 11:06:53 -0800 Subject: [PATCH 24/66] add missing action --- weed/command/mini.go | 1 + 1 file changed, 1 insertion(+) diff --git a/weed/command/mini.go b/weed/command/mini.go index e7bdd5625..3fcfbc6d4 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -876,6 +876,7 @@ func startS3Service() { iamCfg := &iam_pb.S3ApiConfiguration{} ident := &iam_pb.Identity{Name: user} ident.Credentials = append(ident.Credentials, &iam_pb.Credential{AccessKey: accessKey, SecretKey: secretKey}) + ident.Actions = append(ident.Actions, "Admin") iamCfg.Identities = append(iamCfg.Identities, ident) iamPath := filepath.Join(*miniDataFolders, "iam_config.json") From 7f611f5d3a2e94236db58f95fec59b745991e344 Mon Sep 17 00:00:00 2001 From: Sheya Bernstein Date: Wed, 24 Dec 2025 20:22:37 +0000 Subject: [PATCH 25/66] fix: Correct admin server port in Helm worker deployment (#7872) The worker deployment was incorrectly passing the admin gRPC port (33646) to the -admin flag. However, the SeaweedFS worker command automatically calculates the gRPC port by adding 10000 to the HTTP port provided. This caused workers to attempt connection to port 43646 (33646 + 10000) instead of the correct gRPC port 33646 (23646 + 10000). Changes: - Update worker-deployment.yaml to use admin.port instead of admin.grpcPort - Workers now correctly connect to admin HTTP port, allowing the binary to calculate the gRPC port automatically Fixes workers failing with: "dial tcp :43646: connect: no route to host" Related: - Worker code: weed/pb/grpc_client_server.go:272 (grpcPort = port + 10000) - Worker docs: weed/command/worker.go:36 (admin HTTP port + 10000) --- k8s/charts/seaweedfs/templates/worker/worker-deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k8s/charts/seaweedfs/templates/worker/worker-deployment.yaml b/k8s/charts/seaweedfs/templates/worker/worker-deployment.yaml index d6b94564c..d57a900d5 100644 --- a/k8s/charts/seaweedfs/templates/worker/worker-deployment.yaml +++ b/k8s/charts/seaweedfs/templates/worker/worker-deployment.yaml @@ -134,7 +134,7 @@ spec: {{- if .Values.worker.adminServer }} -admin={{ .Values.worker.adminServer }} \ {{- else }} - -admin={{ template "seaweedfs.name" . }}-admin.{{ .Release.Namespace }}:{{ .Values.admin.grpcPort }} \ + -admin={{ template "seaweedfs.name" . }}-admin.{{ .Release.Namespace }}:{{ .Values.admin.port }} \ {{- end }} -capabilities={{ .Values.worker.capabilities }} \ -maxConcurrent={{ .Values.worker.maxConcurrent }} \ From 014027f75a59180480230d610c7509cd68fd042c Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Wed, 24 Dec 2025 13:09:08 -0800 Subject: [PATCH 26/66] Fix: Support object tagging in versioned buckets (Issue #7868) (#7871) * Fix: Support object tagging in versioned buckets (Issue #7868) This fix addresses the issue where setting tags on files in versioned buckets would fail with 'filer: no entry is found in filer store' error. Changes: - Updated GetObjectTaggingHandler to check versioning status and retrieve correct object versions - Updated PutObjectTaggingHandler to properly locate and update tags on versioned objects - Updated DeleteObjectTaggingHandler to delete tags from versioned objects - Added proper handling for both specific versions and latest versions - Added distinction between null versions (pre-versioning objects) and versioned objects The fix follows the same versioning-aware pattern already implemented in ACL handlers. Tests: - Added comprehensive test suite for tagging operations on versioned buckets - Tests cover PUT, GET, and DELETE tagging operations on specific versions and latest versions - Tests verify tag isolation between different versions of the same object * Fix: Ensure consistent directory path construction in tagging handlers Changed directory path construction to match the pattern used in ACL handlers: - Added missing '/' before object path when constructing .versions directory path - This ensures compatibility with the filer's expected path structure - Applied to both PutObjectTaggingHandler and DeleteObjectTaggingHandler * Revert: Remove redundant slash in path construction - object already has leading slash from NormalizeObjectKey * Fix: Remove redundant slashes in versioning path construction across handlers - getVersionedObjectDir: object already starts with '/', no need for extra '/' - ACL handlers: same pattern, fix both PutObjectAcl locations - Ensures consistent path construction with object parameter normalization * fix test compilation * Add: Comprehensive ACL tests for versioned and non-versioned buckets - Added s3_acl_versioning_test.go with 5 test cases covering: * GetObjectAcl on versioned buckets * GetObjectAcl on specific versions * PutObjectAcl on versioned buckets * PutObjectAcl on specific versions * Independent ACL management across versions These tests were missing and would have caught the path construction issues we just fixed in the ACL handler. Tests validate that ACL operations work correctly on both versioned and non-versioned objects. * Fix: Correct tagging versioning test file formatting * fix: Update AWS SDK endpoint config and improve cleanup to handle delete markers - Replace deprecated EndpointResolverWithOptions with BaseEndpoint in AWS SDK v2 client configuration - Update cleanupTestBucket to properly delete both object versions and delete markers - Apply changes to both ACL and tagging test files for consistency * Fix S3 multi-delete for versioned objects The bug was in getVersionedObjectDir() which was constructing paths without a slash between the bucket and object key: BEFORE (WRONG): /buckets/mybucket{key}.versions AFTER (FIXED): /buckets/mybucket/{key}/.versions This caused version deletions to claim success but not actually delete files, breaking S3 compatibility tests: - test_versioning_multi_object_delete - test_versioning_multi_object_delete_with_marker - test_versioning_concurrent_multi_object_delete - test_object_lock_multi_delete_object_with_retention Added comprehensive test that reproduces the issue and verifies the fix. * Remove emojis from test output --- test/s3/acl/s3_acl_versioning_test.go | 335 ++++++++++++++ .../delete/s3_multi_delete_versioning_test.go | 164 +++++++ test/s3/tagging/s3_tagging_test.go | 35 +- test/s3/tagging/s3_tagging_versioning_test.go | 434 ++++++++++++++++++ weed/s3api/s3api_object_handlers_acl.go | 4 +- weed/s3api/s3api_object_handlers_tagging.go | 340 ++++++++++++-- weed/s3api/s3api_version_id.go | 1 - 7 files changed, 1277 insertions(+), 36 deletions(-) create mode 100644 test/s3/acl/s3_acl_versioning_test.go create mode 100644 test/s3/delete/s3_multi_delete_versioning_test.go create mode 100644 test/s3/tagging/s3_tagging_versioning_test.go diff --git a/test/s3/acl/s3_acl_versioning_test.go b/test/s3/acl/s3_acl_versioning_test.go new file mode 100644 index 000000000..7e7326ec4 --- /dev/null +++ b/test/s3/acl/s3_acl_versioning_test.go @@ -0,0 +1,335 @@ +package acl + +import ( + "context" + "os" + "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" +) + +func getS3Client(t *testing.T) *s3.Client { + endpoint := os.Getenv("S3_ENDPOINT") + if endpoint == "" { + endpoint = "http://localhost:8333" + } + accessKey := os.Getenv("S3_ACCESS_KEY") + if accessKey == "" { + accessKey = "some_access_key1" + } + secretKey := os.Getenv("S3_SECRET_KEY") + if secretKey == "" { + secretKey = "some_secret_key1" + } + + cfg, err := config.LoadDefaultConfig( + context.TODO(), + config.WithRegion("us-east-1"), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + accessKey, + secretKey, + "", + )), + ) + require.NoError(t, err) + return s3.NewFromConfig(cfg, func(o *s3.Options) { + o.UsePathStyle = true + o.BaseEndpoint = aws.String(endpoint) + }) +} + +func createVersionedTestBucket(t *testing.T, client *s3.Client) string { + bucketName := "test-acl-versioned-" + strings.ToLower(strings.ReplaceAll(time.Now().Format("2006-01-02-15-04-05.000"), ":", "-")) + _, err := client.CreateBucket(context.TODO(), &s3.CreateBucketInput{ + Bucket: aws.String(bucketName), + }) + require.NoError(t, err) + + _, err = client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{ + Bucket: aws.String(bucketName), + VersioningConfiguration: &types.VersioningConfiguration{ + Status: types.BucketVersioningStatusEnabled, + }, + }) + require.NoError(t, err) + time.Sleep(100 * time.Millisecond) + return bucketName +} + +func cleanupTestBucket(t *testing.T, client *s3.Client, bucketName string) { + listResp, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ + Bucket: aws.String(bucketName), + }) + if err == nil { + for _, obj := range listResp.Contents { + client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: obj.Key, + }) + } + } + + listVersionsResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ + Bucket: aws.String(bucketName), + }) + if err == nil { + for _, version := range listVersionsResp.Versions { + client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: version.Key, + VersionId: version.VersionId, + }) + } + for _, marker := range listVersionsResp.DeleteMarkers { + client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: marker.Key, + VersionId: marker.VersionId, + }) + } + } + + client.DeleteBucket(context.TODO(), &s3.DeleteBucketInput{ + Bucket: aws.String(bucketName), + }) +} + +// TestGetObjectAclOnVersionedBucket tests retrieving ACL from versioned objects +func TestGetObjectAclOnVersionedBucket(t *testing.T) { + client := getS3Client(t) + bucketName := createVersionedTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + objectKey := "versioned-object-acl" + putResp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + Body: strings.NewReader("Hello, ACL World!"), + }) + require.NoError(t, err) + + aclResp, err := client.GetObjectAcl(context.TODO(), &s3.GetObjectAclInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + }) + require.NoError(t, err) + assert.NotNil(t, aclResp.Owner) + + t.Logf("Successfully retrieved ACL for versioned object %s (versionId: %s)", objectKey, *putResp.VersionId) +} + +// TestGetObjectAclOnSpecificVersionInVersionedBucket tests retrieving ACL for specific versions +func TestGetObjectAclOnSpecificVersionInVersionedBucket(t *testing.T) { + client := getS3Client(t) + bucketName := createVersionedTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + objectKey := "multi-version-object-acl" + + version1Resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + Body: strings.NewReader("Version 1"), + }) + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + version2Resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + Body: strings.NewReader("Version 2"), + }) + require.NoError(t, err) + + versionId1 := *version1Resp.VersionId + versionId2 := *version2Resp.VersionId + + aclResp1, err := client.GetObjectAcl(context.TODO(), &s3.GetObjectAclInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId1), + }) + require.NoError(t, err) + assert.NotNil(t, aclResp1.Owner) + + aclResp2, err := client.GetObjectAcl(context.TODO(), &s3.GetObjectAclInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId2), + }) + require.NoError(t, err) + assert.NotNil(t, aclResp2.Owner) + + t.Logf("Successfully retrieved ACL for both versions: v1=%s, v2=%s", versionId1, versionId2) +} + +// TestPutObjectAclOnVersionedBucket tests setting ACL on versioned objects +func TestPutObjectAclOnVersionedBucket(t *testing.T) { + client := getS3Client(t) + bucketName := createVersionedTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + objectKey := "versioned-object-put-acl" + putResp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + Body: strings.NewReader("Hello, Put ACL!"), + }) + require.NoError(t, err) + + _, err = client.PutObjectAcl(context.TODO(), &s3.PutObjectAclInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + ACL: types.ObjectCannedACLPublicRead, + }) + require.NoError(t, err) + + aclResp, err := client.GetObjectAcl(context.TODO(), &s3.GetObjectAclInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + }) + require.NoError(t, err) + assert.NotNil(t, aclResp.Owner) + + t.Logf("Successfully set and verified ACL for versioned object %s (versionId: %s)", objectKey, *putResp.VersionId) +} + +// TestPutObjectAclOnSpecificVersionInVersionedBucket tests setting ACL on specific versions +func TestPutObjectAclOnSpecificVersionInVersionedBucket(t *testing.T) { + client := getS3Client(t) + bucketName := createVersionedTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + objectKey := "multi-version-object-put-acl" + + version1Resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + Body: strings.NewReader("Version 1"), + }) + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + version2Resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + Body: strings.NewReader("Version 2"), + }) + require.NoError(t, err) + + versionId1 := *version1Resp.VersionId + versionId2 := *version2Resp.VersionId + + _, err = client.PutObjectAcl(context.TODO(), &s3.PutObjectAclInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId1), + ACL: types.ObjectCannedACLPublicRead, + }) + require.NoError(t, err) + + _, err = client.PutObjectAcl(context.TODO(), &s3.PutObjectAclInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId2), + ACL: types.ObjectCannedACLPrivate, + }) + require.NoError(t, err) + + aclResp1, err := client.GetObjectAcl(context.TODO(), &s3.GetObjectAclInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId1), + }) + require.NoError(t, err) + assert.NotNil(t, aclResp1.Owner) + + aclResp2, err := client.GetObjectAcl(context.TODO(), &s3.GetObjectAclInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId2), + }) + require.NoError(t, err) + assert.NotNil(t, aclResp2.Owner) + + t.Logf("Successfully set ACL on both versions: v1=%s, v2=%s", versionId1, versionId2) +} + +// TestModifyAclOnDifferentVersionsIndependently tests that ACL changes on one version don't affect others +func TestModifyAclOnDifferentVersionsIndependently(t *testing.T) { + client := getS3Client(t) + bucketName := createVersionedTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + objectKey := "versioned-independent-acl" + + version1Resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + Body: strings.NewReader("Version 1"), + }) + require.NoError(t, err) + + time.Sleep(50 * time.Millisecond) + + version2Resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + Body: strings.NewReader("Version 2"), + }) + require.NoError(t, err) + + versionId1 := *version1Resp.VersionId + versionId2 := *version2Resp.VersionId + + _, err = client.PutObjectAcl(context.TODO(), &s3.PutObjectAclInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId1), + ACL: types.ObjectCannedACLPublicRead, + }) + require.NoError(t, err) + + _, err = client.PutObjectAcl(context.TODO(), &s3.PutObjectAclInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId2), + ACL: types.ObjectCannedACLPrivate, + }) + require.NoError(t, err) + + aclRespLatest, err := client.GetObjectAcl(context.TODO(), &s3.GetObjectAclInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + }) + require.NoError(t, err) + assert.NotNil(t, aclRespLatest.Owner) + + aclResp1, err := client.GetObjectAcl(context.TODO(), &s3.GetObjectAclInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId1), + }) + require.NoError(t, err) + assert.NotNil(t, aclResp1.Owner) + + aclResp2, err := client.GetObjectAcl(context.TODO(), &s3.GetObjectAclInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId2), + }) + require.NoError(t, err) + assert.NotNil(t, aclResp2.Owner) + + t.Logf("Successfully verified independent ACL management across versions") +} diff --git a/test/s3/delete/s3_multi_delete_versioning_test.go b/test/s3/delete/s3_multi_delete_versioning_test.go new file mode 100644 index 000000000..22179c611 --- /dev/null +++ b/test/s3/delete/s3_multi_delete_versioning_test.go @@ -0,0 +1,164 @@ +package delete + +import ( + "bytes" + "context" + "fmt" + "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" +) + +const ( + testEndpoint = "http://localhost:8333" + testAccessKey = "admin" + testSecretKey = "admin" + testRegion = "us-east-1" +) + +func getTestClient(t *testing.T) *s3.Client { + cfg, err := config.LoadDefaultConfig(context.TODO(), + config.WithRegion(testRegion), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + testAccessKey, + testSecretKey, + "", + )), + ) + require.NoError(t, err) + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(testEndpoint) + o.UsePathStyle = true + }) + + return client +} + +func createTestBucket(t *testing.T, client *s3.Client) string { + bucketName := fmt.Sprintf("test-multi-delete-%d", time.Now().UnixNano()) + _, err := client.CreateBucket(context.TODO(), &s3.CreateBucketInput{ + Bucket: aws.String(bucketName), + }) + require.NoError(t, err) + + _, err = client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{ + Bucket: aws.String(bucketName), + VersioningConfiguration: &types.VersioningConfiguration{ + Status: types.BucketVersioningStatusEnabled, + }, + }) + require.NoError(t, err) + + return bucketName +} + +func cleanupBucket(t *testing.T, client *s3.Client, bucket string) { + listResp, _ := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ + Bucket: aws.String(bucket), + }) + + if listResp != nil { + var objectsToDelete []types.ObjectIdentifier + + for _, version := range listResp.Versions { + objectsToDelete = append(objectsToDelete, types.ObjectIdentifier{ + Key: version.Key, + VersionId: version.VersionId, + }) + } + + for _, marker := range listResp.DeleteMarkers { + objectsToDelete = append(objectsToDelete, types.ObjectIdentifier{ + Key: marker.Key, + VersionId: marker.VersionId, + }) + } + + if len(objectsToDelete) > 0 { + _, _ = client.DeleteObjects(context.TODO(), &s3.DeleteObjectsInput{ + Bucket: aws.String(bucket), + Delete: &types.Delete{ + Objects: objectsToDelete, + Quiet: aws.Bool(false), + }, + }) + } + } + + _, _ = client.DeleteBucket(context.TODO(), &s3.DeleteBucketInput{ + Bucket: aws.String(bucket), + }) +} + +func TestVersioningMultiObjectDelete(t *testing.T) { + client := getTestClient(t) + bucket := createTestBucket(t, client) + defer cleanupBucket(t, client, bucket) + + key := "key" + numVersions := 2 + var versionIds []string + + for i := 0; i < numVersions; i++ { + content := fmt.Sprintf("content-%d", i) + putResp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + Body: bytes.NewReader([]byte(content)), + }) + require.NoError(t, err) + require.NotNil(t, putResp.VersionId) + versionIds = append(versionIds, *putResp.VersionId) + } + + assert.Len(t, versionIds, 2) + + var objectsToDelete []types.ObjectIdentifier + for _, vid := range versionIds { + objectsToDelete = append(objectsToDelete, types.ObjectIdentifier{ + Key: aws.String(key), + VersionId: aws.String(vid), + }) + } + + deleteResp, err := client.DeleteObjects(context.TODO(), &s3.DeleteObjectsInput{ + Bucket: aws.String(bucket), + Delete: &types.Delete{ + Objects: objectsToDelete, + }, + }) + require.NoError(t, err) + t.Logf("Delete response: Deleted=%d, Errors=%d", len(deleteResp.Deleted), len(deleteResp.Errors)) + + listResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ + Bucket: aws.String(bucket), + }) + require.NoError(t, err) + + if listResp.Versions != nil && len(listResp.Versions) > 0 { + t.Errorf("FAIL: Expected no versions, but found %d versions:", len(listResp.Versions)) + for _, v := range listResp.Versions { + t.Logf(" - Key=%s, VersionId=%s, IsLatest=%v", *v.Key, *v.VersionId, v.IsLatest) + } + } else { + t.Logf("PASS: Versions correctly deleted") + } + assert.Nil(t, listResp.Versions, "Expected no versions after deletion") + + deleteResp2, err := client.DeleteObjects(context.TODO(), &s3.DeleteObjectsInput{ + Bucket: aws.String(bucket), + Delete: &types.Delete{ + Objects: objectsToDelete, + }, + }) + require.NoError(t, err) + assert.Empty(t, deleteResp2.Errors, "Idempotent delete should not return errors") +} diff --git a/test/s3/tagging/s3_tagging_test.go b/test/s3/tagging/s3_tagging_test.go index c490ca1aa..4606ec800 100644 --- a/test/s3/tagging/s3_tagging_test.go +++ b/test/s3/tagging/s3_tagging_test.go @@ -63,18 +63,12 @@ func getS3Client(t *testing.T) *s3.Client { defaultConfig.SecretKey, "", )), - config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc( - func(service, region string, options ...interface{}) (aws.Endpoint, error) { - return aws.Endpoint{ - URL: defaultConfig.Endpoint, - SigningRegion: defaultConfig.Region, - }, nil - })), ) require.NoError(t, err) client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true + o.BaseEndpoint = aws.String(defaultConfig.Endpoint) }) return client } @@ -113,6 +107,33 @@ func cleanupTestBucket(t *testing.T, client *s3.Client, bucketName string) { } } + // Delete all versions and delete markers if versioning is enabled + listVersionsResp, err := client.ListObjectVersions(context.TODO(), &s3.ListObjectVersionsInput{ + Bucket: aws.String(bucketName), + }) + if err == nil { + for _, version := range listVersionsResp.Versions { + _, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: version.Key, + VersionId: version.VersionId, + }) + if err != nil { + t.Logf("Warning: failed to delete version %s: %v", *version.Key, err) + } + } + for _, marker := range listVersionsResp.DeleteMarkers { + _, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: marker.Key, + VersionId: marker.VersionId, + }) + if err != nil { + t.Logf("Warning: failed to delete marker %s: %v", *marker.Key, err) + } + } + } + // Then delete the bucket _, err = client.DeleteBucket(context.TODO(), &s3.DeleteBucketInput{ Bucket: aws.String(bucketName), diff --git a/test/s3/tagging/s3_tagging_versioning_test.go b/test/s3/tagging/s3_tagging_versioning_test.go new file mode 100644 index 000000000..5aa49f956 --- /dev/null +++ b/test/s3/tagging/s3_tagging_versioning_test.go @@ -0,0 +1,434 @@ +package tagging + +import ( + "context" + "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" +) + +// This is the fix for GitHub issue #7868 where tagging failed with "no entry is found in filer store" +// TestPutObjectTaggingOnVersionedBucket tests setting tags on objects in a versioned bucket +func TestPutObjectTaggingOnVersionedBucket(t *testing.T) { + client := getS3Client(t) + bucketName := createVersionedTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Put object in versioned bucket + objectKey := "versioned-object-with-tags" + objectContent := "Hello, Versioned World!" + _, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Body: strings.NewReader(objectContent), + Key: aws.String(objectKey), + Bucket: aws.String(bucketName), + }) + require.NoError(t, err, "Should be able to put object in versioned bucket") + + // Set tags on the object in versioned bucket + _, err = client.PutObjectTagging(context.TODO(), &s3.PutObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + Tagging: &types.Tagging{ + TagSet: []types.Tag{ + { + Key: aws.String("env"), + Value: aws.String("production"), + }, + { + Key: aws.String("team"), + Value: aws.String("platform"), + }, + }, + }, + }) + require.NoError(t, err, "Should be able to put tags on versioned object") + + // Get the tags back + tagResp, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + }) + require.NoError(t, err, "Should be able to get tags from versioned object") + + // Verify tags + assert.Len(t, tagResp.TagSet, 2, "Should have 2 tags") + tagMap := make(map[string]string) + for _, tag := range tagResp.TagSet { + tagMap[*tag.Key] = *tag.Value + } + assert.Equal(t, "production", tagMap["env"], "env tag should be 'production'") + assert.Equal(t, "platform", tagMap["team"], "team tag should be 'platform'") +} + +// TestPutObjectTaggingOnSpecificVersionInVersionedBucket tests setting tags on a specific version +func TestPutObjectTaggingOnSpecificVersionInVersionedBucket(t *testing.T) { + client := getS3Client(t) + bucketName := createVersionedTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Create multiple versions of the object + objectKey := "multi-version-object" + version1Resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Body: strings.NewReader("Version 1"), + Key: aws.String(objectKey), + Bucket: aws.String(bucketName), + }) + require.NoError(t, err) + + // Small delay to ensure different version IDs + time.Sleep(50 * time.Millisecond) + versionId1 := *version1Resp.VersionId + + version2Resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Body: strings.NewReader("Version 2"), + Key: aws.String(objectKey), + Bucket: aws.String(bucketName), + }) + require.NoError(t, err) + versionId2 := *version2Resp.VersionId + + // Set tags on version 1 + _, err = client.PutObjectTagging(context.TODO(), &s3.PutObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId1), + Tagging: &types.Tagging{ + TagSet: []types.Tag{ + { + Key: aws.String("version"), + Value: aws.String("v1"), + }, + }, + }, + }) + require.NoError(t, err, "Should be able to put tags on specific version 1") + + // Set tags on version 2 + _, err = client.PutObjectTagging(context.TODO(), &s3.PutObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId2), + Tagging: &types.Tagging{ + TagSet: []types.Tag{ + { + Key: aws.String("version"), + Value: aws.String("v2"), + }, + }, + }, + }) + require.NoError(t, err, "Should be able to put tags on specific version 2") + + // Get tags from version 1 + tagResp1, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId1), + }) + require.NoError(t, err, "Should be able to get tags from version 1") + assert.Len(t, tagResp1.TagSet, 1, "Version 1 should have 1 tag") + assert.Equal(t, "v1", *tagResp1.TagSet[0].Value, "Version 1 tag value should be 'v1'") + + // Get tags from version 2 + tagResp2, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId2), + }) + require.NoError(t, err, "Should be able to get tags from version 2") + assert.Len(t, tagResp2.TagSet, 1, "Version 2 should have 1 tag") + assert.Equal(t, "v2", *tagResp2.TagSet[0].Value, "Version 2 tag value should be 'v2'") +} + +// TestDeleteObjectTaggingOnVersionedBucket tests deleting tags from versioned objects +func TestDeleteObjectTaggingOnVersionedBucket(t *testing.T) { + client := getS3Client(t) + bucketName := createVersionedTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Put object with tags in versioned bucket + objectKey := "versioned-object-tag-delete" + objectContent := "Hello, Delete Tags!" + _, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Body: strings.NewReader(objectContent), + Key: aws.String(objectKey), + Bucket: aws.String(bucketName), + Tagging: aws.String("env=dev&purpose=testing"), + }) + require.NoError(t, err) + + // Verify tags exist + tagResp, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + }) + require.NoError(t, err) + assert.Len(t, tagResp.TagSet, 2, "Should have 2 tags before deletion") + + // Delete tags from the versioned object + _, err = client.DeleteObjectTagging(context.TODO(), &s3.DeleteObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + }) + require.NoError(t, err, "Should be able to delete tags from versioned object") + + // Verify tags are deleted + tagResp, err = client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + }) + require.NoError(t, err) + assert.Len(t, tagResp.TagSet, 0, "Should have 0 tags after deletion") +} + +// TestDeleteObjectTaggingOnSpecificVersionInVersionedBucket tests deleting tags on a specific version +func TestDeleteObjectTaggingOnSpecificVersionInVersionedBucket(t *testing.T) { + client := getS3Client(t) + bucketName := createVersionedTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Create two versions with tags + objectKey := "versioned-multi-delete-tags" + version1Resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Body: strings.NewReader("Version 1"), + Key: aws.String(objectKey), + Bucket: aws.String(bucketName), + Tagging: aws.String("version=v1&keep=true"), + }) + require.NoError(t, err) + + // Small delay to ensure different version IDs + time.Sleep(50 * time.Millisecond) + versionId1 := *version1Resp.VersionId + + version2Resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Body: strings.NewReader("Version 2"), + Key: aws.String(objectKey), + Bucket: aws.String(bucketName), + Tagging: aws.String("version=v2&keep=true"), + }) + require.NoError(t, err) + versionId2 := *version2Resp.VersionId + + // Delete tags from version 1 only + _, err = client.DeleteObjectTagging(context.TODO(), &s3.DeleteObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId1), + }) + require.NoError(t, err, "Should be able to delete tags from version 1") + + // Verify version 1 has no tags + tagResp1, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId1), + }) + require.NoError(t, err) + assert.Len(t, tagResp1.TagSet, 0, "Version 1 should have 0 tags after deletion") + + // Verify version 2 still has tags + tagResp2, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId2), + }) + require.NoError(t, err) + assert.Len(t, tagResp2.TagSet, 2, "Version 2 should still have 2 tags") +} + +// TestGetObjectTaggingOnVersionedBucket tests retrieving tags from versioned objects +func TestGetObjectTaggingOnVersionedBucket(t *testing.T) { + client := getS3Client(t) + bucketName := createVersionedTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Create multiple versions + objectKey := "versioned-object-get-tags" + version1Resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Body: strings.NewReader("Version 1"), + Key: aws.String(objectKey), + Bucket: aws.String(bucketName), + Tagging: aws.String("v=1&stage=dev"), + }) + require.NoError(t, err) + + versionId1 := *version1Resp.VersionId + time.Sleep(50 * time.Millisecond) + + version2Resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Body: strings.NewReader("Version 2"), + Key: aws.String(objectKey), + Bucket: aws.String(bucketName), + Tagging: aws.String("v=2&stage=prod"), + }) + require.NoError(t, err) + + versionId2 := *version2Resp.VersionId + + // Get tags from specific versions + tagResp1, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId1), + }) + require.NoError(t, err, "Should be able to get tags from version 1") + assert.Len(t, tagResp1.TagSet, 2, "Version 1 should have 2 tags") + tagMap1 := make(map[string]string) + for _, tag := range tagResp1.TagSet { + tagMap1[*tag.Key] = *tag.Value + } + assert.Equal(t, "1", tagMap1["v"], "Version 1 should have v=1") + assert.Equal(t, "dev", tagMap1["stage"], "Version 1 should have stage=dev") + + tagResp2, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId2), + }) + require.NoError(t, err, "Should be able to get tags from version 2") + assert.Len(t, tagResp2.TagSet, 2, "Version 2 should have 2 tags") + tagMap2 := make(map[string]string) + for _, tag := range tagResp2.TagSet { + tagMap2[*tag.Key] = *tag.Value + } + assert.Equal(t, "2", tagMap2["v"], "Version 2 should have v=2") + assert.Equal(t, "prod", tagMap2["stage"], "Version 2 should have stage=prod") + + // Get tags from latest version (should be version 2) + tagRespLatest, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + }) + require.NoError(t, err, "Should be able to get tags from latest version") + assert.Len(t, tagRespLatest.TagSet, 2, "Latest version should have 2 tags") + tagMapLatest := make(map[string]string) + for _, tag := range tagRespLatest.TagSet { + tagMapLatest[*tag.Key] = *tag.Value + } + assert.Equal(t, "2", tagMapLatest["v"], "Latest version should have v=2") + assert.Equal(t, "prod", tagMapLatest["stage"], "Latest version should have stage=prod") +} + +// TestModifyTagsOnVersionedObject tests changing tags on different versions independently +func TestModifyTagsOnVersionedObject(t *testing.T) { + client := getS3Client(t) + bucketName := createVersionedTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Create version 1 + objectKey := "versioned-modify-tags" + version1Resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Body: strings.NewReader("Version 1"), + Key: aws.String(objectKey), + Bucket: aws.String(bucketName), + }) + require.NoError(t, err) + + versionId1 := *version1Resp.VersionId + time.Sleep(50 * time.Millisecond) + + // Create version 2 + version2Resp, err := client.PutObject(context.TODO(), &s3.PutObjectInput{ + Body: strings.NewReader("Version 2"), + Key: aws.String(objectKey), + Bucket: aws.String(bucketName), + }) + require.NoError(t, err) + + versionId2 := *version2Resp.VersionId + + // Add tags to version 1 + _, err = client.PutObjectTagging(context.TODO(), &s3.PutObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId1), + Tagging: &types.Tagging{ + TagSet: []types.Tag{ + { + Key: aws.String("status"), + Value: aws.String("old"), + }, + }, + }, + }) + require.NoError(t, err) + + // Modify tags on version 1 (replace status and add new tag) + _, err = client.PutObjectTagging(context.TODO(), &s3.PutObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId1), + Tagging: &types.Tagging{ + TagSet: []types.Tag{ + { + Key: aws.String("status"), + Value: aws.String("archived"), + }, + { + Key: aws.String("archived-date"), + Value: aws.String("2024-01-01"), + }, + }, + }, + }) + require.NoError(t, err) + + // Add tags to version 2 + _, err = client.PutObjectTagging(context.TODO(), &s3.PutObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId2), + Tagging: &types.Tagging{ + TagSet: []types.Tag{ + { + Key: aws.String("status"), + Value: aws.String("current"), + }, + }, + }, + }) + require.NoError(t, err) + + // Verify final state + tagResp1, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId1), + }) + require.NoError(t, err) + assert.Len(t, tagResp1.TagSet, 2, "Version 1 should have 2 tags after modification") + + tagResp2, err := client.GetObjectTagging(context.TODO(), &s3.GetObjectTaggingInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + VersionId: aws.String(versionId2), + }) + require.NoError(t, err) + assert.Len(t, tagResp2.TagSet, 1, "Version 2 should have 1 tag") +} + +// createVersionedTestBucket creates a test bucket with versioning enabled +func createVersionedTestBucket(t *testing.T, client *s3.Client) string { + bucketName := createTestBucket(t, client) + + // Enable versioning on the bucket + _, err := client.PutBucketVersioning(context.TODO(), &s3.PutBucketVersioningInput{ + Bucket: aws.String(bucketName), + VersioningConfiguration: &types.VersioningConfiguration{ + Status: types.BucketVersioningStatusEnabled, + }, + }) + require.NoError(t, err, "Should be able to enable versioning") + + // Wait for versioning configuration to be applied + time.Sleep(100 * time.Millisecond) + + return bucketName +} diff --git a/weed/s3api/s3api_object_handlers_acl.go b/weed/s3api/s3api_object_handlers_acl.go index e90d84603..212354a30 100644 --- a/weed/s3api/s3api_object_handlers_acl.go +++ b/weed/s3api/s3api_object_handlers_acl.go @@ -306,7 +306,7 @@ func (s3a *S3ApiServer) PutObjectAclHandler(w http.ResponseWriter, r *http.Reque if versioningConfigured { if versionId != "" && versionId != "null" { // Versioned object - update the specific version file in .versions directory - updateDirectory = s3a.option.BucketsPath + "/" + bucket + "/" + object + s3_constants.VersionsFolder + updateDirectory = s3a.option.BucketsPath + "/" + bucket + object + s3_constants.VersionsFolder } else { // Latest version in versioned bucket - could be null version or versioned object // Extract version ID from the entry to determine where it's stored @@ -322,7 +322,7 @@ func (s3a *S3ApiServer) PutObjectAclHandler(w http.ResponseWriter, r *http.Reque updateDirectory = s3a.option.BucketsPath + "/" + bucket } else { // Versioned object - stored in .versions directory - updateDirectory = s3a.option.BucketsPath + "/" + bucket + "/" + object + s3_constants.VersionsFolder + updateDirectory = s3a.option.BucketsPath + "/" + bucket + object + s3_constants.VersionsFolder } } } else { diff --git a/weed/s3api/s3api_object_handlers_tagging.go b/weed/s3api/s3api_object_handlers_tagging.go index 23ca05133..7b6b947da 100644 --- a/weed/s3api/s3api_object_handlers_tagging.go +++ b/weed/s3api/s3api_object_handlers_tagging.go @@ -1,12 +1,14 @@ package s3api import ( + "context" "encoding/xml" "fmt" - "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" "io" "net/http" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" @@ -20,21 +22,79 @@ func (s3a *S3ApiServer) GetObjectTaggingHandler(w http.ResponseWriter, r *http.R bucket, object := s3_constants.GetBucketAndObject(r) glog.V(3).Infof("GetObjectTaggingHandler %s %s", bucket, object) - target := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object)) - dir, name := target.DirAndName() + // Check for specific version ID in query parameters + versionId := r.URL.Query().Get("versionId") - tags, err := s3a.getTags(dir, name) + // Check if versioning is configured for the bucket (Enabled or Suspended) + versioningConfigured, err := s3a.isVersioningConfigured(bucket) if err != nil { if err == filer_pb.ErrNotFound { - glog.Errorf("GetObjectTaggingHandler %s: %v", r.URL, err) - s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) - } else { - glog.Errorf("GetObjectTaggingHandler %s: %v", r.URL, err) - s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket) + return } + glog.Errorf("GetObjectTaggingHandler: Error checking versioning status for bucket %s: %v", bucket, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) return } + var entry *filer_pb.Entry + + if versioningConfigured { + // Handle versioned object tagging retrieval + if versionId != "" { + // Request for specific version + glog.V(2).Infof("GetObjectTaggingHandler: requesting tags for specific version %s of %s%s", versionId, bucket, object) + entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) + } else { + // Request for latest version + glog.V(2).Infof("GetObjectTaggingHandler: requesting tags for latest version of %s%s", bucket, object) + entry, err = s3a.getLatestObjectVersion(bucket, object) + } + + if err != nil { + glog.Errorf("GetObjectTaggingHandler: Failed to get object version %s for %s%s: %v", versionId, bucket, object, err) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + + // Check if this is a delete marker + if entry.Extended != nil { + if deleteMarker, exists := entry.Extended[s3_constants.ExtDeleteMarkerKey]; exists && string(deleteMarker) == "true" { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + } + } else { + // Handle regular (non-versioned) object tagging retrieval + target := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object)) + dir, name := target.DirAndName() + + tags, err := s3a.getTags(dir, name) + if err != nil { + if err == filer_pb.ErrNotFound { + glog.Errorf("GetObjectTaggingHandler %s: %v", r.URL, err) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + } else { + glog.Errorf("GetObjectTaggingHandler %s: %v", r.URL, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + } + return + } + + writeSuccessResponseXML(w, r, FromTags(tags)) + return + } + + // Extract tags from the entry's extended attributes + tags := make(map[string]string) + if entry.Extended != nil { + for k, v := range entry.Extended { + if len(k) > len(S3TAG_PREFIX) && k[:len(S3TAG_PREFIX)] == S3TAG_PREFIX { + tags[k[len(S3TAG_PREFIX):]] = string(v) + } + } + } + writeSuccessResponseXML(w, r, FromTags(tags)) } @@ -46,9 +106,6 @@ func (s3a *S3ApiServer) PutObjectTaggingHandler(w http.ResponseWriter, r *http.R bucket, object := s3_constants.GetBucketAndObject(r) glog.V(3).Infof("PutObjectTaggingHandler %s %s", bucket, object) - target := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object)) - dir, name := target.DirAndName() - tagging := &Tagging{} input, err := io.ReadAll(io.LimitReader(r.Body, r.ContentLength)) if err != nil { @@ -69,17 +126,133 @@ func (s3a *S3ApiServer) PutObjectTaggingHandler(w http.ResponseWriter, r *http.R return } - if err = s3a.setTags(dir, name, tagging.ToTags()); err != nil { + // Check for specific version ID in query parameters + versionId := r.URL.Query().Get("versionId") + + // Check if versioning is configured for the bucket (Enabled or Suspended) + versioningConfigured, err := s3a.isVersioningConfigured(bucket) + if err != nil { if err == filer_pb.ErrNotFound { - glog.Errorf("PutObjectTaggingHandler setTags %s: %v", r.URL, err) - s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) - } else { - glog.Errorf("PutObjectTaggingHandler setTags %s: %v", r.URL, err) - s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket) + return } + glog.Errorf("PutObjectTaggingHandler: Error checking versioning status for bucket %s: %v", bucket, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) return } + var entry *filer_pb.Entry + + if versioningConfigured { + // Handle versioned object tagging modification + if versionId != "" { + // Request for specific version + glog.V(2).Infof("PutObjectTaggingHandler: modifying tags for specific version %s of %s%s", versionId, bucket, object) + entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) + } else { + // Request for latest version + glog.V(2).Infof("PutObjectTaggingHandler: modifying tags for latest version of %s%s", bucket, object) + entry, err = s3a.getLatestObjectVersion(bucket, object) + } + + if err != nil { + glog.Errorf("PutObjectTaggingHandler: Failed to get object version %s for %s%s: %v", versionId, bucket, object, err) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + + // Check if this is a delete marker + if entry.Extended != nil { + if deleteMarker, exists := entry.Extended[s3_constants.ExtDeleteMarkerKey]; exists && string(deleteMarker) == "true" { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + } + } else { + // Handle regular (non-versioned) object tagging modification + target := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object)) + dir, name := target.DirAndName() + + if err = s3a.setTags(dir, name, tags); err != nil { + if err == filer_pb.ErrNotFound { + glog.Errorf("PutObjectTaggingHandler setTags %s: %v", r.URL, err) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + } else { + glog.Errorf("PutObjectTaggingHandler setTags %s: %v", r.URL, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + } + return + } + + w.WriteHeader(http.StatusOK) + s3err.PostLog(r, http.StatusOK, s3err.ErrNone) + return + } + + // For versioned objects, determine the correct directory based on the version + var updateDirectory string + if versionId != "" { + // Specific version requested + if versionId == "null" { + // Null version (pre-versioning object) - stored as regular file + updateDirectory = s3a.option.BucketsPath + "/" + bucket + } else { + // Versioned object - stored in .versions directory + updateDirectory = s3a.option.BucketsPath + "/" + bucket + object + s3_constants.VersionsFolder + } + } else { + // Latest version in versioned bucket - could be null version or versioned object + // Extract version ID from the entry to determine where it's stored + var actualVersionId string + if entry.Extended != nil { + if versionIdBytes, exists := entry.Extended[s3_constants.ExtVersionIdKey]; exists { + actualVersionId = string(versionIdBytes) + } + } + + if actualVersionId == "null" || actualVersionId == "" { + // Null version (pre-versioning object) - stored as regular file + updateDirectory = s3a.option.BucketsPath + "/" + bucket + } else { + // Versioned object - stored in .versions directory + updateDirectory = s3a.option.BucketsPath + "/" + bucket + object + s3_constants.VersionsFolder + } + } + + // Remove old tags and add new ones + for k := range entry.Extended { + if len(k) > len(S3TAG_PREFIX) && k[:len(S3TAG_PREFIX)] == S3TAG_PREFIX { + delete(entry.Extended, k) + } + } + + if entry.Extended == nil { + entry.Extended = make(map[string][]byte) + } + for k, v := range tags { + entry.Extended[S3TAG_PREFIX+k] = []byte(v) + } + + // Update the entry with tags + err = s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { + request := &filer_pb.UpdateEntryRequest{ + Directory: updateDirectory, + Entry: entry, + } + + if _, err := client.UpdateEntry(context.Background(), request); err != nil { + return err + } + return nil + }) + + if err != nil { + glog.Errorf("PutObjectTaggingHandler: failed to update entry: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + glog.V(3).Infof("PutObjectTaggingHandler: Successfully updated tags for %s/%s", bucket, object) w.WriteHeader(http.StatusOK) s3err.PostLog(r, http.StatusOK, s3err.ErrNone) } @@ -91,21 +264,136 @@ func (s3a *S3ApiServer) DeleteObjectTaggingHandler(w http.ResponseWriter, r *htt bucket, object := s3_constants.GetBucketAndObject(r) glog.V(3).Infof("DeleteObjectTaggingHandler %s %s", bucket, object) - target := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object)) - dir, name := target.DirAndName() + // Check for specific version ID in query parameters + versionId := r.URL.Query().Get("versionId") - err := s3a.rmTags(dir, name) + // Check if versioning is configured for the bucket (Enabled or Suspended) + versioningConfigured, err := s3a.isVersioningConfigured(bucket) if err != nil { if err == filer_pb.ErrNotFound { - glog.Errorf("DeleteObjectTaggingHandler %s: %v", r.URL, err) - s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) - } else { - glog.Errorf("DeleteObjectTaggingHandler %s: %v", r.URL, err) - s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket) + return } + glog.Errorf("DeleteObjectTaggingHandler: Error checking versioning status for bucket %s: %v", bucket, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) return } + var entry *filer_pb.Entry + + if versioningConfigured { + // Handle versioned object tagging deletion + if versionId != "" { + // Request for specific version + glog.V(2).Infof("DeleteObjectTaggingHandler: deleting tags for specific version %s of %s%s", versionId, bucket, object) + entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) + } else { + // Request for latest version + glog.V(2).Infof("DeleteObjectTaggingHandler: deleting tags for latest version of %s%s", bucket, object) + entry, err = s3a.getLatestObjectVersion(bucket, object) + } + + if err != nil { + glog.Errorf("DeleteObjectTaggingHandler: Failed to get object version %s for %s%s: %v", versionId, bucket, object, err) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + + // Check if this is a delete marker + if entry.Extended != nil { + if deleteMarker, exists := entry.Extended[s3_constants.ExtDeleteMarkerKey]; exists && string(deleteMarker) == "true" { + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + return + } + } + } else { + // Handle regular (non-versioned) object tagging deletion + target := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object)) + dir, name := target.DirAndName() + + err := s3a.rmTags(dir, name) + if err != nil { + if err == filer_pb.ErrNotFound { + glog.Errorf("DeleteObjectTaggingHandler %s: %v", r.URL, err) + s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) + } else { + glog.Errorf("DeleteObjectTaggingHandler %s: %v", r.URL, err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + } + return + } + + w.WriteHeader(http.StatusNoContent) + s3err.PostLog(r, http.StatusNoContent, s3err.ErrNone) + return + } + + // For versioned objects, determine the correct directory based on the version + var updateDirectory string + if versionId != "" { + // Specific version requested + if versionId == "null" { + // Null version (pre-versioning object) - stored as regular file + updateDirectory = s3a.option.BucketsPath + "/" + bucket + } else { + // Versioned object - stored in .versions directory + updateDirectory = s3a.option.BucketsPath + "/" + bucket + object + s3_constants.VersionsFolder + } + } else { + // Latest version in versioned bucket - could be null version or versioned object + // Extract version ID from the entry to determine where it's stored + var actualVersionId string + if entry.Extended != nil { + if versionIdBytes, exists := entry.Extended[s3_constants.ExtVersionIdKey]; exists { + actualVersionId = string(versionIdBytes) + } + } + + if actualVersionId == "null" || actualVersionId == "" { + // Null version (pre-versioning object) - stored as regular file + updateDirectory = s3a.option.BucketsPath + "/" + bucket + } else { + // Versioned object - stored in .versions directory + updateDirectory = s3a.option.BucketsPath + "/" + bucket + object + s3_constants.VersionsFolder + } + } + + // Remove all tags + hasDeletion := false + for k := range entry.Extended { + if len(k) > len(S3TAG_PREFIX) && k[:len(S3TAG_PREFIX)] == S3TAG_PREFIX { + delete(entry.Extended, k) + hasDeletion = true + } + } + + if !hasDeletion { + // No tags to delete - success + w.WriteHeader(http.StatusNoContent) + s3err.PostLog(r, http.StatusNoContent, s3err.ErrNone) + return + } + + // Update the entry + err = s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { + request := &filer_pb.UpdateEntryRequest{ + Directory: updateDirectory, + Entry: entry, + } + + if _, err := client.UpdateEntry(context.Background(), request); err != nil { + return err + } + return nil + }) + + if err != nil { + glog.Errorf("DeleteObjectTaggingHandler: failed to update entry: %v", err) + s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) + return + } + + glog.V(3).Infof("DeleteObjectTaggingHandler: Successfully deleted tags for %s/%s", bucket, object) w.WriteHeader(http.StatusNoContent) s3err.PostLog(r, http.StatusNoContent, s3err.ErrNone) } diff --git a/weed/s3api/s3api_version_id.go b/weed/s3api/s3api_version_id.go index 0ea3e6f89..db347d927 100644 --- a/weed/s3api/s3api_version_id.go +++ b/weed/s3api/s3api_version_id.go @@ -184,4 +184,3 @@ func (s3a *S3ApiServer) generateVersionIdForObject(bucket, object string) string useInvertedFormat := s3a.getVersionIdFormat(bucket, object) return generateVersionId(useInvertedFormat) } - From 2f6aa9822119e2c52086f78d1017b1402c883bbb Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Wed, 24 Dec 2025 19:07:08 -0800 Subject: [PATCH 27/66] Refactor: Replace removeDuplicateSlashes with NormalizeObjectKey (#7873) * Replace removeDuplicateSlashes with NormalizeObjectKey Use s3_constants.NormalizeObjectKey instead of removeDuplicateSlashes in most places for consistency. NormalizeObjectKey handles both duplicate slash removal and ensures the path starts with '/', providing more complete normalization. * Fix double slash issues after NormalizeObjectKey After using NormalizeObjectKey, object keys have a leading '/'. This commit ensures: - getVersionedObjectDir strips leading slash before concatenation - getEntry calls receive names without leading slash - String concatenation with '/' doesn't create '//' paths This prevents path construction errors like: /buckets/bucket//object (wrong) /buckets/bucket/object (correct) * ensure object key leading "/" * fix compilation * fix: Strip leading slash from object keys in S3 API responses After introducing NormalizeObjectKey, all internal object keys have a leading slash. However, S3 API responses must return keys without leading slashes to match AWS S3 behavior. Fixed in three functions: - addVersion: Strip slash for version list entries - processRegularFile: Strip slash for regular file entries - processExplicitDirectory: Strip slash for directory entries This ensures ListObjectVersions and similar APIs return keys like 'bar' instead of '/bar', matching S3 API specifications. * fix: Normalize keyMarker for consistent pagination comparison The S3 API provides keyMarker without a leading slash (e.g., 'object-001'), but after introducing NormalizeObjectKey, all internal object keys have leading slashes (e.g., '/object-001'). When comparing keyMarker < normalizedObjectKey in shouldSkipObjectForMarker, the ASCII value of '/' (47) is less than 'o' (111), causing all objects to be incorrectly skipped during pagination. This resulted in page 2 and beyond returning 0 results. Fix: Normalize the keyMarker when creating versionCollector so comparisons work correctly with normalized object keys. Fixes pagination tests: - TestVersioningPaginationOver1000Versions - TestVersioningPaginationMultipleObjectsManyVersions * refactor: Change NormalizeObjectKey to return keys without leading slash BREAKING STRATEGY CHANGE: Previously, NormalizeObjectKey added a leading slash to all object keys, which required stripping it when returning keys to S3 API clients and caused complexity in marker normalization for pagination. NEW STRATEGY: - NormalizeObjectKey now returns keys WITHOUT leading slash (e.g., 'foo/bar' not '/foo/bar') - This matches the S3 API format directly - All path concatenations now explicitly add '/' between bucket and object - No need to strip slashes in responses or normalize markers Changes: 1. Modified NormalizeObjectKey to strip leading slash instead of adding it 2. Fixed all path concatenations to use: - BucketsPath + '/' + bucket + '/' + object instead of: - BucketsPath + '/' + bucket + object 3. Reverted response key stripping in: - addVersion() - processRegularFile() - processExplicitDirectory() 4. Reverted keyMarker normalization in findVersionsRecursively() 5. Updated matchesPrefixFilter() to work with keys without leading slash 6. Fixed paths in handlers: - s3api_object_handlers.go (GetObject, HeadObject, cacheRemoteObjectForStreaming) - s3api_object_handlers_postpolicy.go - s3api_object_handlers_tagging.go - s3api_object_handlers_acl.go - s3api_version_id.go (getVersionedObjectDir, getVersionIdFormat) - s3api_object_versioning.go (getObjectVersionList, updateLatestVersionAfterDeletion) All versioning tests pass including pagination stress tests. * adjust format * Update post policy tests to match new NormalizeObjectKey behavior - Update TestPostPolicyKeyNormalization to expect keys without leading slashes - Update TestNormalizeObjectKey to expect keys without leading slashes - Update TestPostPolicyFilenameSubstitution to expect keys without leading slashes - Update path construction in tests to use new pattern: BucketsPath + '/' + bucket + '/' + object * Fix ListObjectVersions prefix filtering Remove leading slash addition to prefix parameter to allow correct filtering of .versions directories when listing object versions with a specific prefix. The prefix parameter should match entry paths relative to bucket root. Adding a leading slash was breaking the prefix filter for paginated requests. Fixes pagination issue where second page returned 0 versions instead of continuing with remaining versions. * no leading slash * Fix urlEscapeObject to add leading slash for filer paths NormalizeObjectKey now returns keys without leading slashes to match S3 API format. However, urlEscapeObject is used for filer paths which require leading slashes. Add leading slash back after normalization to ensure filer paths are correct. Fixes TestS3ApiServer_toFilerPath test failures. * adjust tests * normalize * Fix: Normalize prefixes and markers in LIST operations using NormalizeObjectKey Ensure consistent key normalization across all S3 operations (GET, PUT, LIST). Previously, LIST operations were not applying the same normalization rules (handling backslashes, duplicate slashes, leading slashes) as GET/PUT operations. Changes: - Updated normalizePrefixMarker() to call NormalizeObjectKey for both prefix and marker - This ensures prefixes with leading slashes, backslashes, or duplicate slashes are handled consistently with how object keys are normalized - Fixes Parquet test failures where pads.write_dataset creates implicit directory structures that couldn't be discovered by subsequent LIST operations - Added TestPrefixNormalizationInList and TestListPrefixConsistency tests All existing LIST tests continue to pass with the normalization improvements. * Add debugging logging to LIST operations to track prefix normalization * Fix: Remove leading slash addition from GetPrefix to work with NormalizeObjectKey The NormalizeObjectKey function removes leading slashes to match S3 API format (e.g., 'foo/bar' not '/foo/bar'). However, GetPrefix was adding a leading slash back, which caused LIST operations to fail with incorrect path handling. Now GetPrefix only normalizes duplicate slashes without adding a leading slash, which allows NormalizeObjectKey changes to work correctly for S3 LIST operations. All Parquet integration tests now pass (20/20). * Fix: Handle object paths without leading slash in checkDirectoryObject NormalizeObjectKey() removes the leading slash to match S3 API format. However, checkDirectoryObject() was assuming the object path has a leading slash when processing directory markers (paths ending with '/'). Now we ensure the object has a leading slash before processing it for filer operations. Fixes implicit directory marker test (explicit_dir/) while keeping Parquet integration tests passing (20/20). All tests pass: - Implicit directory tests: 6/6 - Parquet integration tests: 20/20 * Fix: Handle explicit directory markers with trailing slashes Explicit directory markers created with put_object(Key='dir/', ...) are stored in the filer with the trailing slash as part of the name. The checkDirectoryObject() function now checks for both: 1. Explicit directories: lookup with trailing slash preserved (e.g., 'explicit_dir/') 2. Implicit directories: lookup without trailing slash (e.g., 'implicit_dir') This ensures both types of directory markers are properly recognized. All tests pass: - Implicit directory tests: 6/6 (including explicit directory marker test) - Parquet integration tests: 20/20 * Fix: Preserve trailing slash in NormalizeObjectKey NormalizeObjectKey now preserves trailing slashes when normalizing object keys. This is important for explicit directory markers like 'explicit_dir/' which rely on the trailing slash to be recognized as directory objects. The normalization process: 1. Notes if trailing slash was present 2. Removes duplicate slashes and converts backslashes 3. Removes leading slash for S3 API format 4. Restores trailing slash if it was in the original This ensures explicit directory markers created with put_object(Key='dir/', ...) are properly normalized and can be looked up by their exact name. All tests pass: - Implicit directory tests: 6/6 - Parquet integration tests: 20/20 * clean object * Fix: Don't restore trailing slash if result is empty When normalizing paths that are only slashes (e.g., '///', '/'), the function should return an empty string, not a single slash. The fix ensures we only restore the trailing slash if the result is non-empty. This fixes the 'just_slashes' test case: - Input: '///' - Expected: '' - Previous: '/' - Fixed: '' All tests now pass: - Unit tests: TestNormalizeObjectKey (13/13) - Implicit directory tests: 6/6 - Parquet integration tests: 20/20 * prefixEndsOnDelimiter * Update s3api_object_handlers_list.go * Update s3api_object_handlers_list.go * handle create directory --- test/s3/parquet/debug_write_dataset.py | 86 +++++++++++++++++++ .../s3/parquet/test_implicit_directory_fix.py | 2 +- weed/replication/sink/gcssink/gcs_sink.go | 5 +- weed/s3api/s3_constants/header.go | 20 +++-- weed/s3api/s3_constants/header_test.go | 28 +++--- weed/s3api/s3api_conditional_headers_test.go | 2 +- weed/s3api/s3api_list_normalization_test.go | 85 ++++++++++++++++++ weed/s3api/s3api_object_handlers.go | 51 +++++------ weed/s3api/s3api_object_handlers_acl.go | 16 ++-- weed/s3api/s3api_object_handlers_copy.go | 16 ++-- weed/s3api/s3api_object_handlers_delete.go | 6 +- .../s3api/s3api_object_handlers_postpolicy.go | 2 +- .../s3api_object_handlers_postpolicy_test.go | 66 +++++++------- weed/s3api/s3api_object_handlers_put.go | 24 +++++- weed/s3api/s3api_object_handlers_tagging.go | 34 ++++---- weed/s3api/s3api_object_versioning.go | 61 ++++++------- weed/s3api/s3api_version_id.go | 4 +- .../storage/backend/s3_backend/s3_download.go | 2 +- 18 files changed, 349 insertions(+), 161 deletions(-) create mode 100644 test/s3/parquet/debug_write_dataset.py create mode 100644 weed/s3api/s3api_list_normalization_test.go diff --git a/test/s3/parquet/debug_write_dataset.py b/test/s3/parquet/debug_write_dataset.py new file mode 100644 index 000000000..41762497b --- /dev/null +++ b/test/s3/parquet/debug_write_dataset.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Debug script to understand what pads.write_dataset creates.""" + +import sys +import pyarrow as pa +import pyarrow.dataset as pads +import s3fs + +# Create a simple test table +table = pa.table({'id': [1, 2, 3], 'value': [1.0, 2.0, 3.0]}) + +# Initialize S3 filesystem +fs = s3fs.S3FileSystem( + client_kwargs={'endpoint_url': 'http://localhost:8333'}, + key='some_access_key1', + secret='some_secret_key1', + use_listings_cache=False, +) + +# Create bucket +if not fs.exists('test-bucket'): + fs.mkdir('test-bucket') + +# Write with pads.write_dataset +test_path = 's3://test-bucket/test-write-simple/' +print(f"Writing to: {test_path}") +print(f"Table schema: {table.schema}") +print(f"Table rows: {table.num_rows}") + +try: + pads.write_dataset(table, test_path, format='parquet', filesystem=fs) + print("\n✓ Write succeeded") + + # List all files recursively + print(f"\nListing all files recursively under {test_path}:") + import os + base_path = 'test-bucket/test-write-simple' + def list_recursive(path, indent=0): + try: + items = fs.ls(path, detail=False) + for item in items: + is_dir = fs.isdir(item) + item_name = item.split('/')[-1] if '/' in item else item + if is_dir: + print(f"{' ' * indent}📁 {item_name}/") + list_recursive(item, indent + 1) + else: + # Get file size + try: + info = fs.info(item) + size = info.get('size', 0) + print(f"{' ' * indent}📄 {item_name} ({size} bytes)") + except: + print(f"{' ' * indent}📄 {item_name}") + except Exception as e: + print(f"{' ' * indent}Error listing {path}: {e}") + + list_recursive(base_path) + + # Try to read back with different methods + print(f"\n\nTrying to read back using different methods:") + + # Method 1: pads.dataset with the same path + print(f"\n1. pads.dataset('{test_path}'):") + try: + dataset = pads.dataset(test_path, format='parquet', filesystem=fs) + result = dataset.to_table() + print(f" ✓ Success: {result.num_rows} rows") + except Exception as e: + print(f" ✗ Failed: {e}") + + # Method 2: pads.dataset with the dir containing parquet files + print(f"\n2. pads.dataset without trailing slash:") + test_path_no_slash = 's3://test-bucket/test-write-simple' + try: + dataset = pads.dataset(test_path_no_slash, format='parquet', filesystem=fs) + result = dataset.to_table() + print(f" ✓ Success: {result.num_rows} rows") + except Exception as e: + print(f" ✗ Failed: {e}") + +except Exception as e: + import traceback + print(f"✗ Error: {e}") + traceback.print_exc() + sys.exit(1) diff --git a/test/s3/parquet/test_implicit_directory_fix.py b/test/s3/parquet/test_implicit_directory_fix.py index 9ac8f0346..2ed52e5d7 100755 --- a/test/s3/parquet/test_implicit_directory_fix.py +++ b/test/s3/parquet/test_implicit_directory_fix.py @@ -182,7 +182,7 @@ def test_explicit_directory_marker(fs, s3_client): logger.info("="*80) # Create an explicit directory marker - logger.info(f"\nCreating explicit directory: {BUCKET_NAME}/explicit_dir/") + logger.info(f"Creating explicit directory: {BUCKET_NAME}/explicit_dir/") try: s3_client.put_object( Bucket=BUCKET_NAME, diff --git a/weed/replication/sink/gcssink/gcs_sink.go b/weed/replication/sink/gcssink/gcs_sink.go index 6fe78b21b..1a930fd4a 100644 --- a/weed/replication/sink/gcssink/gcs_sink.go +++ b/weed/replication/sink/gcssink/gcs_sink.go @@ -3,9 +3,10 @@ package gcssink import ( "context" "fmt" - "github.com/seaweedfs/seaweedfs/weed/replication/repl_util" "os" + "github.com/seaweedfs/seaweedfs/weed/replication/repl_util" + "cloud.google.com/go/storage" "google.golang.org/api/option" @@ -83,7 +84,7 @@ func (g *GcsSink) DeleteEntry(key string, isDirectory, deleteIncludeChunks bool, } if err := g.client.Bucket(g.bucket).Object(key).Delete(context.Background()); err != nil { - return fmt.Errorf("gcs delete %s%s: %v", g.bucket, key, err) + return fmt.Errorf("gcs delete %s/%s: %v", g.bucket, key, err) } return nil diff --git a/weed/s3api/s3_constants/header.go b/weed/s3api/s3_constants/header.go index 4b34f397e..f379c91ed 100644 --- a/weed/s3api/s3_constants/header.go +++ b/weed/s3api/s3_constants/header.go @@ -144,16 +144,26 @@ func GetBucketAndObject(r *http.Request) (bucket, object string) { return } -// NormalizeObjectKey ensures the object key has a leading slash and no duplicate slashes. +// NormalizeObjectKey normalizes object keys by removing duplicate slashes and converting backslashes. // This normalizes keys from various sources (URL path, form values, etc.) to a consistent format. // It also converts Windows-style backslashes to forward slashes for cross-platform compatibility. +// Returns keys WITHOUT leading slash to match S3 API format (e.g., "foo/bar" not "/foo/bar"). +// Preserves trailing slash if present (e.g., "foo/" stays "foo/"). func NormalizeObjectKey(object string) string { + // Preserve trailing slash if present + hasTrailingSlash := strings.HasSuffix(object, "/") + // Convert Windows-style backslashes to forward slashes object = strings.ReplaceAll(object, "\\", "/") object = removeDuplicateSlashes(object) - if !strings.HasPrefix(object, "/") { - object = "/" + object + // Remove leading slash to match S3 API format + object = strings.TrimPrefix(object, "/") + + // Restore trailing slash if it was present and result is not empty + if hasTrailingSlash && object != "" && !strings.HasSuffix(object, "/") { + object = object + "/" } + return object } @@ -181,10 +191,6 @@ func GetPrefix(r *http.Request) string { query := r.URL.Query() prefix := query.Get("prefix") prefix = removeDuplicateSlashes(prefix) - if !strings.HasPrefix(prefix, "/") { - prefix = "/" + prefix - } - return prefix } diff --git a/weed/s3api/s3_constants/header_test.go b/weed/s3api/s3_constants/header_test.go index f1cb06fac..4b1c1b8ca 100644 --- a/weed/s3api/s3_constants/header_test.go +++ b/weed/s3api/s3_constants/header_test.go @@ -13,67 +13,67 @@ func TestNormalizeObjectKey(t *testing.T) { { name: "simple key", input: "file.txt", - expected: "/file.txt", + expected: "file.txt", }, { name: "key with leading slash", input: "/file.txt", - expected: "/file.txt", + expected: "file.txt", }, { name: "key with directory", input: "folder/file.txt", - expected: "/folder/file.txt", + expected: "folder/file.txt", }, { name: "key with leading slash and directory", input: "/folder/file.txt", - expected: "/folder/file.txt", + expected: "folder/file.txt", }, { name: "key with duplicate slashes", input: "folder//subfolder///file.txt", - expected: "/folder/subfolder/file.txt", + expected: "folder/subfolder/file.txt", }, { name: "Windows backslash - simple", input: "folder\\file.txt", - expected: "/folder/file.txt", + expected: "folder/file.txt", }, { name: "Windows backslash - nested", input: "folder\\subfolder\\file.txt", - expected: "/folder/subfolder/file.txt", + expected: "folder/subfolder/file.txt", }, { name: "Windows backslash - with leading slash", input: "/folder\\subfolder\\file.txt", - expected: "/folder/subfolder/file.txt", + expected: "folder/subfolder/file.txt", }, { name: "mixed slashes", input: "folder\\subfolder/another\\file.txt", - expected: "/folder/subfolder/another/file.txt", + expected: "folder/subfolder/another/file.txt", }, { name: "Windows full path style (edge case)", input: "C:\\Users\\test\\file.txt", - expected: "/C:/Users/test/file.txt", + expected: "C:/Users/test/file.txt", }, { name: "empty string", input: "", - expected: "/", + expected: "", }, { name: "just a slash", input: "/", - expected: "/", + expected: "", }, { name: "just a backslash", input: "\\", - expected: "/", + expected: "", }, } @@ -129,5 +129,3 @@ func TestRemoveDuplicateSlashes(t *testing.T) { }) } } - - diff --git a/weed/s3api/s3api_conditional_headers_test.go b/weed/s3api/s3api_conditional_headers_test.go index 834f57305..20c92af0e 100644 --- a/weed/s3api/s3api_conditional_headers_test.go +++ b/weed/s3api/s3api_conditional_headers_test.go @@ -475,7 +475,7 @@ func createTestGetRequest(bucket, object string) *http.Request { Method: "GET", Header: make(http.Header), URL: &url.URL{ - Path: fmt.Sprintf("/%s%s", bucket, object), + Path: fmt.Sprintf("/%s/%s", bucket, object), }, } } diff --git a/weed/s3api/s3api_list_normalization_test.go b/weed/s3api/s3api_list_normalization_test.go new file mode 100644 index 000000000..128457b8f --- /dev/null +++ b/weed/s3api/s3api_list_normalization_test.go @@ -0,0 +1,85 @@ +package s3api + +import ( + "testing" + + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" +) + +// TestPrefixNormalizationInList verifies that prefixes are normalized consistently in list operations +func TestPrefixNormalizationInList(t *testing.T) { + tests := []struct { + name string + inputPrefix string + expectedPrefix string + description string + }{ + { + name: "simple prefix", + inputPrefix: "parquet-tests/abc123/", + expectedPrefix: "parquet-tests/abc123/", + description: "Normal prefix with trailing slash", + }, + { + name: "leading slash", + inputPrefix: "/parquet-tests/abc123/", + expectedPrefix: "parquet-tests/abc123/", + description: "Prefix with leading slash should be stripped", + }, + { + name: "duplicate slashes", + inputPrefix: "parquet-tests//abc123/", + expectedPrefix: "parquet-tests/abc123/", + description: "Prefix with duplicate slashes should be cleaned", + }, + { + name: "backslashes", + inputPrefix: "parquet-tests\\abc123\\", + expectedPrefix: "parquet-tests/abc123/", + description: "Backslashes should be converted to forward slashes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Normalize using NormalizeObjectKey (same as object keys) + normalizedPrefix := s3_constants.NormalizeObjectKey(tt.inputPrefix) + + if normalizedPrefix != tt.expectedPrefix { + t.Errorf("Prefix normalization mismatch:\n Input: %q\n Expected: %q\n Got: %q\n Desc: %s", + tt.inputPrefix, tt.expectedPrefix, normalizedPrefix, tt.description) + } + }) + } +} + +// TestListPrefixConsistency verifies that objects written and listed use consistent key formats +func TestListPrefixConsistency(t *testing.T) { + // When an object is written to "parquet-tests/123/data.parquet", + // and we list with prefix "parquet-tests/123/", + // we should find that object + + objectKey := "parquet-tests/123/data.parquet" + listPrefix := "parquet-tests/123/" + + // Normalize as would happen in PUT + normalizedObjectKey := s3_constants.NormalizeObjectKey(objectKey) + + // Check that the list prefix would match the object path + if !startsWithPrefix(normalizedObjectKey, listPrefix) { + t.Errorf("List prefix mismatch:\n Object: %q\n Prefix: %q\n Object doesn't start with prefix", + normalizedObjectKey, listPrefix) + } +} + +func startsWithPrefix(objectKey, prefix string) bool { + // Normalize the prefix using the same logic as NormalizeObjectKey + normalizedPrefix := s3_constants.NormalizeObjectKey(prefix) + + // Check if the object starts with the normalized prefix + if normalizedPrefix == "" { + return true + } + + return objectKey == normalizedPrefix || objectKey[:len(normalizedPrefix)] == normalizedPrefix +} diff --git a/weed/s3api/s3api_object_handlers.go b/weed/s3api/s3api_object_handlers.go index 054a26264..67c40d0c3 100644 --- a/weed/s3api/s3api_object_handlers.go +++ b/weed/s3api/s3api_object_handlers.go @@ -177,11 +177,12 @@ func mimeDetect(r *http.Request, dataReader io.Reader) io.ReadCloser { } func urlEscapeObject(object string) string { - t := urlPathEscape(removeDuplicateSlashes(object)) - if strings.HasPrefix(t, "/") { - return t + normalized := s3_constants.NormalizeObjectKey(object) + // Ensure leading slash for filer paths + if normalized != "" && !strings.HasPrefix(normalized, "/") { + normalized = "/" + normalized } - return "/" + t + return urlPathEscape(normalized) } func entryUrlEncode(dir string, entry string, encodingTypeUrl bool) (dirName string, entryName string, prefix string) { @@ -286,7 +287,7 @@ func (s3a *S3ApiServer) checkDirectoryObject(bucket, object string) (*filer_pb.E } bucketDir := s3a.option.BucketsPath + "/" + bucket - cleanObject := strings.TrimSuffix(strings.TrimPrefix(object, "/"), "/") + cleanObject := strings.TrimSuffix(object, "/") if cleanObject == "" { return nil, true, nil // Root level directory object, but we don't handle it @@ -437,8 +438,8 @@ func newListEntry(entry *filer_pb.Entry, key string, dir string, name string, bu func (s3a *S3ApiServer) toFilerPath(bucket, object string) string { // Returns the raw file path - no URL escaping needed // The path is used directly, not embedded in a URL - object = removeDuplicateSlashes(object) - return fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object) + object = s3_constants.NormalizeObjectKey(object) + return fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, bucket, object) } // hasConditionalHeaders checks if the request has any conditional headers @@ -535,7 +536,7 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request) if versionId != "" { // Request for specific version - must look in .versions directory - glog.V(3).Infof("GetObject: requesting specific version %s for %s%s", versionId, bucket, object) + glog.V(3).Infof("GetObject: requesting specific version %s for %s/%s", versionId, bucket, object) entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) if err != nil { glog.Errorf("Failed to get specific version %s: %v", versionId, err) @@ -550,7 +551,7 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request) // - If .versions/ doesn't exist (ErrNotFound): only null version at regular path, use it directly // - If transient error: fall back to getLatestObjectVersion which has retry logic bucketDir := s3a.option.BucketsPath + "/" + bucket - normalizedObject := removeDuplicateSlashes(object) + normalizedObject := s3_constants.NormalizeObjectKey(object) versionsDir := normalizedObject + s3_constants.VersionsFolder // Quick check (no retries) for .versions/ directory @@ -561,7 +562,7 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request) // Use getLatestObjectVersion which will properly find the newest version entry, err = s3a.getLatestObjectVersion(bucket, object) if err != nil { - glog.Errorf("GetObject: Failed to get latest version for %s%s: %v", bucket, object, err) + glog.Errorf("GetObject: Failed to get latest version for %s/%s: %v", bucket, object, err) s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) return } @@ -574,16 +575,16 @@ func (s3a *S3ApiServer) GetObjectHandler(w http.ResponseWriter, r *http.Request) targetVersionId = "null" } else { // No object at regular path either - object doesn't exist - glog.Errorf("GetObject: object not found at regular path or .versions for %s%s", bucket, object) + glog.Errorf("GetObject: object not found at regular path or .versions for %s/%s", bucket, object) s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) return } } else { // Transient error checking .versions/, fall back to getLatestObjectVersion with retries - glog.V(2).Infof("GetObject: transient error checking .versions for %s%s: %v, falling back to getLatestObjectVersion", bucket, object, versionsErr) + glog.V(2).Infof("GetObject: transient error checking .versions for %s/%s: %v, falling back to getLatestObjectVersion", bucket, object, versionsErr) entry, err = s3a.getLatestObjectVersion(bucket, object) if err != nil { - glog.Errorf("GetObject: Failed to get latest version for %s%s: %v", bucket, object, err) + glog.Errorf("GetObject: Failed to get latest version for %s/%s: %v", bucket, object, err) s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) return } @@ -2148,10 +2149,10 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request if versionId != "" { // Request for specific version - glog.V(2).Infof("HeadObject: requesting specific version %s for %s%s", versionId, bucket, object) + glog.V(2).Infof("HeadObject: requesting specific version %s for %s/%s", versionId, bucket, object) entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) if err != nil { - glog.Errorf("Failed to get specific version %s: %v", versionId, err) + glog.Errorf("Failed to get specific version %s for %s/%s: %v", versionId, bucket, object, err) s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) return } @@ -2163,7 +2164,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request // - If .versions/ doesn't exist (ErrNotFound): only null version at regular path, use it directly // - If transient error: fall back to getLatestObjectVersion which has retry logic bucketDir := s3a.option.BucketsPath + "/" + bucket - normalizedObject := removeDuplicateSlashes(object) + normalizedObject := s3_constants.NormalizeObjectKey(object) versionsDir := normalizedObject + s3_constants.VersionsFolder // Quick check (no retries) for .versions/ directory @@ -2174,7 +2175,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request // Use getLatestObjectVersion which will properly find the newest version entry, err = s3a.getLatestObjectVersion(bucket, object) if err != nil { - glog.Errorf("HeadObject: Failed to get latest version for %s%s: %v", bucket, object, err) + glog.Errorf("HeadObject: Failed to get latest version for %s/%s: %v", bucket, object, err) s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) return } @@ -2187,16 +2188,16 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request targetVersionId = "null" } else { // No object at regular path either - object doesn't exist - glog.Errorf("HeadObject: object not found at regular path or .versions for %s%s", bucket, object) + glog.Errorf("HeadObject: object not found at regular path or .versions for %s/%s", bucket, object) s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) return } } else { // Transient error checking .versions/, fall back to getLatestObjectVersion with retries - glog.V(2).Infof("HeadObject: transient error checking .versions for %s%s: %v, falling back to getLatestObjectVersion", bucket, object, versionsErr) + glog.V(2).Infof("HeadObject: transient error checking .versions for %s/%s: %v, falling back to getLatestObjectVersion", bucket, object, versionsErr) entry, err = s3a.getLatestObjectVersion(bucket, object) if err != nil { - glog.Errorf("HeadObject: Failed to get latest version for %s%s: %v", bucket, object, err) + glog.Errorf("HeadObject: Failed to get latest version for %s/%s: %v", bucket, object, err) s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) return } @@ -2367,7 +2368,7 @@ func (s3a *S3ApiServer) HeadObjectHandler(w http.ResponseWriter, r *http.Request } // Detect and handle SSE - glog.V(3).Infof("HeadObjectHandler: Retrieved entry for %s%s - %d chunks", bucket, object, len(objectEntryForSSE.Chunks)) + glog.V(3).Infof("HeadObjectHandler: Retrieved entry for %s/%s - %d chunks", bucket, object, len(objectEntryForSSE.Chunks)) sseType := s3a.detectPrimarySSEType(objectEntryForSSE) glog.V(2).Infof("HeadObjectHandler: Detected SSE type: %s", sseType) if sseType != "" && sseType != "None" { @@ -2441,7 +2442,7 @@ func writeFinalResponse(w http.ResponseWriter, proxyResponse *http.Response, bod // fetchObjectEntry fetches the filer entry for an object // Returns nil if not found (not an error), or propagates other errors func (s3a *S3ApiServer) fetchObjectEntry(bucket, object string) (*filer_pb.Entry, error) { - objectPath := fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object) + objectPath := fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, bucket, object) fetchedEntry, fetchErr := s3a.getEntry("", objectPath) if fetchErr != nil { if errors.Is(fetchErr, filer_pb.ErrNotFound) { @@ -2455,7 +2456,7 @@ func (s3a *S3ApiServer) fetchObjectEntry(bucket, object string) (*filer_pb.Entry // fetchObjectEntryRequired fetches the filer entry for an object // Returns an error if the object is not found or any other error occurs func (s3a *S3ApiServer) fetchObjectEntryRequired(bucket, object string) (*filer_pb.Entry, error) { - objectPath := fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object) + objectPath := fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, bucket, object) fetchedEntry, fetchErr := s3a.getEntry("", objectPath) if fetchErr != nil { return nil, fetchErr // Return error for both not-found and other errors @@ -3367,7 +3368,7 @@ func (s3a *S3ApiServer) getMultipartInfo(entry *filer_pb.Entry, partNumber int) // This is shared by all remote object caching functions. func (s3a *S3ApiServer) buildRemoteObjectPath(bucket, object string) (dir, name string) { dir = s3a.option.BucketsPath + "/" + bucket - name = strings.TrimPrefix(removeDuplicateSlashes(object), "/") + name = s3_constants.NormalizeObjectKey(object) if idx := strings.LastIndex(name, "/"); idx > 0 { dir = dir + "/" + name[:idx] name = name[idx+1:] @@ -3433,7 +3434,7 @@ func (s3a *S3ApiServer) cacheRemoteObjectForStreaming(r *http.Request, entry *fi var dir, name string if versionId != "" && versionId != "null" { // This is a specific version - entry is located at /buckets//.versions/v_ - normalizedObject := strings.TrimPrefix(removeDuplicateSlashes(object), "/") + normalizedObject := s3_constants.NormalizeObjectKey(object) dir = s3a.option.BucketsPath + "/" + bucket + "/" + normalizedObject + s3_constants.VersionsFolder name = s3a.getVersionFileName(versionId) } else { diff --git a/weed/s3api/s3api_object_handlers_acl.go b/weed/s3api/s3api_object_handlers_acl.go index 212354a30..b0b1c3aa2 100644 --- a/weed/s3api/s3api_object_handlers_acl.go +++ b/weed/s3api/s3api_object_handlers_acl.go @@ -45,16 +45,16 @@ func (s3a *S3ApiServer) GetObjectAclHandler(w http.ResponseWriter, r *http.Reque // Handle versioned object ACL retrieval - use same logic as GetObjectHandler if versionId != "" { // Request for specific version - glog.V(2).Infof("GetObjectAclHandler: requesting ACL for specific version %s of %s%s", versionId, bucket, object) + glog.V(2).Infof("GetObjectAclHandler: requesting ACL for specific version %s of %s/%s", versionId, bucket, object) entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) } else { // Request for latest version - glog.V(2).Infof("GetObjectAclHandler: requesting ACL for latest version of %s%s", bucket, object) + glog.V(2).Infof("GetObjectAclHandler: requesting ACL for latest version of %s/%s", bucket, object) entry, err = s3a.getLatestObjectVersion(bucket, object) } if err != nil { - glog.Errorf("GetObjectAclHandler: Failed to get object version %s for %s%s: %v", versionId, bucket, object, err) + glog.Errorf("GetObjectAclHandler: Failed to get object version %s for %s/%s: %v", versionId, bucket, object, err) s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) return } @@ -188,16 +188,16 @@ func (s3a *S3ApiServer) PutObjectAclHandler(w http.ResponseWriter, r *http.Reque // Handle versioned object ACL modification - use same logic as GetObjectHandler if versionId != "" { // Request for specific version - glog.V(2).Infof("PutObjectAclHandler: modifying ACL for specific version %s of %s%s", versionId, bucket, object) + glog.V(2).Infof("PutObjectAclHandler: modifying ACL for specific version %s of %s/%s", versionId, bucket, object) entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) } else { // Request for latest version - glog.V(2).Infof("PutObjectAclHandler: modifying ACL for latest version of %s%s", bucket, object) + glog.V(2).Infof("PutObjectAclHandler: modifying ACL for latest version of %s/%s", bucket, object) entry, err = s3a.getLatestObjectVersion(bucket, object) } if err != nil { - glog.Errorf("PutObjectAclHandler: Failed to get object version %s for %s%s: %v", versionId, bucket, object, err) + glog.Errorf("PutObjectAclHandler: Failed to get object version %s for %s/%s: %v", versionId, bucket, object, err) s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) return } @@ -306,7 +306,7 @@ func (s3a *S3ApiServer) PutObjectAclHandler(w http.ResponseWriter, r *http.Reque if versioningConfigured { if versionId != "" && versionId != "null" { // Versioned object - update the specific version file in .versions directory - updateDirectory = s3a.option.BucketsPath + "/" + bucket + object + s3_constants.VersionsFolder + updateDirectory = s3a.option.BucketsPath + "/" + bucket + "/" + object + s3_constants.VersionsFolder } else { // Latest version in versioned bucket - could be null version or versioned object // Extract version ID from the entry to determine where it's stored @@ -322,7 +322,7 @@ func (s3a *S3ApiServer) PutObjectAclHandler(w http.ResponseWriter, r *http.Reque updateDirectory = s3a.option.BucketsPath + "/" + bucket } else { // Versioned object - stored in .versions directory - updateDirectory = s3a.option.BucketsPath + "/" + bucket + object + s3_constants.VersionsFolder + updateDirectory = s3a.option.BucketsPath + "/" + bucket + "/" + object + s3_constants.VersionsFolder } } } else { diff --git a/weed/s3api/s3api_object_handlers_copy.go b/weed/s3api/s3api_object_handlers_copy.go index 01cf9484b..26775f9ae 100644 --- a/weed/s3api/s3api_object_handlers_copy.go +++ b/weed/s3api/s3api_object_handlers_copy.go @@ -65,7 +65,7 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request replaceMeta, replaceTagging := replaceDirective(r.Header) if (srcBucket == dstBucket && srcObject == dstObject || cpSrcPath == "") && (replaceMeta || replaceTagging) { - fullPath := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, dstBucket, dstObject)) + fullPath := util.FullPath(fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, dstBucket, dstObject)) dir, name := fullPath.DirAndName() entry, err := s3a.getEntry(dir, name) if err != nil || entry.IsDirectory { @@ -116,7 +116,7 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request } else if srcVersioningState == s3_constants.VersioningSuspended { // Versioning suspended - current object is stored as regular file ("null" version) // Try regular file first, fall back to latest version if needed - srcPath := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, srcBucket, srcObject)) + srcPath := util.FullPath(fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, srcBucket, srcObject)) dir, name := srcPath.DirAndName() entry, err = s3a.getEntry(dir, name) if err != nil { @@ -126,7 +126,7 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request } } else { // No versioning configured - use regular retrieval - srcPath := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, srcBucket, srcObject)) + srcPath := util.FullPath(fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, srcBucket, srcObject)) dir, name := srcPath.DirAndName() entry, err = s3a.getEntry(dir, name) } @@ -284,7 +284,7 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request // Calculate ETag for versioning filerEntry := &filer.Entry{ - FullPath: util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, dstBucket, dstObject)), + FullPath: util.FullPath(fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, dstBucket, dstObject)), Attr: filer.Attr{ FileSize: dstEntry.Attributes.FileSize, Mtime: time.Unix(dstEntry.Attributes.Mtime, 0), @@ -328,7 +328,7 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request // Remove any versioning-related metadata from source that shouldn't carry over cleanupVersioningMetadata(dstEntry.Extended) - dstPath := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, dstBucket, dstObject)) + dstPath := util.FullPath(fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, dstBucket, dstObject)) dstDir, dstName := dstPath.DirAndName() // Check if destination exists and remove it first (S3 copy overwrites) @@ -381,7 +381,7 @@ func pathToBucketAndObject(path string) (bucket, object string) { parts := strings.SplitN(path, "/", 2) if len(parts) == 2 { bucket = parts[0] - object = "/" + parts[1] + object = parts[1] return bucket, object } else if len(parts) == 1 && parts[0] != "" { // Only bucket provided, no object @@ -497,7 +497,7 @@ func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Req } else if srcVersioningState == s3_constants.VersioningSuspended { // Versioning suspended - current object is stored as regular file ("null" version) // Try regular file first, fall back to latest version if needed - srcPath := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, srcBucket, srcObject)) + srcPath := util.FullPath(fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, srcBucket, srcObject)) dir, name := srcPath.DirAndName() entry, err = s3a.getEntry(dir, name) if err != nil { @@ -507,7 +507,7 @@ func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Req } } else { // No versioning configured - use regular retrieval - srcPath := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, srcBucket, srcObject)) + srcPath := util.FullPath(fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, srcBucket, srcObject)) dir, name := srcPath.DirAndName() entry, err = s3a.getEntry(dir, name) } diff --git a/weed/s3api/s3api_object_handlers_delete.go b/weed/s3api/s3api_object_handlers_delete.go index da0b78654..8618933df 100644 --- a/weed/s3api/s3api_object_handlers_delete.go +++ b/weed/s3api/s3api_object_handlers_delete.go @@ -121,7 +121,7 @@ func (s3a *S3ApiServer) DeleteObjectHandler(w http.ResponseWriter, r *http.Reque return } - target := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object)) + target := util.FullPath(fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, bucket, object)) dir, name := target.DirAndName() err := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { @@ -331,9 +331,9 @@ func (s3a *S3ApiServer) DeleteMultipleObjectsHandler(w http.ResponseWriter, r *h 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 = object.Key[:lastSeparator] } - parentDirectoryPath = fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, parentDirectoryPath) + parentDirectoryPath = fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, bucket, parentDirectoryPath) err := doDeleteEntry(client, parentDirectoryPath, entryName, isDeleteData, isRecursive) if err == nil { diff --git a/weed/s3api/s3api_object_handlers_postpolicy.go b/weed/s3api/s3api_object_handlers_postpolicy.go index e6e885848..58e2a89ac 100644 --- a/weed/s3api/s3api_object_handlers_postpolicy.go +++ b/weed/s3api/s3api_object_handlers_postpolicy.go @@ -114,7 +114,7 @@ func (s3a *S3ApiServer) PostPolicyBucketHandler(w http.ResponseWriter, r *http.R } } - filePath := fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object) + filePath := fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, bucket, object) // Get ContentType from post formData // Otherwise from formFile ContentType diff --git a/weed/s3api/s3api_object_handlers_postpolicy_test.go b/weed/s3api/s3api_object_handlers_postpolicy_test.go index 357fb9c7c..0e181d7a1 100644 --- a/weed/s3api/s3api_object_handlers_postpolicy_test.go +++ b/weed/s3api/s3api_object_handlers_postpolicy_test.go @@ -15,54 +15,53 @@ import ( ) // TestPostPolicyKeyNormalization tests that object keys from presigned POST -// are properly normalized with a leading slash and duplicate slashes removed. -// This addresses issue #7713 where keys without leading slashes caused -// bucket and key to be concatenated without a separator. +// are properly normalized without leading slashes and with duplicate slashes removed. +// This ensures consistent key handling across the S3 API. func TestPostPolicyKeyNormalization(t *testing.T) { tests := []struct { name string key string - expectedPrefix string // Expected path prefix after bucket + expectedObject string // Expected normalized object key }{ { name: "key without leading slash", key: "test_image.png", - expectedPrefix: "/test_image.png", + expectedObject: "test_image.png", }, { name: "key with leading slash", key: "/test_image.png", - expectedPrefix: "/test_image.png", + expectedObject: "test_image.png", }, { name: "key with path without leading slash", key: "folder/subfolder/test_image.png", - expectedPrefix: "/folder/subfolder/test_image.png", + expectedObject: "folder/subfolder/test_image.png", }, { name: "key with path with leading slash", key: "/folder/subfolder/test_image.png", - expectedPrefix: "/folder/subfolder/test_image.png", + expectedObject: "folder/subfolder/test_image.png", }, { name: "simple filename", key: "file.txt", - expectedPrefix: "/file.txt", + expectedObject: "file.txt", }, { name: "key with duplicate slashes", key: "folder//subfolder///file.txt", - expectedPrefix: "/folder/subfolder/file.txt", + expectedObject: "folder/subfolder/file.txt", }, { name: "key with leading duplicate slashes", key: "//folder/file.txt", - expectedPrefix: "/folder/file.txt", + expectedObject: "folder/file.txt", }, { name: "key with trailing slash", key: "folder/", - expectedPrefix: "/folder/", + expectedObject: "folder/", }, } @@ -71,15 +70,15 @@ func TestPostPolicyKeyNormalization(t *testing.T) { // Use the actual NormalizeObjectKey function object := s3_constants.NormalizeObjectKey(tt.key) - // Verify the normalized object has the expected prefix - assert.Equal(t, tt.expectedPrefix, object, + // Verify the normalized object matches expected + assert.Equal(t, tt.expectedObject, object, "Key should be normalized correctly") // Verify path construction would be correct bucket := "my-bucket" bucketsPath := "/buckets" - expectedPath := bucketsPath + "/" + bucket + tt.expectedPrefix - actualPath := bucketsPath + "/" + bucket + object + expectedPath := bucketsPath + "/" + bucket + "/" + tt.expectedObject + actualPath := bucketsPath + "/" + bucket + "/" + object assert.Equal(t, expectedPath, actualPath, "File path should be correctly constructed with slash between bucket and key") @@ -98,16 +97,19 @@ func TestNormalizeObjectKey(t *testing.T) { input string expected string }{ - {"empty string", "", "/"}, - {"simple file", "file.txt", "/file.txt"}, - {"with leading slash", "/file.txt", "/file.txt"}, - {"path without slash", "a/b/c.txt", "/a/b/c.txt"}, - {"path with slash", "/a/b/c.txt", "/a/b/c.txt"}, - {"duplicate slashes", "a//b///c.txt", "/a/b/c.txt"}, - {"leading duplicates", "///a/b.txt", "/a/b.txt"}, - {"all duplicates", "//a//b//", "/a/b/"}, - {"just slashes", "///", "/"}, - {"trailing slash", "folder/", "/folder/"}, + {"empty string", "", ""}, + {"simple file", "file.txt", "file.txt"}, + {"with leading slash", "/file.txt", "file.txt"}, + {"path without slash", "a/b/c.txt", "a/b/c.txt"}, + {"path with slash", "/a/b/c.txt", "a/b/c.txt"}, + {"duplicate slashes", "a//b///c.txt", "a/b/c.txt"}, + {"leading duplicates", "///a/b.txt", "a/b.txt"}, + {"all duplicates", "//a//b//", "a/b/"}, + {"just slashes", "///", ""}, + {"trailing slash", "folder/", "folder/"}, + {"backslash to forward slash", "folder\\file.txt", "folder/file.txt"}, + {"windows path", "folder\\subfolder\\file.txt", "folder/subfolder/file.txt"}, + {"mixed slashes", "a/b\\c/d", "a/b/c/d"}, } for _, tt := range tests { @@ -130,25 +132,25 @@ func TestPostPolicyFilenameSubstitution(t *testing.T) { name: "filename at end", keyTemplate: "uploads/${filename}", uploadedFilename: "photo.jpg", - expectedKey: "/uploads/photo.jpg", + expectedKey: "uploads/photo.jpg", }, { name: "filename in middle", keyTemplate: "user/files/${filename}/original", uploadedFilename: "document.pdf", - expectedKey: "/user/files/document.pdf/original", + expectedKey: "user/files/document.pdf/original", }, { name: "no substitution needed", keyTemplate: "static/file.txt", uploadedFilename: "ignored.txt", - expectedKey: "/static/file.txt", + expectedKey: "static/file.txt", }, { name: "filename only", keyTemplate: "${filename}", uploadedFilename: "myfile.png", - expectedKey: "/myfile.png", + expectedKey: "myfile.png", }, } @@ -292,7 +294,7 @@ func TestPostPolicyPathConstruction(t *testing.T) { object := s3_constants.NormalizeObjectKey(tt.formKey) // Construct path as done in PostPolicyBucketHandler - filePath := s3a.option.BucketsPath + "/" + tt.bucket + object + filePath := s3a.option.BucketsPath + "/" + tt.bucket + "/" + object assert.Equal(t, tt.expectedPath, filePath, "File path should be correctly constructed") @@ -374,7 +376,7 @@ func TestPostPolicyBucketHandlerKeyExtraction(t *testing.T) { object := s3_constants.NormalizeObjectKey(formValues.Get("Key")) // Construct path - filePath := s3a.option.BucketsPath + "/" + tt.bucket + object + filePath := s3a.option.BucketsPath + "/" + tt.bucket + "/" + object assert.Contains(t, filePath, tt.wantPathHas, "Path should contain properly separated bucket and key") diff --git a/weed/s3api/s3api_object_handlers_put.go b/weed/s3api/s3api_object_handlers_put.go index 6310e592e..e9e523138 100644 --- a/weed/s3api/s3api_object_handlers_put.go +++ b/weed/s3api/s3api_object_handlers_put.go @@ -113,8 +113,24 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request) objectContentType := r.Header.Get("Content-Type") if strings.HasSuffix(object, "/") && r.ContentLength <= 1024 { + // Split the object into directory path and name + objectWithoutSlash := strings.TrimSuffix(object, "/") + dirName := path.Dir(objectWithoutSlash) + entryName := path.Base(objectWithoutSlash) + + if dirName == "." { + dirName = "" + } + dirName = strings.TrimPrefix(dirName, "/") + + // Construct full directory path + fullDirPath := s3a.option.BucketsPath + "/" + bucket + if dirName != "" { + fullDirPath = fullDirPath + "/" + dirName + } + if err := s3a.mkdir( - s3a.option.BucketsPath, bucket+strings.TrimSuffix(object, "/"), + fullDirPath, entryName, func(entry *filer_pb.Entry) { if objectContentType == "" { objectContentType = s3_constants.FolderMimeType @@ -883,13 +899,13 @@ func (s3a *S3ApiServer) updateIsLatestFlagsForSuspendedVersioning(bucket, object versionsObjectPath := object + s3_constants.VersionsFolder versionsDir := bucketDir + "/" + versionsObjectPath - glog.V(2).Infof("updateIsLatestFlagsForSuspendedVersioning: updating flags for %s%s", bucket, object) + glog.V(2).Infof("updateIsLatestFlagsForSuspendedVersioning: updating flags for %s/%s", bucket, object) // Check if .versions directory exists _, err := s3a.getEntry(bucketDir, versionsObjectPath) if err != nil { // No .versions directory exists, nothing to update - glog.V(2).Infof("updateIsLatestFlagsForSuspendedVersioning: no .versions directory for %s%s", bucket, object) + glog.V(2).Infof("updateIsLatestFlagsForSuspendedVersioning: no .versions directory for %s/%s", bucket, object) return nil } @@ -939,7 +955,7 @@ func (s3a *S3ApiServer) updateIsLatestFlagsForSuspendedVersioning(bucket, object return fmt.Errorf("failed to update .versions directory metadata: %v", err) } - glog.V(2).Infof("updateIsLatestFlagsForSuspendedVersioning: cleared latest version metadata for %s%s", bucket, object) + glog.V(2).Infof("updateIsLatestFlagsForSuspendedVersioning: cleared latest version metadata for %s/%s", bucket, object) } return nil diff --git a/weed/s3api/s3api_object_handlers_tagging.go b/weed/s3api/s3api_object_handlers_tagging.go index 7b6b947da..647545254 100644 --- a/weed/s3api/s3api_object_handlers_tagging.go +++ b/weed/s3api/s3api_object_handlers_tagging.go @@ -43,16 +43,16 @@ func (s3a *S3ApiServer) GetObjectTaggingHandler(w http.ResponseWriter, r *http.R // Handle versioned object tagging retrieval if versionId != "" { // Request for specific version - glog.V(2).Infof("GetObjectTaggingHandler: requesting tags for specific version %s of %s%s", versionId, bucket, object) + glog.V(2).Infof("GetObjectTaggingHandler: requesting tags for specific version %s of %s/%s", versionId, bucket, object) entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) } else { // Request for latest version - glog.V(2).Infof("GetObjectTaggingHandler: requesting tags for latest version of %s%s", bucket, object) + glog.V(2).Infof("GetObjectTaggingHandler: requesting tags for latest version of %s/%s", bucket, object) entry, err = s3a.getLatestObjectVersion(bucket, object) } if err != nil { - glog.Errorf("GetObjectTaggingHandler: Failed to get object version %s for %s%s: %v", versionId, bucket, object, err) + glog.Errorf("GetObjectTaggingHandler: Failed to get object version %s for %s/%s: %v", versionId, bucket, object, err) s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) return } @@ -66,7 +66,7 @@ func (s3a *S3ApiServer) GetObjectTaggingHandler(w http.ResponseWriter, r *http.R } } else { // Handle regular (non-versioned) object tagging retrieval - target := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object)) + target := util.FullPath(fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, bucket, object)) dir, name := target.DirAndName() tags, err := s3a.getTags(dir, name) @@ -147,16 +147,16 @@ func (s3a *S3ApiServer) PutObjectTaggingHandler(w http.ResponseWriter, r *http.R // Handle versioned object tagging modification if versionId != "" { // Request for specific version - glog.V(2).Infof("PutObjectTaggingHandler: modifying tags for specific version %s of %s%s", versionId, bucket, object) + glog.V(2).Infof("PutObjectTaggingHandler: modifying tags for specific version %s of %s/%s", versionId, bucket, object) entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) } else { // Request for latest version - glog.V(2).Infof("PutObjectTaggingHandler: modifying tags for latest version of %s%s", bucket, object) + glog.V(2).Infof("PutObjectTaggingHandler: modifying tags for latest version of %s/%s", bucket, object) entry, err = s3a.getLatestObjectVersion(bucket, object) } if err != nil { - glog.Errorf("PutObjectTaggingHandler: Failed to get object version %s for %s%s: %v", versionId, bucket, object, err) + glog.Errorf("PutObjectTaggingHandler: Failed to get object version %s for %s/%s: %v", versionId, bucket, object, err) s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) return } @@ -170,7 +170,7 @@ func (s3a *S3ApiServer) PutObjectTaggingHandler(w http.ResponseWriter, r *http.R } } else { // Handle regular (non-versioned) object tagging modification - target := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object)) + target := util.FullPath(fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, bucket, object)) dir, name := target.DirAndName() if err = s3a.setTags(dir, name, tags); err != nil { @@ -198,7 +198,7 @@ func (s3a *S3ApiServer) PutObjectTaggingHandler(w http.ResponseWriter, r *http.R updateDirectory = s3a.option.BucketsPath + "/" + bucket } else { // Versioned object - stored in .versions directory - updateDirectory = s3a.option.BucketsPath + "/" + bucket + object + s3_constants.VersionsFolder + updateDirectory = s3a.option.BucketsPath + "/" + bucket + "/" + object + s3_constants.VersionsFolder } } else { // Latest version in versioned bucket - could be null version or versioned object @@ -215,7 +215,7 @@ func (s3a *S3ApiServer) PutObjectTaggingHandler(w http.ResponseWriter, r *http.R updateDirectory = s3a.option.BucketsPath + "/" + bucket } else { // Versioned object - stored in .versions directory - updateDirectory = s3a.option.BucketsPath + "/" + bucket + object + s3_constants.VersionsFolder + updateDirectory = s3a.option.BucketsPath + "/" + bucket + "/" + object + s3_constants.VersionsFolder } } @@ -262,7 +262,7 @@ func (s3a *S3ApiServer) PutObjectTaggingHandler(w http.ResponseWriter, r *http.R func (s3a *S3ApiServer) DeleteObjectTaggingHandler(w http.ResponseWriter, r *http.Request) { bucket, object := s3_constants.GetBucketAndObject(r) - glog.V(3).Infof("DeleteObjectTaggingHandler %s %s", bucket, object) + glog.V(3).Infof("DeleteObjectTaggingHandler %s/%s", bucket, object) // Check for specific version ID in query parameters versionId := r.URL.Query().Get("versionId") @@ -285,16 +285,16 @@ func (s3a *S3ApiServer) DeleteObjectTaggingHandler(w http.ResponseWriter, r *htt // Handle versioned object tagging deletion if versionId != "" { // Request for specific version - glog.V(2).Infof("DeleteObjectTaggingHandler: deleting tags for specific version %s of %s%s", versionId, bucket, object) + glog.V(2).Infof("DeleteObjectTaggingHandler: deleting tags for specific version %s of %s/%s", versionId, bucket, object) entry, err = s3a.getSpecificObjectVersion(bucket, object, versionId) } else { // Request for latest version - glog.V(2).Infof("DeleteObjectTaggingHandler: deleting tags for latest version of %s%s", bucket, object) + glog.V(2).Infof("DeleteObjectTaggingHandler: deleting tags for latest version of %s/%s", bucket, object) entry, err = s3a.getLatestObjectVersion(bucket, object) } if err != nil { - glog.Errorf("DeleteObjectTaggingHandler: Failed to get object version %s for %s%s: %v", versionId, bucket, object, err) + glog.Errorf("DeleteObjectTaggingHandler: Failed to get object version %s for %s/%s: %v", versionId, bucket, object, err) s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchKey) return } @@ -308,7 +308,7 @@ func (s3a *S3ApiServer) DeleteObjectTaggingHandler(w http.ResponseWriter, r *htt } } else { // Handle regular (non-versioned) object tagging deletion - target := util.FullPath(fmt.Sprintf("%s/%s%s", s3a.option.BucketsPath, bucket, object)) + target := util.FullPath(fmt.Sprintf("%s/%s/%s", s3a.option.BucketsPath, bucket, object)) dir, name := target.DirAndName() err := s3a.rmTags(dir, name) @@ -337,7 +337,7 @@ func (s3a *S3ApiServer) DeleteObjectTaggingHandler(w http.ResponseWriter, r *htt updateDirectory = s3a.option.BucketsPath + "/" + bucket } else { // Versioned object - stored in .versions directory - updateDirectory = s3a.option.BucketsPath + "/" + bucket + object + s3_constants.VersionsFolder + updateDirectory = s3a.option.BucketsPath + "/" + bucket + "/" + object + s3_constants.VersionsFolder } } else { // Latest version in versioned bucket - could be null version or versioned object @@ -354,7 +354,7 @@ func (s3a *S3ApiServer) DeleteObjectTaggingHandler(w http.ResponseWriter, r *htt updateDirectory = s3a.option.BucketsPath + "/" + bucket } else { // Versioned object - stored in .versions directory - updateDirectory = s3a.option.BucketsPath + "/" + bucket + object + s3_constants.VersionsFolder + updateDirectory = s3a.option.BucketsPath + "/" + bucket + "/" + object + s3_constants.VersionsFolder } } diff --git a/weed/s3api/s3api_object_versioning.go b/weed/s3api/s3api_object_versioning.go index a14af6a10..3fadc46cb 100644 --- a/weed/s3api/s3api_object_versioning.go +++ b/weed/s3api/s3api_object_versioning.go @@ -348,20 +348,19 @@ func (vc *versionCollector) isFull() bool { // matchesPrefixFilter checks if an entry path matches the prefix filter func (vc *versionCollector) matchesPrefixFilter(entryPath string, isDirectory bool) bool { - normalizedPrefix := strings.TrimPrefix(vc.prefix, "/") - if normalizedPrefix == "" { + if vc.prefix == "" { return true } // Entry matches if its path starts with the prefix - isMatch := strings.HasPrefix(entryPath, normalizedPrefix) + isMatch := strings.HasPrefix(entryPath, vc.prefix) if !isMatch && isDirectory { // Directory might match with trailing slash - isMatch = strings.HasPrefix(entryPath+"/", normalizedPrefix) + isMatch = strings.HasPrefix(entryPath+"/", vc.prefix) } // For directories, also check if we need to descend (prefix is deeper) - canDescend := isDirectory && strings.HasPrefix(normalizedPrefix, entryPath) + canDescend := isDirectory && strings.HasPrefix(vc.prefix, entryPath) return isMatch || canDescend } @@ -423,7 +422,7 @@ func (vc *versionCollector) addVersion(version *ObjectVersion, objectKey string) // processVersionsDirectory handles a .versions directory entry func (vc *versionCollector) processVersionsDirectory(entryPath string) error { objectKey := strings.TrimSuffix(entryPath, s3_constants.VersionsFolder) - normalizedObjectKey := removeDuplicateSlashes(objectKey) + normalizedObjectKey := s3_constants.NormalizeObjectKey(objectKey) // Mark as processed vc.processedObjects[objectKey] = true @@ -493,7 +492,7 @@ func (vc *versionCollector) processExplicitDirectory(entryPath string, entry *fi // processRegularFile handles a regular file entry (pre-versioning or suspended-versioning object) func (vc *versionCollector) processRegularFile(currentPath, entryPath string, entry *filer_pb.Entry) { objectKey := entryPath - normalizedObjectKey := removeDuplicateSlashes(objectKey) + normalizedObjectKey := s3_constants.NormalizeObjectKey(objectKey) // Skip files before keyMarker if vc.shouldSkipObjectForMarker(normalizedObjectKey) { @@ -780,11 +779,11 @@ func (s3a *S3ApiServer) calculateETagFromChunks(chunks []*filer_pb.FileChunk) st // getSpecificObjectVersion retrieves a specific version of an object func (s3a *S3ApiServer) getSpecificObjectVersion(bucket, object, versionId string) (*filer_pb.Entry, error) { // Normalize object path to ensure consistency with toFilerPath behavior - normalizedObject := removeDuplicateSlashes(object) + normalizedObject := s3_constants.NormalizeObjectKey(object) if versionId == "" { // Get current version - return s3a.getEntry(path.Join(s3a.option.BucketsPath, bucket), strings.TrimPrefix(normalizedObject, "/")) + return s3a.getEntry(path.Join(s3a.option.BucketsPath, bucket), normalizedObject) } if versionId == "null" { @@ -812,7 +811,7 @@ func (s3a *S3ApiServer) getSpecificObjectVersion(bucket, object, versionId strin // deleteSpecificObjectVersion deletes a specific version of an object func (s3a *S3ApiServer) deleteSpecificObjectVersion(bucket, object, versionId string) error { // Normalize object path to ensure consistency with toFilerPath behavior - normalizedObject := removeDuplicateSlashes(object) + normalizedObject := s3_constants.NormalizeObjectKey(object) if versionId == "" { return fmt.Errorf("version ID is required for version-specific deletion") @@ -821,25 +820,24 @@ func (s3a *S3ApiServer) deleteSpecificObjectVersion(bucket, object, versionId st if versionId == "null" { // Delete "null" version (pre-versioning object stored as regular file) bucketDir := s3a.option.BucketsPath + "/" + bucket - cleanObject := strings.TrimPrefix(normalizedObject, "/") // Check if the object exists - _, err := s3a.getEntry(bucketDir, cleanObject) + _, err := s3a.getEntry(bucketDir, normalizedObject) if err != nil { // Object doesn't exist - this is OK for delete operations (idempotent) - glog.V(2).Infof("deleteSpecificObjectVersion: null version object %s already deleted or doesn't exist", cleanObject) + glog.V(2).Infof("deleteSpecificObjectVersion: null version object %s already deleted or doesn't exist", normalizedObject) return nil } // Delete the regular file - deleteErr := s3a.rm(bucketDir, cleanObject, true, false) + deleteErr := s3a.rm(bucketDir, normalizedObject, true, false) if deleteErr != nil { // Check if file was already deleted by another process - if _, checkErr := s3a.getEntry(bucketDir, cleanObject); checkErr != nil { + if _, checkErr := s3a.getEntry(bucketDir, normalizedObject); checkErr != nil { // File doesn't exist anymore, deletion was successful return nil } - return fmt.Errorf("failed to delete null version %s: %v", cleanObject, deleteErr) + return fmt.Errorf("failed to delete null version %s: %v", normalizedObject, deleteErr) } return nil } @@ -864,7 +862,7 @@ func (s3a *S3ApiServer) deleteSpecificObjectVersion(bucket, object, versionId st // Check if file was already deleted by another process (race condition handling) if _, checkErr := s3a.getEntry(versionsDir, versionFile); checkErr != nil { // File doesn't exist anymore, deletion was successful (another thread deleted it) - glog.V(2).Infof("deleteSpecificObjectVersion: version %s for %s%s already deleted by another process", versionId, bucket, object) + glog.V(2).Infof("deleteSpecificObjectVersion: version %s for %s/%s already deleted by another process", versionId, bucket, object) return nil } // File still exists but deletion failed for another reason @@ -873,7 +871,7 @@ func (s3a *S3ApiServer) deleteSpecificObjectVersion(bucket, object, versionId st // If we deleted the latest version, update the .versions directory metadata to point to the new latest if isLatestVersion { - err := s3a.updateLatestVersionAfterDeletion(bucket, object) + err := s3a.updateLatestVersionAfterDeletion(bucket, normalizedObject) if err != nil { glog.Warningf("deleteSpecificObjectVersion: failed to update latest version after deletion: %v", err) // Don't return error since the deletion was successful @@ -886,8 +884,7 @@ func (s3a *S3ApiServer) deleteSpecificObjectVersion(bucket, object, versionId st // updateLatestVersionAfterDeletion finds the new latest version after deleting the current latest func (s3a *S3ApiServer) updateLatestVersionAfterDeletion(bucket, object string) error { bucketDir := s3a.option.BucketsPath + "/" + bucket - cleanObject := strings.TrimPrefix(object, "/") - versionsObjectPath := cleanObject + s3_constants.VersionsFolder + versionsObjectPath := object + s3_constants.VersionsFolder versionsDir := bucketDir + "/" + versionsObjectPath glog.V(1).Infof("updateLatestVersionAfterDeletion: updating latest version for %s/%s, listing %s", bucket, object, versionsDir) @@ -989,9 +986,7 @@ func (s3a *S3ApiServer) ListObjectVersionsHandler(w http.ResponseWriter, r *http query := r.URL.Query() originalPrefix := query.Get("prefix") // Keep original prefix for response prefix := originalPrefix // Use for internal processing - if prefix != "" && !strings.HasPrefix(prefix, "/") { - prefix = "/" + prefix - } + // Note: prefix is used for filtering relative to bucket root, so no leading slash needed keyMarker := query.Get("key-marker") versionIdMarker := query.Get("version-id-marker") @@ -1022,7 +1017,7 @@ func (s3a *S3ApiServer) ListObjectVersionsHandler(w http.ResponseWriter, r *http // getLatestObjectVersion finds the latest version of an object by reading .versions directory metadata func (s3a *S3ApiServer) getLatestObjectVersion(bucket, object string) (*filer_pb.Entry, error) { // Normalize object path to ensure consistency with toFilerPath behavior - normalizedObject := removeDuplicateSlashes(object) + normalizedObject := s3_constants.NormalizeObjectKey(object) bucketDir := s3a.option.BucketsPath + "/" + bucket versionsObjectPath := normalizedObject + s3_constants.VersionsFolder @@ -1050,12 +1045,12 @@ func (s3a *S3ApiServer) getLatestObjectVersion(bucket, object string) (*filer_pb // .versions directory doesn't exist - this can happen for objects that existed // before versioning was enabled on the bucket. Fall back to checking for a // regular (non-versioned) object file. - glog.V(1).Infof("getLatestObjectVersion: no .versions directory for %s%s after %d attempts (error: %v), checking for pre-versioning object", bucket, normalizedObject, maxRetries, err) + glog.V(1).Infof("getLatestObjectVersion: no .versions directory for %s/%s after %d attempts (error: %v), checking for pre-versioning object", bucket, normalizedObject, maxRetries, err) regularEntry, regularErr := s3a.getEntry(bucketDir, normalizedObject) if regularErr != nil { - glog.V(1).Infof("getLatestObjectVersion: no pre-versioning object found for %s%s (error: %v)", bucket, normalizedObject, regularErr) - return nil, fmt.Errorf("failed to get %s%s .versions directory and no regular object found: %w", bucket, normalizedObject, err) + glog.V(1).Infof("getLatestObjectVersion: no pre-versioning object found for %s/%s (error: %v)", bucket, normalizedObject, regularErr) + return nil, fmt.Errorf("failed to get %s/%s .versions directory and no regular object found: %w", bucket, normalizedObject, err) } glog.V(1).Infof("getLatestObjectVersion: found pre-versioning object for %s/%s", bucket, normalizedObject) @@ -1081,14 +1076,14 @@ func (s3a *S3ApiServer) getLatestObjectVersion(bucket, object string) (*filer_pb // If still no metadata after retries, fall back to pre-versioning object if versionsEntry.Extended == nil { - glog.V(2).Infof("getLatestObjectVersion: no Extended metadata in .versions directory for %s%s after retries, checking for pre-versioning object", bucket, object) + glog.V(2).Infof("getLatestObjectVersion: no Extended metadata in .versions directory for %s/%s after retries, checking for pre-versioning object", bucket, object) regularEntry, regularErr := s3a.getEntry(bucketDir, normalizedObject) if regularErr != nil { - return nil, fmt.Errorf("no version metadata in .versions directory and no regular object found for %s%s", bucket, normalizedObject) + return nil, fmt.Errorf("no version metadata in .versions directory and no regular object found for %s/%s", bucket, normalizedObject) } - glog.V(2).Infof("getLatestObjectVersion: found pre-versioning object for %s%s (no Extended metadata case)", bucket, object) + glog.V(2).Infof("getLatestObjectVersion: found pre-versioning object for %s/%s (no Extended metadata case)", bucket, object) return regularEntry, nil } } @@ -1103,10 +1098,10 @@ func (s3a *S3ApiServer) getLatestObjectVersion(bucket, object string) (*filer_pb regularEntry, regularErr := s3a.getEntry(bucketDir, normalizedObject) if regularErr != nil { - return nil, fmt.Errorf("no version metadata in .versions directory and no regular object found for %s%s", bucket, normalizedObject) + return nil, fmt.Errorf("no version metadata in .versions directory and no regular object found for %s/%s", bucket, normalizedObject) } - glog.V(2).Infof("getLatestObjectVersion: found pre-versioning object for %s%s after version deletion", bucket, object) + glog.V(2).Infof("getLatestObjectVersion: found pre-versioning object for %s/%s after version deletion", bucket, object) return regularEntry, nil } @@ -1139,7 +1134,7 @@ func (s3a *S3ApiServer) getLatestVersionEntryFromDirectoryEntry(bucket, object s return nil, fmt.Errorf("nil .versions directory entry") } - normalizedObject := removeDuplicateSlashes(object) + normalizedObject := s3_constants.NormalizeObjectKey(object) // Check if the directory entry has latest version metadata if versionsDirEntry.Extended == nil { diff --git a/weed/s3api/s3api_version_id.go b/weed/s3api/s3api_version_id.go index db347d927..5f74d36a8 100644 --- a/weed/s3api/s3api_version_id.go +++ b/weed/s3api/s3api_version_id.go @@ -6,7 +6,6 @@ import ( "fmt" "math" "strconv" - "strings" "time" "github.com/seaweedfs/seaweedfs/weed/glog" @@ -155,9 +154,8 @@ func (s3a *S3ApiServer) getVersionFileName(versionId string) string { // For new .versions directories, returns true (use new format). // For existing directories, infers format from the latest version ID. func (s3a *S3ApiServer) getVersionIdFormat(bucket, object string) bool { - cleanObject := strings.TrimPrefix(object, "/") bucketDir := s3a.option.BucketsPath + "/" + bucket - versionsPath := cleanObject + s3_constants.VersionsFolder + versionsPath := object + s3_constants.VersionsFolder // Try to get the .versions directory entry versionsEntry, err := s3a.getEntry(bucketDir, versionsPath) diff --git a/weed/storage/backend/s3_backend/s3_download.go b/weed/storage/backend/s3_backend/s3_download.go index b0d30fbdb..af6f7b4f3 100644 --- a/weed/storage/backend/s3_backend/s3_download.go +++ b/weed/storage/backend/s3_backend/s3_download.go @@ -47,7 +47,7 @@ func downloadFromS3(sess s3iface.S3API, destFileName string, sourceBucket string Key: aws.String(sourceKey), }) if err != nil { - return fileSize, fmt.Errorf("failed to download /buckets/%s%s to %s: %v", sourceBucket, sourceKey, destFileName, err) + return fileSize, fmt.Errorf("failed to download /buckets/%s/%s to %s: %v", sourceBucket, sourceKey, destFileName, err) } glog.V(1).Infof("downloaded file %s\n", destFileName) From 7064ad420df549b488183fb0e9e0f281c6dd0a2b Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Thu, 25 Dec 2025 11:00:54 -0800 Subject: [PATCH 28/66] Refactor S3 integration tests to use weed mini (#7877) * Refactor S3 integration tests to use weed mini * Fix weed mini flags for sse and parquet tests * Fix IAM test startup: remove -iam.config flag from weed mini * Enhance logging in IAM Makefile to debug startup failure * Simplify weed mini flags and checks in S3 tests (IAM, Parquet, SSE, Copying) * Simplify weed mini flags and checks in all S3 tests * Fix IAM tests: use -s3.iam.config for weed mini * Replace timeout command with portable loop in IAM Makefile * Standardize portable loop-based readiness checks in all S3 Makefiles * Define SERVER_DIR in retention Makefile * Fix versioning and retention Makefiles: remove unsupported weed mini flags * fix filer_group test * fix cors * emojis * fix sse * fix retention * fixes * fix * fixes * fix parquet * fixes * fix * clean up * avoid duplicated debug server * Update .gitignore * simplify * clean up * add credentials * bind * delay * Update Makefile * Update Makefile * check ready * delay * update remote credentials * Update Makefile * clean up * kill * Update Makefile * update credentials --- .gitignore | 2 + test/fuse_integration/Makefile | 18 +- test/s3/compatibility/run.sh | 5 +- test/s3/copying/Makefile | 48 ++--- test/s3/cors/Makefile | 46 ++--- test/s3/filer_group/Makefile | 59 +++--- test/s3/filer_group/test_config.json | 1 - test/s3/iam/Makefile | 99 ++++------ .../parquet/CROSS_FILESYSTEM_COMPATIBILITY.md | 172 ------------------ test/s3/parquet/FINAL_ROOT_CAUSE_ANALYSIS.md | 58 ------ test/s3/parquet/MINIO_DIRECTORY_HANDLING.md | 70 ------- test/s3/parquet/Makefile | 164 ++++------------- test/s3/parquet/TEST_COVERAGE.md | 46 ----- .../s3/parquet/test_implicit_directory_fix.py | 1 + test/s3/remote_cache/Makefile | 36 +--- test/s3/remote_cache/remote_cache_test.go | 15 +- test/s3/retention/Makefile | 31 ++-- test/s3/sse/Makefile | 115 +++--------- test/s3/tagging/Makefile | 36 +--- test/s3/versioning/Makefile | 69 +++---- weed/command/mini.go | 4 +- 21 files changed, 241 insertions(+), 854 deletions(-) delete mode 100644 test/s3/parquet/CROSS_FILESYSTEM_COMPATIBILITY.md delete mode 100644 test/s3/parquet/FINAL_ROOT_CAUSE_ANALYSIS.md delete mode 100644 test/s3/parquet/MINIO_DIRECTORY_HANDLING.md delete mode 100644 test/s3/parquet/TEST_COVERAGE.md diff --git a/.gitignore b/.gitignore index 91fa9391d..b895a8f08 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,5 @@ coverage.out test/s3/remote_cache/remote-server.pid test/s3/remote_cache/primary-server.pid /test/erasure_coding/filerldb2 +/test/s3/cors/test-mini-data +/test/s3/filer_group/test-volume-data diff --git a/test/fuse_integration/Makefile b/test/fuse_integration/Makefile index fe2ad690b..3c1e68d59 100644 --- a/test/fuse_integration/Makefile +++ b/test/fuse_integration/Makefile @@ -12,20 +12,20 @@ COVERAGE_FILE := coverage.out # Check if weed binary exists check-binary: @if [ ! -f "$(WEED_BINARY)" ]; then \ - echo "❌ SeaweedFS binary not found at $(WEED_BINARY)"; \ + echo "ERROR SeaweedFS binary not found at $(WEED_BINARY)"; \ echo " Please run 'make' in the root directory first"; \ exit 1; \ fi - @echo "✅ SeaweedFS binary found" + @echo "OK SeaweedFS binary found" # Check FUSE installation check-fuse: @if command -v fusermount >/dev/null 2>&1; then \ - echo "✅ FUSE is installed (Linux)"; \ + echo "OK FUSE is installed (Linux)"; \ elif command -v umount >/dev/null 2>&1 && [ "$$(uname)" = "Darwin" ]; then \ - echo "✅ FUSE is available (macOS)"; \ + echo "OK FUSE is available (macOS)"; \ else \ - echo "❌ FUSE not found. Please install:"; \ + echo "ERROR FUSE not found. Please install:"; \ echo " Ubuntu/Debian: sudo apt-get install fuse"; \ echo " CentOS/RHEL: sudo yum install fuse"; \ echo " macOS: brew install macfuse"; \ @@ -36,8 +36,8 @@ check-fuse: check-go: @go version | grep -q "go1\.[2-9][0-9]" || \ go version | grep -q "go1\.2[1-9]" || \ - (echo "❌ Go $(GO_VERSION)+ required. Current: $$(go version)" && exit 1) - @echo "✅ Go version check passed" + (echo "ERROR Go $(GO_VERSION)+ required. Current: $$(go version)" && exit 1) + @echo "OK Go version check passed" # Verify all prerequisites check-prereqs: check-go check-fuse @@ -45,9 +45,9 @@ check-prereqs: check-go check-fuse # Build the SeaweedFS binary (if needed) build: - @echo "🔨 Building SeaweedFS..." + @echo "Building SeaweedFS..." cd ../.. && make - @echo "✅ Build complete" + @echo "OK Build complete" # Initialize go module (if needed) init-module: diff --git a/test/s3/compatibility/run.sh b/test/s3/compatibility/run.sh index adfee1366..844435d69 100755 --- a/test/s3/compatibility/run.sh +++ b/test/s3/compatibility/run.sh @@ -22,10 +22,7 @@ docker stop $CONTAINER_NAME || echo "already stopped" ulimit -n 10000 # Start weed w/ filer + s3 in the background -$WEED_BIN server \ - -filer \ - -s3 \ - -volume.max 0 \ +$WEED_BIN mini \ -master.volumeSizeLimitMB 5 \ -dir "$(pwd)/tmp" \ 1>&2>weed.log & diff --git a/test/s3/copying/Makefile b/test/s3/copying/Makefile index 225798900..1a5c98c01 100644 --- a/test/s3/copying/Makefile +++ b/test/s3/copying/Makefile @@ -63,30 +63,24 @@ start-seaweedfs: check-binary @pkill -f "weed volume" || true @pkill -f "weed filer" || true @pkill -f "weed s3" || true + @pkill -f "weed mini" || true @sleep 2 # Create necessary directories - @mkdir -p /tmp/seaweedfs-test-copying-master - @mkdir -p /tmp/seaweedfs-test-copying-volume + @mkdir -p /tmp/seaweedfs-test-copying - # Start master server with volume size limit - @nohup $(SEAWEEDFS_BINARY) master -port=$(MASTER_PORT) -mdir=/tmp/seaweedfs-test-copying-master -volumeSizeLimitMB=$(VOLUME_MAX_SIZE_MB) -ip=127.0.0.1 -peers=none > /tmp/seaweedfs-master.log 2>&1 & - @sleep 3 - - # Start volume server - @nohup $(SEAWEEDFS_BINARY) volume -port=$(VOLUME_PORT) -master=127.0.0.1:$(MASTER_PORT) -dir=/tmp/seaweedfs-test-copying-volume -ip=127.0.0.1 > /tmp/seaweedfs-volume.log 2>&1 & - @sleep 3 - - # Start filer server (using standard SeaweedFS gRPC port convention: HTTP port + 10000) - @nohup $(SEAWEEDFS_BINARY) filer -port=$(FILER_PORT) -port.grpc=$$(( $(FILER_PORT) + 10000 )) -master=127.0.0.1:$(MASTER_PORT) -ip=127.0.0.1 > /tmp/seaweedfs-filer.log 2>&1 & - @sleep 3 - - # Create S3 configuration - @echo '{"identities":[{"name":"$(ACCESS_KEY)","credentials":[{"accessKey":"$(ACCESS_KEY)","secretKey":"$(SECRET_KEY)"}],"actions":["Admin","Read","Write"]}]}' > /tmp/seaweedfs-s3.json - - # Start S3 server - @nohup $(SEAWEEDFS_BINARY) s3 -port=$(S3_PORT) -filer=127.0.0.1:$(FILER_PORT) -config=/tmp/seaweedfs-s3.json -ip.bind=127.0.0.1 > /tmp/seaweedfs-s3.log 2>&1 & - @sleep 5 + # Start weed mini + @echo "Starting weed mini with dir=/tmp/seaweedfs-test-copying" + @export AWS_ACCESS_KEY_ID=$(ACCESS_KEY) && \ + export AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) && \ + # Start weed mini with S3 configuration + @echo "Starting weed mini..." + @nohup $(SEAWEEDFS_BINARY) mini \ + -dir=/tmp/seaweedfs-test-copying \ + -s3.port=$(S3_PORT) \ + -s3.config=/tmp/seaweedfs-s3.json \ + -ip=127.0.0.1 \ + > /tmp/seaweedfs-mini.log 2>&1 & echo $$! > /tmp/weed-mini.pid 5 # Wait for S3 service to be ready @echo "$(YELLOW)Waiting for S3 service to be ready...$(NC)" @@ -98,16 +92,12 @@ start-seaweedfs: check-binary echo "Waiting for S3 service... ($$i/30)"; \ sleep 1; \ done - - # Additional wait for filer gRPC to be ready - @echo "$(YELLOW)Waiting for filer gRPC to be ready...$(NC)" - @sleep 2 - @echo "$(GREEN)SeaweedFS server started successfully$(NC)" - @echo "Master: http://localhost:$(MASTER_PORT)" - @echo "Volume: http://localhost:$(VOLUME_PORT)" - @echo "Filer: http://localhost:$(FILER_PORT)" + # Additional wait for filer gRPC to be ready + @echo "$(YELLOW)Waiting for filer gRPC to be ready...$(NC)" + @sleep 2 + @echo "$(GREEN)SeaweedFS server started successfully$(NC)" + @echo "Mini Log: /tmp/seaweedfs-mini.log" @echo "S3: http://localhost:$(S3_PORT)" - @echo "Volume Max Size: $(VOLUME_MAX_SIZE_MB)MB" stop-seaweedfs: @echo "$(YELLOW)Stopping SeaweedFS server...$(NC)" diff --git a/test/s3/cors/Makefile b/test/s3/cors/Makefile index 3164d1341..4a1db781e 100644 --- a/test/s3/cors/Makefile +++ b/test/s3/cors/Makefile @@ -11,6 +11,7 @@ VOLUME_PORT := 8080 FILER_PORT := 8888 TEST_TIMEOUT := 10m TEST_PATTERN := TestCORS +SERVER_DIR := test-mini-data # Default target help: @@ -41,21 +42,21 @@ build-weed: @echo "Building SeaweedFS binary..." @cd ../../../weed && go build -o weed_binary . @chmod +x $(WEED_BINARY) - @echo "✅ SeaweedFS binary built at $(WEED_BINARY)" + @echo "OK SeaweedFS binary built at $(WEED_BINARY)" check-deps: build-weed @echo "Checking dependencies..." - @echo "🔍 DEBUG: Checking Go installation..." + @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)..." + @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" + @echo "OK All dependencies are available" # Start SeaweedFS server for testing start-server: check-deps @@ -77,23 +78,26 @@ start-server: check-deps @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.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 \ + @mkdir -p $(SERVER_DIR) + @echo "🔍 DEBUG: Launching SeaweedFS S3 server in background..." + @echo "🔍 DEBUG: Command: $(WEED_BINARY) mini -dir=$(SERVER_DIR) -s3.port=$(S3_PORT) -s3.config=$(S3_CONFIG)" + @$(WEED_BINARY) mini \ + -dir=$(SERVER_DIR) \ -s3.port=$(S3_PORT) \ - -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 + -s3.config=$(S3_CONFIG) \ + > weed-test.log 2>&1 & \ + echo $$! > weed-test.pid + + @echo "Waiting for S3 server to be ready..." + @for i in $$(seq 1 30); do \ + if echo | nc -z localhost $(S3_PORT); then \ + echo "S3 server is ready!"; \ + exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo "S3 server failed to start"; \ + exit 1 > 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 diff --git a/test/s3/filer_group/Makefile b/test/s3/filer_group/Makefile index df243d2b0..21d5c050a 100644 --- a/test/s3/filer_group/Makefile +++ b/test/s3/filer_group/Makefile @@ -16,6 +16,8 @@ TEST_PATTERN := Test # Filer group configuration FILER_GROUP := testgroup +SERVER_DIR := ./test-volume-data +S3_CONFIG := ../../../docker/compose/s3.json # Default target help: @@ -44,7 +46,7 @@ build-weed: @echo "Building SeaweedFS binary..." @cd ../../../weed && go build -o weed_binary . @chmod +x $(WEED_BINARY) - @echo "✅ SeaweedFS binary built at $(WEED_BINARY)" + @echo "OK SeaweedFS binary built at $(WEED_BINARY)" check-deps: build-weed @echo "Checking dependencies..." @@ -53,51 +55,40 @@ check-deps: build-weed @test -f $(WEED_BINARY) || (echo "SeaweedFS binary not found at $(WEED_BINARY)" && exit 1) @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" + @echo "OK All dependencies are available" # Start SeaweedFS server with filer group configured start-server: check-deps @echo "Starting SeaweedFS server with filer group: $(FILER_GROUP)..." @rm -f weed-server.pid - @mkdir -p ./test-volume-data + @mkdir -p $(SERVER_DIR) @if netstat -tlnp 2>/dev/null | grep $(S3_PORT) >/dev/null; then \ - echo "⚠️ Port $(S3_PORT) is already in use"; \ + echo "WARNING: Port $(S3_PORT) is already in use"; \ exit 1; \ fi @echo "Launching SeaweedFS server with filer group $(FILER_GROUP)..." - @$(WEED_BINARY) server \ + @export AWS_ACCESS_KEY_ID=some_access_key1 && \ + export AWS_SECRET_ACCESS_KEY=some_secret_key1 && \ + $(WEED_BINARY) mini \ -debug \ - -s3 \ + -dir=$(SERVER_DIR) \ -s3.port=$(S3_PORT) \ - -s3.allowDeleteBucketNotEmpty=true \ - -s3.config=../../../docker/compose/s3.json \ - -filer \ - -filer.maxMB=64 \ + -s3.config=$(S3_CONFIG) \ -filer.filerGroup=$(FILER_GROUP) \ - -master.volumeSizeLimitMB=50 \ - -volume.max=100 \ - -dir=./test-volume-data \ - -volume.preStopSeconds=1 \ - -metricsPort=9325 \ - > weed-test.log 2>&1 & echo $$! > weed-server.pid - @echo "Server PID: $$(cat weed-server.pid 2>/dev/null || echo 'PID file not found')" - @echo "Waiting for server to start (up to 90 seconds)..." - @for i in $$(seq 1 90); do \ - if curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1; then \ - echo "✅ SeaweedFS server started successfully on port $(S3_PORT) with filer group $(FILER_GROUP)"; \ + > weed-server.log 2>&1 & \ + echo $$! > weed-server.pid + + @echo "Waiting for S3 server to be ready..." + @for i in $$(seq 1 30); do \ + if echo | nc -z localhost $(S3_PORT); then \ + echo "S3 server is ready!"; \ exit 0; \ fi; \ - if [ $$i -eq 30 ]; then \ - echo "⚠️ Server taking longer than expected (30s), checking logs..."; \ - if [ -f weed-test.log ]; then \ - tail -20 weed-test.log; \ - fi; \ - fi; \ sleep 1; \ done; \ - echo "❌ Server failed to start within 90 seconds"; \ - if [ -f weed-test.log ]; then \ - cat weed-test.log; \ + echo "❌ Server failed to start within 30 seconds"; \ + if [ -f weed-server.log ]; then \ + cat weed-server.log; \ fi; \ exit 1 @@ -126,9 +117,9 @@ stop-server: # Show server logs logs: - @if test -f weed-test.log; then \ + @if test -f weed-server.log; then \ echo "=== SeaweedFS Server Logs ==="; \ - tail -f weed-test.log; \ + tail -f weed-server.log; \ else \ echo "No log file found. Server may not be running."; \ fi @@ -146,7 +137,7 @@ test-with-server: start-server @echo "Test pattern: $(TEST_PATTERN)" @echo "Test timeout: $(TEST_TIMEOUT)" @trap "$(MAKE) stop-server" EXIT; \ - $(MAKE) test || (echo "❌ Tests failed, showing server logs:" && echo "=== Last 50 lines of server logs ===" && tail -50 weed-test.log && echo "=== End of server logs ===" && exit 1) + $(MAKE) test || (echo "❌ Tests failed, showing server logs:" && echo "=== Last 50 lines of server logs ===" && tail -50 weed-server.log && echo "=== End of server logs ===" && exit 1) @$(MAKE) stop-server @echo "✅ Tests completed and server stopped" @@ -154,7 +145,7 @@ test-with-server: start-server clean: @echo "Cleaning up test artifacts..." @$(MAKE) stop-server - @rm -f weed-test*.log weed-server.pid + @rm -f weed-server.log weed-test*.log weed-server.pid @rm -rf test-volume-data/ @go clean -testcache @echo "✅ Cleanup completed" diff --git a/test/s3/filer_group/test_config.json b/test/s3/filer_group/test_config.json index 34a4e5d66..05e5c6912 100644 --- a/test/s3/filer_group/test_config.json +++ b/test/s3/filer_group/test_config.json @@ -1,6 +1,5 @@ { "s3_endpoint": "http://localhost:8333", - "master_address": "localhost:9333", "access_key": "some_access_key1", "secret_key": "some_secret_key1", "region": "us-east-1", diff --git a/test/s3/iam/Makefile b/test/s3/iam/Makefile index 7a3f8f950..5113b6b57 100644 --- a/test/s3/iam/Makefile +++ b/test/s3/iam/Makefile @@ -19,6 +19,7 @@ MASTER_PID_FILE = /tmp/weed-master.pid VOLUME_PID_FILE = /tmp/weed-volume.pid FILER_PID_FILE = /tmp/weed-filer.pid S3_PID_FILE = /tmp/weed-s3.pid +MINI_PID_FILE = /tmp/weed-mini.pid help: ## Show this help message @echo "SeaweedFS S3 IAM Integration Tests" @@ -49,80 +50,54 @@ test: clean setup start-services run-tests stop-services ## Run complete IAM int test-quick: run-tests ## Run tests assuming services are already running run-tests: ## Execute the Go tests - @echo "🧪 Running S3 IAM Integration Tests..." + @echo "Running S3 IAM Integration Tests..." go test -v -timeout $(TEST_TIMEOUT) ./... setup: ## Setup test environment - @echo "🔧 Setting up test environment..." + @echo "Setting up test environment..." @mkdir -p test-volume-data/filerldb2 @mkdir -p test-volume-data/m9333 start-services: ## Start SeaweedFS services for testing - @echo "🚀 Starting SeaweedFS services..." - @echo "Starting master server..." - @$(WEED_BINARY) master -port=$(MASTER_PORT) \ - -mdir=test-volume-data/m9333 \ - -peers=none > weed-master.log 2>&1 & \ - echo $$! > $(MASTER_PID_FILE) - - @echo "Waiting for master server to be ready..." - @timeout 60 bash -c 'until curl -s http://localhost:$(MASTER_PORT)/cluster/status > /dev/null 2>&1; do echo "Waiting for master server..."; sleep 2; done' || (echo "❌ Master failed to start, checking logs..." && tail -20 weed-master.log && exit 1) - @echo "✅ Master server is ready" - - @echo "Starting volume server..." - @$(WEED_BINARY) volume -port=$(VOLUME_PORT) \ - -ip=localhost \ - -dataCenter=dc1 -rack=rack1 \ + @echo "Starting SeaweedFS services using weed mini..." + @mkdir -p test-volume-data + @$(WEED_BINARY) mini \ -dir=test-volume-data \ - -max=100 \ - -master=localhost:$(MASTER_PORT) > weed-volume.log 2>&1 & \ - echo $$! > $(VOLUME_PID_FILE) + -s3.port=$(S3_PORT) \ + -s3.config=test_config.json \ + -s3.iam.config=$(CURDIR)/iam_config.json \ + > weed-mini.log 2>&1 & \ + echo $$! > $(MINI_PID_FILE) - @echo "Waiting for volume server to be ready..." - @timeout 60 bash -c 'until curl -s http://localhost:$(VOLUME_PORT)/status > /dev/null 2>&1; do echo "Waiting for volume server..."; sleep 2; done' || (echo "❌ Volume server failed to start, checking logs..." && tail -20 weed-volume.log && exit 1) - @echo "✅ Volume server is ready" - - @echo "Starting filer server..." - @$(WEED_BINARY) filer -port=$(FILER_PORT) \ - -defaultStoreDir=test-volume-data/filerldb2 \ - -master=localhost:$(MASTER_PORT) > weed-filer.log 2>&1 & \ - echo $$! > $(FILER_PID_FILE) - - @echo "Waiting for filer server to be ready..." - @timeout 60 bash -c 'until curl -s http://localhost:$(FILER_PORT)/status > /dev/null 2>&1; do echo "Waiting for filer server..."; sleep 2; done' || (echo "❌ Filer failed to start, checking logs..." && tail -20 weed-filer.log && exit 1) - @echo "✅ Filer server is ready" - - @echo "Starting S3 API server with IAM..." - @$(WEED_BINARY) -v=3 s3 -port=$(S3_PORT) \ - -filer=localhost:$(FILER_PORT) \ - -config=test_config.json \ - -iam.config=$(CURDIR)/iam_config.json > weed-s3.log 2>&1 & \ - echo $$! > $(S3_PID_FILE) - - @echo "Waiting for S3 API server to be ready..." - @timeout 60 bash -c 'until curl -s http://localhost:$(S3_PORT) > /dev/null 2>&1; do echo "Waiting for S3 API server..."; sleep 2; done' || (echo "❌ S3 API failed to start, checking logs..." && tail -20 weed-s3.log && exit 1) - @echo "✅ S3 API server is ready" - - @echo "✅ All services started and ready" + @echo "Waiting for services to be ready..." + @$(MAKE) wait-for-services + @echo "OK All services started and ready" wait-for-services: ## Wait for all services to be ready - @echo "⏳ Waiting for services to be ready..." - @echo "Checking master server..." - @timeout 30 bash -c 'until curl -s http://localhost:$(MASTER_PORT)/cluster/status > /dev/null; do sleep 1; done' || (echo "❌ Master failed to start" && exit 1) - - @echo "Checking filer server..." - @timeout 30 bash -c 'until curl -s http://localhost:$(FILER_PORT)/status > /dev/null; do sleep 1; done' || (echo "❌ Filer failed to start" && exit 1) - + @echo "Waiting for services to be ready..." @echo "Checking S3 API server..." - @timeout 30 bash -c 'until curl -s http://localhost:$(S3_PORT) > /dev/null 2>&1; do sleep 1; done' || (echo "❌ S3 API failed to start" && exit 1) + @for i in $$(seq 1 30); do \ + if curl -s http://localhost:$(S3_PORT) > /dev/null 2>&1; then \ + echo "OK S3 API server is ready"; \ + exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo "ERROR S3 API failed to start"; \ + exit 1 @echo "Pre-allocating volumes for concurrent operations..." - @curl -s "http://localhost:$(MASTER_PORT)/vol/grow?collection=default&count=10&replication=000" > /dev/null || echo "⚠️ Volume pre-allocation failed, but continuing..." + @curl -s "http://localhost:$(MASTER_PORT)/vol/grow?collection=default&count=10&replication=000" > /dev/null || echo "WARNING Volume pre-allocation failed, but continuing..." @sleep 3 - @echo "✅ All services are ready" + @echo "OK All services are ready" stop-services: ## Stop all SeaweedFS services - @echo "🛑 Stopping SeaweedFS services..." + @echo "Stopping SeaweedFS services..." + @if [ -f $(MINI_PID_FILE) ]; then \ + echo "Stopping weed mini..."; \ + kill $$(cat $(MINI_PID_FILE)) 2>/dev/null || true; \ + rm -f $(MINI_PID_FILE); \ + fi @if [ -f $(S3_PID_FILE) ]; then \ echo "Stopping S3 API server..."; \ kill $$(cat $(S3_PID_FILE)) 2>/dev/null || true; \ @@ -143,17 +118,17 @@ stop-services: ## Stop all SeaweedFS services kill $$(cat $(MASTER_PID_FILE)) 2>/dev/null || true; \ rm -f $(MASTER_PID_FILE); \ fi - @echo "✅ All services stopped" + @echo "OK All services stopped" clean: stop-services ## Clean up test environment - @echo "🧹 Cleaning up test environment..." + @echo "Cleaning up test environment..." @rm -rf test-volume-data @rm -f weed-*.log @rm -f *.test - @echo "✅ Cleanup complete" + @echo "Cleanup complete" logs: ## Show service logs - @echo "📋 Service Logs:" + @echo "Service Logs:" @echo "=== Master Log ===" @tail -20 weed-master.log 2>/dev/null || echo "No master log" @echo "" @@ -167,7 +142,7 @@ logs: ## Show service logs @tail -20 weed-s3.log 2>/dev/null || echo "No S3 log" status: ## Check service status - @echo "📊 Service Status:" + @echo "Service Status:" @echo -n "Master: "; curl -s http://localhost:$(MASTER_PORT)/cluster/status > /dev/null 2>&1 && echo "✅ Running" || echo "❌ Not running" @echo -n "Filer: "; curl -s http://localhost:$(FILER_PORT)/status > /dev/null 2>&1 && echo "✅ Running" || echo "❌ Not running" @echo -n "S3 API: "; curl -s http://localhost:$(S3_PORT) > /dev/null 2>&1 && echo "✅ Running" || echo "❌ Not running" diff --git a/test/s3/parquet/CROSS_FILESYSTEM_COMPATIBILITY.md b/test/s3/parquet/CROSS_FILESYSTEM_COMPATIBILITY.md deleted file mode 100644 index 62ef9563d..000000000 --- a/test/s3/parquet/CROSS_FILESYSTEM_COMPATIBILITY.md +++ /dev/null @@ -1,172 +0,0 @@ -# Cross-Filesystem Compatibility Test Results - -## Overview - -This document summarizes the cross-filesystem compatibility testing between **s3fs** and **PyArrow native S3 filesystem** implementations when working with SeaweedFS. - -## Test Purpose - -Verify that Parquet files written using one filesystem implementation (s3fs or PyArrow native S3) can be correctly read using the other implementation, confirming true file format compatibility. - -## Test Methodology - -### Test Matrix - -The test performs the following combinations: - -1. **Write with s3fs → Read with PyArrow native S3** -2. **Write with PyArrow native S3 → Read with s3fs** - -For each direction, the test: -- Creates a sample PyArrow table with multiple data types (int64, string, float64, bool) -- Writes the Parquet file using one filesystem implementation -- Reads the Parquet file using the other filesystem implementation -- Verifies data integrity by comparing: - - Row counts - - Schema equality - - Data contents (after sorting by ID to handle row order differences) - -### File Sizes Tested - -- **Small files**: 5 rows (quick validation) -- **Large files**: 200,000 rows (multi-row-group validation) - -## Test Results - -### ✅ Small Files (5 rows) - -| Write Method | Read Method | Result | Read Function Used | -|--------------|-------------|--------|--------------------| -| s3fs | PyArrow native S3 | ✅ PASS | pq.read_table | -| PyArrow native S3 | s3fs | ✅ PASS | pq.read_table | - -**Status**: **ALL TESTS PASSED** - -### Large Files (200,000 rows) - -Large file testing requires adequate volume capacity in SeaweedFS. When run with default volume settings (50MB max size), tests may encounter capacity issues with the number of large test files created simultaneously. - -**Recommendation**: For large file testing, increase `VOLUME_MAX_SIZE_MB` in the Makefile or run tests with `TEST_QUICK=1` for development/validation purposes. - -## Key Findings - -### ✅ Full Compatibility Confirmed - -**Files written with s3fs and PyArrow native S3 filesystem are fully compatible and can be read by either implementation.** - -This confirms that: - -1. **Identical Parquet Format**: Both s3fs and PyArrow native S3 use the same underlying PyArrow library to generate Parquet files, resulting in identical file formats at the binary level. - -2. **S3 API Compatibility**: SeaweedFS's S3 implementation handles both filesystem backends correctly, with proper: - - Object creation (PutObject) - - Object reading (GetObject) - - Directory handling (implicit directories) - - Multipart uploads (for larger files) - -3. **Metadata Consistency**: File metadata, schemas, and data integrity are preserved across both write and read operations regardless of which filesystem implementation is used. - -## Implementation Details - -### Common Write Path - -Both implementations use PyArrow's `pads.write_dataset()` function: - -```python -# s3fs approach -fs = s3fs.S3FileSystem(...) -pads.write_dataset(table, path, format="parquet", filesystem=fs) - -# PyArrow native approach -s3 = pafs.S3FileSystem(...) -pads.write_dataset(table, path, format="parquet", filesystem=s3) -``` - -### Multiple Read Methods Tested - -The test attempts reads using multiple PyArrow methods: -- `pq.read_table()` - Direct table reading -- `pq.ParquetDataset()` - Dataset-based reading -- `pads.dataset()` - PyArrow dataset API - -All methods successfully read files written by either filesystem implementation. - -## Practical Implications - -### For Users - -1. **Flexibility**: Users can choose either s3fs or PyArrow native S3 based on their preferences: - - **s3fs**: More mature, widely used, familiar API - - **PyArrow native**: Pure PyArrow solution, fewer dependencies - -2. **Interoperability**: Teams using different tools can seamlessly share Parquet datasets stored in SeaweedFS - -3. **Migration**: Easy to migrate between filesystem implementations without data conversion - -### For SeaweedFS - -1. **S3 Compatibility**: Confirms SeaweedFS's S3 implementation is compatible with major Python data science tools - -2. **Implicit Directory Handling**: The implicit directory fix works correctly for both filesystem implementations - -3. **Standard Compliance**: SeaweedFS handles S3 operations in a way that's compatible with AWS S3 behavior - -## Running the Tests - -### Quick Test (Recommended for Development) - -```bash -cd test/s3/parquet -TEST_QUICK=1 make test-cross-fs-with-server -``` - -### Full Test (All File Sizes) - -```bash -cd test/s3/parquet -make test-cross-fs-with-server -``` - -### Manual Test (Assuming Server is Running) - -```bash -cd test/s3/parquet -make setup-python -make start-seaweedfs-ci - -# In another terminal -TEST_QUICK=1 make test-cross-fs - -# Cleanup -make stop-seaweedfs-safe -``` - -## Environment Variables - -The test supports customization through environment variables: - -- `S3_ENDPOINT_URL`: S3 endpoint (default: `http://localhost:8333`) -- `S3_ACCESS_KEY`: Access key (default: `some_access_key1`) -- `S3_SECRET_KEY`: Secret key (default: `some_secret_key1`) -- `BUCKET_NAME`: Bucket name (default: `test-parquet-bucket`) -- `TEST_QUICK`: Run only small tests (default: `0`, set to `1` for quick mode) - -## Conclusion - -The cross-filesystem compatibility tests demonstrate that **Parquet files written via s3fs and PyArrow native S3 filesystem are completely interchangeable**. This validates that: - -1. The Parquet file format is implementation-agnostic -2. SeaweedFS's S3 API correctly handles both filesystem backends -3. Users have full flexibility in choosing their preferred filesystem implementation - -This compatibility is a testament to: -- PyArrow's consistent file format generation -- SeaweedFS's robust S3 API implementation -- Proper handling of S3 semantics (especially implicit directories) - ---- - -**Test Implementation**: `test_cross_filesystem_compatibility.py` -**Last Updated**: November 21, 2024 -**Status**: ✅ All critical tests passing - diff --git a/test/s3/parquet/FINAL_ROOT_CAUSE_ANALYSIS.md b/test/s3/parquet/FINAL_ROOT_CAUSE_ANALYSIS.md deleted file mode 100644 index 3dff9cb03..000000000 --- a/test/s3/parquet/FINAL_ROOT_CAUSE_ANALYSIS.md +++ /dev/null @@ -1,58 +0,0 @@ -# Final Root Cause Analysis - -## Overview - -This document provides a deep technical analysis of the s3fs compatibility issue with PyArrow Parquet datasets on SeaweedFS, and the solution implemented to resolve it. - -## Root Cause - -When PyArrow writes datasets using `write_dataset()`, it creates implicit directory structures by writing files without explicit directory markers. However, some S3 workflows may create 0-byte directory markers. - -### The Problem - -1. **PyArrow writes dataset files** without creating explicit directory objects -2. **s3fs calls HEAD** on the directory path to check if it exists -3. **If HEAD returns 200** with `Content-Length: 0`, s3fs interprets it as a file (not a directory) -4. **PyArrow fails** when trying to read, reporting "Parquet file size is 0 bytes" - -### AWS S3 Behavior - -AWS S3 returns **404 Not Found** for implicit directories (directories that only exist because they have children but no explicit marker object). This allows s3fs to fall back to LIST operations to detect the directory. - -## The Solution - -### Implementation - -Modified the S3 API HEAD handler in `weed/s3api/s3api_object_handlers.go` to: - -1. **Check if object ends with `/`**: Explicit directory markers return 200 as before -2. **Check if object has children**: If a 0-byte object has children in the filer, treat it as an implicit directory -3. **Return 404 for implicit directories**: This matches AWS S3 behavior and triggers s3fs's LIST fallback - -### Code Changes - -The fix is implemented in the `HeadObjectHandler` function with logic to: -- Detect implicit directories by checking for child entries -- Return 404 (NoSuchKey) for implicit directories -- Preserve existing behavior for explicit directory markers and regular files - -## Performance Considerations - -### Optimization: Child Check Cache -- Child existence checks are performed via filer LIST operations -- Results could be cached for frequently accessed paths -- Trade-off between consistency and performance - -### Impact -- Minimal performance impact for normal file operations -- Slight overhead for HEAD requests on implicit directories (one additional LIST call) -- Overall improvement in PyArrow compatibility outweighs minor performance cost - -## TODO - -- [ ] Add detailed benchmarking results comparing before/after fix -- [ ] Document edge cases discovered during implementation -- [ ] Add architectural diagrams showing the request flow -- [ ] Document alternative solutions considered and why they were rejected -- [ ] Add performance profiling data for child existence checks - diff --git a/test/s3/parquet/MINIO_DIRECTORY_HANDLING.md b/test/s3/parquet/MINIO_DIRECTORY_HANDLING.md deleted file mode 100644 index 04d80cfcb..000000000 --- a/test/s3/parquet/MINIO_DIRECTORY_HANDLING.md +++ /dev/null @@ -1,70 +0,0 @@ -# MinIO Directory Handling Comparison - -## Overview - -This document compares how MinIO handles directory markers versus SeaweedFS's implementation, and explains the different approaches to S3 directory semantics. - -## MinIO's Approach - -MinIO handles implicit directories similarly to AWS S3: - -1. **No explicit directory objects**: Directories are implicit, defined only by object key prefixes -2. **HEAD on directory returns 404**: Consistent with AWS S3 behavior -3. **LIST operations reveal directories**: Directories are discovered through delimiter-based LIST operations -4. **Automatic prefix handling**: MinIO automatically recognizes prefixes as directories - -### MinIO Implementation Details - -- Uses in-memory metadata for fast prefix lookups -- Optimized for LIST operations with common delimiter (`/`) -- No persistent directory objects in storage layer -- Directories "exist" as long as they contain objects - -## SeaweedFS Approach - -SeaweedFS uses a filer-based approach with real directory entries: - -### Before the Fix - -1. **Explicit directory objects**: Could create 0-byte objects as directory markers -2. **HEAD returns 200**: Even for implicit directories -3. **Caused s3fs issues**: s3fs interpreted 0-byte HEAD responses as empty files - -### After the Fix - -1. **Hybrid approach**: Supports both explicit markers (with `/` suffix) and implicit directories -2. **HEAD returns 404 for implicit directories**: Matches AWS S3 and MinIO behavior -3. **Filer integration**: Uses filer's directory metadata to detect implicit directories -4. **s3fs compatibility**: Triggers proper LIST fallback behavior - -## Key Differences - -| Aspect | MinIO | SeaweedFS (After Fix) | -|--------|-------|----------------------| -| Directory Storage | No persistent objects | Filer directory entries | -| Implicit Directory HEAD | 404 Not Found | 404 Not Found | -| Explicit Marker HEAD | Not applicable | 200 OK (with `/` suffix) | -| Child Detection | Prefix scan | Filer LIST operation | -| Performance | In-memory lookups | Filer gRPC calls | - -## Implementation Considerations - -### Advantages of SeaweedFS Approach -- Integrates with existing filer metadata -- Supports both implicit and explicit directories -- Preserves directory metadata and attributes -- Compatible with POSIX filer semantics - -### Trade-offs -- Additional filer communication overhead for HEAD requests -- Complexity of supporting both directory paradigms -- Performance depends on filer efficiency - -## TODO - -- [ ] Add performance benchmark comparison: MinIO vs SeaweedFS -- [ ] Document edge cases where behaviors differ -- [ ] Add example request/response traces for both systems -- [ ] Document migration path for users moving from MinIO to SeaweedFS -- [ ] Add compatibility matrix for different S3 clients - diff --git a/test/s3/parquet/Makefile b/test/s3/parquet/Makefile index 0aa6c8117..708f4aa5c 100644 --- a/test/s3/parquet/Makefile +++ b/test/s3/parquet/Makefile @@ -4,14 +4,9 @@ # Default values SEAWEEDFS_BINARY ?= weed S3_PORT ?= 8333 -FILER_PORT ?= 8888 -VOLUME_PORT ?= 8080 -MASTER_PORT ?= 9333 TEST_TIMEOUT ?= 15m ACCESS_KEY ?= some_access_key1 SECRET_KEY ?= some_secret_key1 -VOLUME_MAX_SIZE_MB ?= 50 -VOLUME_MAX_COUNT ?= 100 BUCKET_NAME ?= test-parquet-bucket ENABLE_SSE_S3 ?= false @@ -68,11 +63,7 @@ help: @echo "Configuration:" @echo " SEAWEEDFS_BINARY=$(SEAWEEDFS_BINARY)" @echo " S3_PORT=$(S3_PORT)" - @echo " FILER_PORT=$(FILER_PORT)" - @echo " VOLUME_PORT=$(VOLUME_PORT)" - @echo " MASTER_PORT=$(MASTER_PORT)" @echo " BUCKET_NAME=$(BUCKET_NAME)" - @echo " VOLUME_MAX_SIZE_MB=$(VOLUME_MAX_SIZE_MB)" @echo " ENABLE_SSE_S3=$(ENABLE_SSE_S3)" @echo " PYTHON=$(PYTHON)" @@ -106,39 +97,25 @@ setup-python: check-python start-seaweedfs-ci: check-binary @echo "$(YELLOW)Starting SeaweedFS server for Parquet testing...$(NC)" - # Clean up any existing processes first (CI-safe) - @echo "Cleaning up any existing processes..." + # Clean up any existing processes first (CI-safe) - aggressive cleanup + @echo "Aggressively cleaning up any existing processes on S3 port $(S3_PORT) and master port 9333..." @if command -v lsof >/dev/null 2>&1; then \ - lsof -ti :$(MASTER_PORT) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \ - lsof -ti :$(VOLUME_PORT) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \ - lsof -ti :$(FILER_PORT) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \ - lsof -ti :$(S3_PORT) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \ - lsof -ti :$$(( $(MASTER_PORT) + 10000 )) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \ - lsof -ti :$$(( $(VOLUME_PORT) + 10000 )) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \ - lsof -ti :$$(( $(FILER_PORT) + 10000 )) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \ + for attempt in 1 2 3; do \ + lsof -ti :$(S3_PORT) 2>/dev/null | head -5 | while read pid; do kill -9 $$pid 2>/dev/null || true; done; \ + lsof -ti :9333 2>/dev/null | head -5 | while read pid; do kill -9 $$pid 2>/dev/null || true; done; \ + sleep 1; \ + done; \ fi - @sleep 2 + @sleep 3 # Create necessary directories - @mkdir -p /tmp/seaweedfs-test-parquet-master - @mkdir -p /tmp/seaweedfs-test-parquet-volume - @mkdir -p /tmp/seaweedfs-test-parquet-filer + @mkdir -p /tmp/seaweedfs-test-parquet # Clean up any old server logs @rm -f /tmp/seaweedfs-parquet-*.log || true - # Start master server with volume size limit and explicit gRPC port - @echo "Starting master server..." - @nohup $(SEAWEEDFS_BINARY) master -port=$(MASTER_PORT) -port.grpc=$$(( $(MASTER_PORT) + 10000 )) -mdir=/tmp/seaweedfs-test-parquet-master -volumeSizeLimitMB=$(VOLUME_MAX_SIZE_MB) -ip=127.0.0.1 -peers=none > /tmp/seaweedfs-parquet-master.log 2>&1 & - @sleep 3 - - # Start volume server with master HTTP port and increased capacity - @echo "Starting volume server..." - @nohup $(SEAWEEDFS_BINARY) volume -port=$(VOLUME_PORT) -master=127.0.0.1:$(MASTER_PORT) -dir=/tmp/seaweedfs-test-parquet-volume -max=$(VOLUME_MAX_COUNT) -ip=127.0.0.1 -preStopSeconds=1 > /tmp/seaweedfs-parquet-volume.log 2>&1 & - @sleep 5 - - # Start filer server with embedded S3 - @echo "Starting filer server with embedded S3..." + # Start weed mini with embedded S3 + @echo "Starting weed mini with embedded S3..." @if [ "$(ENABLE_SSE_S3)" = "true" ]; then \ echo " SSE-S3 encryption: ENABLED"; \ printf '{"identities":[{"name":"%s","credentials":[{"accessKey":"%s","secretKey":"%s"}],"actions":["Admin","Read","Write"]}],"buckets":[{"name":"$(BUCKET_NAME)","encryption":{"sseS3":{"enabled":true}}}]}' "$(ACCESS_KEY)" "$(ACCESS_KEY)" "$(SECRET_KEY)" > /tmp/seaweedfs-parquet-s3.json; \ @@ -146,96 +123,43 @@ start-seaweedfs-ci: check-binary echo " SSE-S3 encryption: DISABLED"; \ printf '{"identities":[{"name":"%s","credentials":[{"accessKey":"%s","secretKey":"%s"}],"actions":["Admin","Read","Write"]}]}' "$(ACCESS_KEY)" "$(ACCESS_KEY)" "$(SECRET_KEY)" > /tmp/seaweedfs-parquet-s3.json; \ fi - @AWS_ACCESS_KEY_ID=$(ACCESS_KEY) AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) nohup $(SEAWEEDFS_BINARY) filer -port=$(FILER_PORT) -port.grpc=$$(( $(FILER_PORT) + 10000 )) -master=127.0.0.1:$(MASTER_PORT) -dataCenter=defaultDataCenter -ip=127.0.0.1 -s3 -s3.port=$(S3_PORT) -s3.config=/tmp/seaweedfs-parquet-s3.json > /tmp/seaweedfs-parquet-filer.log 2>&1 & - @sleep 5 - - # Wait for S3 service to be ready - use port-based checking for reliability - @echo "$(YELLOW)Waiting for S3 service to be ready...$(NC)" - @for i in $$(seq 1 20); do \ - if netstat -an 2>/dev/null | grep -q ":$(S3_PORT).*LISTEN" || \ - ss -an 2>/dev/null | grep -q ":$(S3_PORT).*LISTEN" || \ - lsof -i :$(S3_PORT) >/dev/null 2>&1; then \ - echo "$(GREEN)S3 service is listening on port $(S3_PORT)$(NC)"; \ - sleep 1; \ - break; \ + @$(SEAWEEDFS_BINARY) mini \ + -dir=/tmp/seaweedfs-test-parquet \ + -ip.bind=0.0.0.0 \ + -s3.port=$(S3_PORT) \ + -s3.config=/tmp/seaweedfs-parquet-s3.json \ + > /tmp/seaweedfs-parquet-mini.log 2>&1 & echo $$! > /tmp/weed-mini.pid + @echo "Waiting for S3 service to be fully ready (max 90 seconds)..." + @bash -c 'for i in $$(seq 1 90); do \ + if curl -s -H "Authorization: AWS4-HMAC-SHA256 Credential=$(ACCESS_KEY)" http://localhost:$(S3_PORT)/ > /dev/null 2>&1; then \ + echo "✅ S3 service is ready"; \ + sleep 2; \ + exit 0; \ fi; \ - if [ $$i -eq 20 ]; then \ - echo "$(RED)S3 service failed to start within 20 seconds$(NC)"; \ - echo "=== Detailed Logs ==="; \ - echo "Master log:"; tail -30 /tmp/seaweedfs-parquet-master.log || true; \ - echo "Volume log:"; tail -30 /tmp/seaweedfs-parquet-volume.log || true; \ - echo "Filer log:"; tail -30 /tmp/seaweedfs-parquet-filer.log || true; \ - echo "=== Port Status ==="; \ - netstat -an 2>/dev/null | grep ":$(S3_PORT)" || \ - ss -an 2>/dev/null | grep ":$(S3_PORT)" || \ - echo "No port listening on $(S3_PORT)"; \ - exit 1; \ - fi; \ - echo "Waiting for S3 service... ($$i/20)"; \ sleep 1; \ - done - - # Additional wait for filer gRPC to be ready - @echo "$(YELLOW)Waiting for filer gRPC to be ready...$(NC)" - @sleep 2 - - # Wait for volume server to register with master and ensure volume assignment works - @echo "$(YELLOW)Waiting for volume assignment to be ready...$(NC)" - @for i in $$(seq 1 30); do \ - ASSIGN_RESULT=$$(curl -s "http://localhost:$(MASTER_PORT)/dir/assign?count=1" 2>/dev/null); \ - if echo "$$ASSIGN_RESULT" | grep -q '"fid"'; then \ - echo "$(GREEN)Volume assignment is ready$(NC)"; \ - break; \ - fi; \ - if [ $$i -eq 30 ]; then \ - echo "$(RED)Volume assignment not ready after 30 seconds$(NC)"; \ - echo "=== Last assign attempt ==="; \ - echo "$$ASSIGN_RESULT"; \ - echo "=== Master Status ==="; \ - curl -s "http://localhost:$(MASTER_PORT)/dir/status" 2>/dev/null || echo "Failed to get master status"; \ - echo "=== Master Logs ==="; \ - tail -50 /tmp/seaweedfs-parquet-master.log 2>/dev/null || echo "No master log"; \ - echo "=== Volume Logs ==="; \ - tail -50 /tmp/seaweedfs-parquet-volume.log 2>/dev/null || echo "No volume log"; \ - exit 1; \ - fi; \ - echo "Waiting for volume assignment... ($$i/30)"; \ - sleep 1; \ - done - - @echo "$(GREEN)SeaweedFS server started successfully for Parquet testing$(NC)" - @echo "Master: http://localhost:$(MASTER_PORT)" - @echo "Volume: http://localhost:$(VOLUME_PORT)" - @echo "Filer: http://localhost:$(FILER_PORT)" - @echo "S3: http://localhost:$(S3_PORT)" - @echo "Volume Max Size: $(VOLUME_MAX_SIZE_MB)MB" + done; \ + echo "ERROR S3 service failed to start within 90 seconds"; \ + echo "=== Server log output ==="; \ + cat /tmp/seaweedfs-parquet-mini.log 2>/dev/null || echo "No startup log available"; \ + exit 1' start-seaweedfs: check-binary @echo "$(YELLOW)Starting SeaweedFS server for Parquet testing...$(NC)" @# Use port-based cleanup for consistency and safety @echo "Cleaning up any existing processes..." - @lsof -ti :$(MASTER_PORT) 2>/dev/null | xargs -r kill -TERM || true - @lsof -ti :$(VOLUME_PORT) 2>/dev/null | xargs -r kill -TERM || true - @lsof -ti :$(FILER_PORT) 2>/dev/null | xargs -r kill -TERM || true @lsof -ti :$(S3_PORT) 2>/dev/null | xargs -r kill -TERM || true - @# Clean up gRPC ports (HTTP port + 10000) - @lsof -ti :$$(( $(MASTER_PORT) + 10000 )) 2>/dev/null | xargs -r kill -TERM || true - @lsof -ti :$$(( $(VOLUME_PORT) + 10000 )) 2>/dev/null | xargs -r kill -TERM || true - @lsof -ti :$$(( $(FILER_PORT) + 10000 )) 2>/dev/null | xargs -r kill -TERM || true @sleep 2 @$(MAKE) start-seaweedfs-ci stop-seaweedfs: @echo "$(YELLOW)Stopping SeaweedFS server...$(NC)" @# Use port-based cleanup for consistency and safety - @lsof -ti :$(MASTER_PORT) 2>/dev/null | xargs -r kill -TERM || true - @lsof -ti :$(VOLUME_PORT) 2>/dev/null | xargs -r kill -TERM || true - @lsof -ti :$(FILER_PORT) 2>/dev/null | xargs -r kill -TERM || true + @if [ -f /tmp/weed-mini.pid ]; then \ + echo "Stopping weed mini..."; \ + kill $$(cat /tmp/weed-mini.pid) || true; \ + rm -f /tmp/weed-mini.pid; \ + fi @lsof -ti :$(S3_PORT) 2>/dev/null | xargs -r kill -TERM || true - @# Clean up gRPC ports (HTTP port + 10000) - @lsof -ti :$$(( $(MASTER_PORT) + 10000 )) 2>/dev/null | xargs -r kill -TERM || true - @lsof -ti :$$(( $(VOLUME_PORT) + 10000 )) 2>/dev/null | xargs -r kill -TERM || true - @lsof -ti :$$(( $(FILER_PORT) + 10000 )) 2>/dev/null | xargs -r kill -TERM || true @sleep 2 @echo "$(GREEN)SeaweedFS server stopped$(NC)" @@ -245,22 +169,10 @@ stop-seaweedfs-safe: @# Use port-based cleanup which is safer in CI @if command -v lsof >/dev/null 2>&1; then \ echo "Using lsof for port-based cleanup..."; \ - lsof -ti :$(MASTER_PORT) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \ - lsof -ti :$(VOLUME_PORT) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \ - lsof -ti :$(FILER_PORT) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \ lsof -ti :$(S3_PORT) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \ - lsof -ti :$$(( $(MASTER_PORT) + 10000 )) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \ - lsof -ti :$$(( $(VOLUME_PORT) + 10000 )) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \ - lsof -ti :$$(( $(FILER_PORT) + 10000 )) 2>/dev/null | head -5 | while read pid; do kill -TERM $$pid 2>/dev/null || true; done; \ else \ echo "lsof not available, using netstat approach..."; \ - netstat -tlnp 2>/dev/null | grep :$(MASTER_PORT) | awk '{print $$7}' | cut -d/ -f1 | head -5 | while read pid; do [ "$$pid" != "-" ] && kill -TERM $$pid 2>/dev/null || true; done; \ - netstat -tlnp 2>/dev/null | grep :$(VOLUME_PORT) | awk '{print $$7}' | cut -d/ -f1 | head -5 | while read pid; do [ "$$pid" != "-" ] && kill -TERM $$pid 2>/dev/null || true; done; \ - netstat -tlnp 2>/dev/null | grep :$(FILER_PORT) | awk '{print $$7}' | cut -d/ -f1 | head -5 | while read pid; do [ "$$pid" != "-" ] && kill -TERM $$pid 2>/dev/null || true; done; \ netstat -tlnp 2>/dev/null | grep :$(S3_PORT) | awk '{print $$7}' | cut -d/ -f1 | head -5 | while read pid; do [ "$$pid" != "-" ] && kill -TERM $$pid 2>/dev/null || true; done; \ - netstat -tlnp 2>/dev/null | grep :$$(( $(MASTER_PORT) + 10000 )) | awk '{print $$7}' | cut -d/ -f1 | head -5 | while read pid; do [ "$$pid" != "-" ] && kill -TERM $$pid 2>/dev/null || true; done; \ - netstat -tlnp 2>/dev/null | grep :$$(( $(VOLUME_PORT) + 10000 )) | awk '{print $$7}' | cut -d/ -f1 | head -5 | while read pid; do [ "$$pid" != "-" ] && kill -TERM $$pid 2>/dev/null || true; done; \ - netstat -tlnp 2>/dev/null | grep :$$(( $(FILER_PORT) + 10000 )) | awk '{print $$7}' | cut -d/ -f1 | head -5 | while read pid; do [ "$$pid" != "-" ] && kill -TERM $$pid 2>/dev/null || true; done; \ fi @sleep 2 @echo "$(GREEN)SeaweedFS server safely stopped$(NC)" @@ -351,18 +263,14 @@ test-implicit-dir-with-server: build-weed setup-python # Debug targets debug-logs: - @echo "$(YELLOW)=== Master Log ===$(NC)" - @tail -n 50 /tmp/seaweedfs-parquet-master.log || echo "No master log found" - @echo "$(YELLOW)=== Volume Log ===$(NC)" - @tail -n 50 /tmp/seaweedfs-parquet-volume.log || echo "No volume log found" - @echo "$(YELLOW)=== Filer Log ===$(NC)" - @tail -n 50 /tmp/seaweedfs-parquet-filer.log || echo "No filer log found" + @echo "$(YELLOW)=== Mini Log ===$(NC)" + @tail -n 50 /tmp/seaweedfs-parquet-mini.log || echo "No mini log found" debug-status: @echo "$(YELLOW)=== Process Status ===$(NC)" @ps aux | grep -E "(weed|seaweedfs)" | grep -v grep || echo "No SeaweedFS processes found" @echo "$(YELLOW)=== Port Status ===$(NC)" - @netstat -an | grep -E "($(MASTER_PORT)|$(VOLUME_PORT)|$(FILER_PORT)|$(S3_PORT))" || echo "No ports in use" + @netstat -an | grep -E "($(S3_PORT))" || echo "No ports in use" # Manual test targets for development manual-start: start-seaweedfs diff --git a/test/s3/parquet/TEST_COVERAGE.md b/test/s3/parquet/TEST_COVERAGE.md deleted file mode 100644 index f08a93ab9..000000000 --- a/test/s3/parquet/TEST_COVERAGE.md +++ /dev/null @@ -1,46 +0,0 @@ -# Test Coverage Documentation - -## Overview - -This document provides comprehensive test coverage documentation for the SeaweedFS S3 Parquet integration tests. - -## Test Categories - -### Unit Tests (Go) -- 17 test cases covering S3 API handlers -- Tests for implicit directory handling -- HEAD request behavior validation -- Located in: `weed/s3api/s3api_implicit_directory_test.go` - -### Integration Tests (Python) -- 6 test cases for implicit directory fix -- Tests HEAD request behavior on directory markers -- s3fs directory detection validation -- PyArrow dataset read compatibility -- Located in: `test_implicit_directory_fix.py` - -### End-to-End Tests (Python) -- 20 test cases combining write and read methods -- Small file tests (5 rows): 10 test combinations -- Large file tests (200,000 rows): 10 test combinations -- Tests multiple write methods: `pads.write_dataset`, `pq.write_table+s3fs` -- Tests multiple read methods: `pads.dataset`, `pq.ParquetDataset`, `pq.read_table`, `s3fs+direct`, `s3fs+buffered` -- Located in: `s3_parquet_test.py` - -## Coverage Summary - -| Test Type | Count | Status | -|-----------|-------|--------| -| Unit Tests (Go) | 17 | ✅ Pass | -| Integration Tests (Python) | 6 | ✅ Pass | -| End-to-End Tests (Python) | 20 | ✅ Pass | -| **Total** | **43** | **✅ All Pass** | - -## TODO - -- [ ] Add detailed test execution time metrics -- [ ] Document test data generation strategies -- [ ] Add code coverage percentages for Go tests -- [ ] Document edge cases and corner cases tested -- [ ] Add performance benchmarking results - diff --git a/test/s3/parquet/test_implicit_directory_fix.py b/test/s3/parquet/test_implicit_directory_fix.py index 2ed52e5d7..58f3f2170 100755 --- a/test/s3/parquet/test_implicit_directory_fix.py +++ b/test/s3/parquet/test_implicit_directory_fix.py @@ -60,6 +60,7 @@ def setup_s3(): endpoint_url=S3_ENDPOINT_URL, aws_access_key_id=S3_ACCESS_KEY, aws_secret_access_key=S3_SECRET_KEY, + region_name='us-east-1', use_ssl=False ) diff --git a/test/s3/remote_cache/Makefile b/test/s3/remote_cache/Makefile index 1b7a64539..0292c0d35 100644 --- a/test/s3/remote_cache/Makefile +++ b/test/s3/remote_cache/Makefile @@ -10,19 +10,17 @@ all: test-with-server # Configuration WEED_BINARY := ../../../weed/weed_binary +ACCESS_KEY ?= some_access_key1 +SECRET_KEY ?= some_secret_key1 + # Primary SeaweedFS (the one being tested - has remote caching) PRIMARY_S3_PORT := 8333 -PRIMARY_FILER_PORT := 8888 PRIMARY_MASTER_PORT := 9333 -PRIMARY_VOLUME_PORT := 8080 PRIMARY_METRICS_PORT := 9324 PRIMARY_DIR := ./test-primary-data # Secondary SeaweedFS (acts as "remote" S3 storage) REMOTE_S3_PORT := 8334 -REMOTE_FILER_PORT := 8889 -REMOTE_MASTER_PORT := 9334 -REMOTE_VOLUME_PORT := 8081 REMOTE_METRICS_PORT := 9325 REMOTE_DIR := ./test-remote-data @@ -73,18 +71,11 @@ start-remote: check-deps @echo "Starting remote SeaweedFS (secondary instance)..." @rm -f remote-server.pid @mkdir -p $(REMOTE_DIR) - @$(WEED_BINARY) server \ - -s3 \ + @AWS_ACCESS_KEY_ID=$(ACCESS_KEY) AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) $(WEED_BINARY) mini \ -s3.port=$(REMOTE_S3_PORT) \ -s3.allowDeleteBucketNotEmpty=true \ - -filer \ - -filer.port=$(REMOTE_FILER_PORT) \ - -master.port=$(REMOTE_MASTER_PORT) \ - -volume.port=$(REMOTE_VOLUME_PORT) \ - -master.volumeSizeLimitMB=50 \ - -volume.max=100 \ -dir=$(REMOTE_DIR) \ - -volume.preStopSeconds=1 \ + -ip.bind=0.0.0.0 \ -metricsPort=$(REMOTE_METRICS_PORT) \ > remote-weed.log 2>&1 & echo $$! > remote-server.pid @echo "Waiting for remote SeaweedFS to start..." @@ -93,7 +84,7 @@ start-remote: check-deps echo "Remote SeaweedFS started on port $(REMOTE_S3_PORT)"; \ exit 0; \ fi; \ - sleep 1; \ + sleep 3; \ done; \ echo "ERROR: Remote SeaweedFS failed to start"; \ cat remote-weed.log; \ @@ -114,18 +105,11 @@ start-primary: check-deps @echo "Starting primary SeaweedFS..." @rm -f primary-server.pid @mkdir -p $(PRIMARY_DIR) - @$(WEED_BINARY) server \ - -s3 \ + @AWS_ACCESS_KEY_ID=$(ACCESS_KEY) AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) $(WEED_BINARY) mini \ -s3.port=$(PRIMARY_S3_PORT) \ -s3.allowDeleteBucketNotEmpty=true \ - -filer \ - -filer.port=$(PRIMARY_FILER_PORT) \ - -master.port=$(PRIMARY_MASTER_PORT) \ - -volume.port=$(PRIMARY_VOLUME_PORT) \ - -master.volumeSizeLimitMB=50 \ - -volume.max=100 \ -dir=$(PRIMARY_DIR) \ - -volume.preStopSeconds=1 \ + -ip.bind=0.0.0.0 \ -metricsPort=$(PRIMARY_METRICS_PORT) \ > primary-weed.log 2>&1 & echo $$! > primary-server.pid @echo "Waiting for primary SeaweedFS to start..." @@ -134,7 +118,7 @@ start-primary: check-deps echo "Primary SeaweedFS started on port $(PRIMARY_S3_PORT)"; \ exit 0; \ fi; \ - sleep 1; \ + sleep 3; \ done; \ echo "ERROR: Primary SeaweedFS failed to start"; \ cat primary-weed.log; \ @@ -156,7 +140,7 @@ setup-remote: @curl -s -X PUT "http://localhost:$(REMOTE_S3_PORT)/$(REMOTE_BUCKET)" || echo "Bucket may already exist" @sleep 1 @echo "Configuring remote storage on primary..." - @printf 'remote.configure -name=seaweedremote -type=s3 -s3.access_key=any -s3.secret_key=any -s3.endpoint=http://localhost:$(REMOTE_S3_PORT) -s3.region=us-east-1\nexit\n' | $(WEED_BINARY) shell -master=localhost:$(PRIMARY_MASTER_PORT) 2>&1 || echo "remote.configure done" + @printf 'remote.configure -name=seaweedremote -type=s3 -s3.access_key=$(ACCESS_KEY) -s3.secret_key=$(SECRET_KEY) -s3.endpoint=http://localhost:$(REMOTE_S3_PORT) -s3.region=us-east-1\nexit\n' | $(WEED_BINARY) shell -master=localhost:$(PRIMARY_MASTER_PORT) 2>&1 || echo "remote.configure done" @sleep 2 @echo "Mounting remote bucket on primary..." @printf 'remote.mount -dir=/buckets/remotemounted -remote=seaweedremote/$(REMOTE_BUCKET) -nonempty\nexit\n' | $(WEED_BINARY) shell -master=localhost:$(PRIMARY_MASTER_PORT) 2>&1 || echo "remote.mount done" diff --git a/test/s3/remote_cache/remote_cache_test.go b/test/s3/remote_cache/remote_cache_test.go index 08eca1802..290151ba8 100644 --- a/test/s3/remote_cache/remote_cache_test.go +++ b/test/s3/remote_cache/remote_cache_test.go @@ -34,8 +34,8 @@ const ( remoteEndpoint = "http://localhost:8334" // Credentials (anonymous access for testing) - accessKey = "any" - secretKey = "any" + accessKey = "some_access_key1" + secretKey = "some_secret_key1" // Bucket name - mounted on primary as remote storage testBucket = "remotemounted" @@ -121,17 +121,6 @@ func getFromPrimary(t *testing.T, key string) []byte { return data } -// syncToRemote syncs local data to remote storage -func syncToRemote(t *testing.T) { - t.Log("Syncing to remote storage...") - output, err := runWeedShell(t, "remote.cache.uncache -dir=/buckets/"+testBucket+" -include=*") - if err != nil { - t.Logf("syncToRemote warning: %v", err) - } - t.Log(output) - time.Sleep(1 * time.Second) -} - // uncacheLocal purges the local cache, forcing data to be fetched from remote func uncacheLocal(t *testing.T, pattern string) { t.Logf("Purging local cache for pattern: %s", pattern) diff --git a/test/s3/retention/Makefile b/test/s3/retention/Makefile index 3277e1db0..9854e0aef 100644 --- a/test/s3/retention/Makefile +++ b/test/s3/retention/Makefile @@ -6,11 +6,14 @@ # Configuration WEED_BINARY := ../../../weed/weed_binary S3_PORT := 8333 +ACCESS_KEY ?= some_access_key1 +SECRET_KEY ?= some_secret_key1 MASTER_PORT := 9333 VOLUME_PORT := 8080 FILER_PORT := 8888 TEST_TIMEOUT := 15m TEST_PATTERN := TestRetention +SERVER_DIR := ./test-volume-data/server-data # Default target help: @@ -80,23 +83,16 @@ start-server: check-deps @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.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 \ + @echo "🔍 DEBUG: Creating server data directory..." + @mkdir -p $(SERVER_DIR) + @echo "🔍 DEBUG: Launching SeaweedFS S3 server in background..." + @echo "🔍 DEBUG: Command: $(WEED_BINARY) mini -dir=$(SERVER_DIR) -s3.port=$(S3_PORT)" + @AWS_ACCESS_KEY_ID=$(ACCESS_KEY) AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) $(WEED_BINARY) mini \ + -dir=$(SERVER_DIR) \ -s3.port=$(S3_PORT) \ - -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')" + > weed-test.log 2>&1 & \ + echo $$! > weed-server.pid + @echo "🔍 DEBUG: Server PID: $$(cat weed-test.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 \ @@ -105,7 +101,6 @@ start-server: check-deps 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 \ @@ -123,8 +118,6 @@ start-server: check-deps 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..."; \ diff --git a/test/s3/sse/Makefile b/test/s3/sse/Makefile index 8d0869a82..e646ef901 100644 --- a/test/s3/sse/Makefile +++ b/test/s3/sse/Makefile @@ -93,54 +93,35 @@ start-seaweedfs: check-binary @sleep 2 # Create necessary directories - @mkdir -p /tmp/seaweedfs-test-sse-master - @mkdir -p /tmp/seaweedfs-test-sse-volume - @mkdir -p /tmp/seaweedfs-test-sse-filer - - # Start master server with volume size limit and explicit gRPC port - @nohup $(SEAWEEDFS_BINARY) master -port=$(MASTER_PORT) -port.grpc=$$(( $(MASTER_PORT) + 10000 )) -mdir=/tmp/seaweedfs-test-sse-master -volumeSizeLimitMB=$(VOLUME_MAX_SIZE_MB) -ip=127.0.0.1 -peers=none > /tmp/seaweedfs-sse-master.log 2>&1 & - @sleep 3 - - # Start volume server with master HTTP port and increased capacity - @nohup $(SEAWEEDFS_BINARY) volume -port=$(VOLUME_PORT) -master=127.0.0.1:$(MASTER_PORT) -dir=/tmp/seaweedfs-test-sse-volume -max=$(VOLUME_MAX_COUNT) -ip=127.0.0.1 > /tmp/seaweedfs-sse-volume.log 2>&1 & - @sleep 5 - - # Start filer server (using standard SeaweedFS gRPC port convention: HTTP port + 10000) - @nohup $(SEAWEEDFS_BINARY) filer -port=$(FILER_PORT) -port.grpc=$$(( $(FILER_PORT) + 10000 )) -master=127.0.0.1:$(MASTER_PORT) -dataCenter=defaultDataCenter -ip=127.0.0.1 > /tmp/seaweedfs-sse-filer.log 2>&1 & - @sleep 3 + @mkdir -p /tmp/seaweedfs-test-sse # Create S3 configuration with SSE-KMS support @printf '{"identities":[{"name":"%s","credentials":[{"accessKey":"%s","secretKey":"%s"}],"actions":["Admin","Read","Write"]}],"kms":{"type":"%s","configs":{"keyId":"%s","encryptionContext":{},"bucketKey":false}}}' "$(ACCESS_KEY)" "$(ACCESS_KEY)" "$(SECRET_KEY)" "$(KMS_TYPE)" "$(KMS_KEY_ID)" > /tmp/seaweedfs-sse-s3.json - # Start S3 server with KMS configuration - @nohup $(SEAWEEDFS_BINARY) s3 -port=$(S3_PORT) -filer=127.0.0.1:$(FILER_PORT) -config=/tmp/seaweedfs-sse-s3.json -ip.bind=127.0.0.1 > /tmp/seaweedfs-sse-s3.log 2>&1 & - @sleep 5 - - # Wait for S3 service to be ready - @echo "$(YELLOW)Waiting for S3 service to be ready...$(NC)" + # Start weed mini + @AWS_ACCESS_KEY_ID=$(ACCESS_KEY) AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) $(SEAWEEDFS_BINARY) mini \ + -dir=/tmp/seaweedfs-test-sse \ + -s3.port=$(S3_PORT) \ + -s3.config=/tmp/seaweedfs-sse-s3.json \ + > /tmp/seaweedfs-sse-mini.log 2>&1 & echo $$! > /tmp/weed-mini.pid + + @echo "Checking S3 service is ready..." @for i in $$(seq 1 30); do \ - if curl -s -f http://127.0.0.1:$(S3_PORT) > /dev/null 2>&1; then \ - echo "$(GREEN)S3 service is ready$(NC)"; \ + if curl -s http://127.0.0.1:$(S3_PORT) > /dev/null 2>&1; then \ + echo "✅ S3 service is ready"; \ break; \ fi; \ - echo "Waiting for S3 service... ($$i/30)"; \ sleep 1; \ done - - # Additional wait for filer gRPC to be ready - @echo "$(YELLOW)Waiting for filer gRPC to be ready...$(NC)" - @sleep 2 - @echo "$(GREEN)SeaweedFS server started successfully for SSE testing$(NC)" - @echo "Master: http://localhost:$(MASTER_PORT)" - @echo "Volume: http://localhost:$(VOLUME_PORT)" - @echo "Filer: http://localhost:$(FILER_PORT)" - @echo "S3: http://localhost:$(S3_PORT)" - @echo "Volume Max Size: $(VOLUME_MAX_SIZE_MB)MB" - @echo "SSE-KMS Support: Enabled" stop-seaweedfs: @echo "$(YELLOW)Stopping SeaweedFS server...$(NC)" @# Use port-based cleanup for consistency and safety + @if [ -f /tmp/weed-mini.pid ]; then \ + echo "Stopping weed mini..."; \ + kill $$(cat /tmp/weed-mini.pid) || true; \ + rm -f /tmp/weed-mini.pid; \ + fi @lsof -ti :$(MASTER_PORT) | xargs -r kill -TERM || true @lsof -ti :$(VOLUME_PORT) | xargs -r kill -TERM || true @lsof -ti :$(FILER_PORT) | xargs -r kill -TERM || true @@ -345,71 +326,33 @@ start-seaweedfs-ci: check-binary @echo "$(YELLOW)Starting SeaweedFS server for CI testing...$(NC)" # Create necessary directories - @mkdir -p /tmp/seaweedfs-test-sse-master - @mkdir -p /tmp/seaweedfs-test-sse-volume - @mkdir -p /tmp/seaweedfs-test-sse-filer + @mkdir -p /tmp/seaweedfs-test-sse # Clean up any old server logs @rm -f /tmp/seaweedfs-sse-*.log || true - # Start master server with volume size limit and explicit gRPC port - @echo "Starting master server..." - @nohup $(SEAWEEDFS_BINARY) master -port=$(MASTER_PORT) -port.grpc=$$(( $(MASTER_PORT) + 10000 )) -mdir=/tmp/seaweedfs-test-sse-master -volumeSizeLimitMB=$(VOLUME_MAX_SIZE_MB) -ip=127.0.0.1 -peers=none > /tmp/seaweedfs-sse-master.log 2>&1 & - @sleep 3 - - # Start volume server with master HTTP port and increased capacity - @echo "Starting volume server..." - @nohup $(SEAWEEDFS_BINARY) volume -port=$(VOLUME_PORT) -master=127.0.0.1:$(MASTER_PORT) -dir=/tmp/seaweedfs-test-sse-volume -max=$(VOLUME_MAX_COUNT) -ip=127.0.0.1 > /tmp/seaweedfs-sse-volume.log 2>&1 & - @sleep 5 - # Create S3 JSON configuration with KMS (Local provider) and basic identity for embedded S3 @sed -e 's/ACCESS_KEY_PLACEHOLDER/$(ACCESS_KEY)/g' \ -e 's/SECRET_KEY_PLACEHOLDER/$(SECRET_KEY)/g' \ s3-config-template.json > /tmp/seaweedfs-s3.json - # Start filer server with embedded S3 using the JSON config (with verbose logging) - @echo "Starting filer server with embedded S3..." - @AWS_ACCESS_KEY_ID=$(ACCESS_KEY) AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) GLOG_v=4 nohup $(SEAWEEDFS_BINARY) filer -port=$(FILER_PORT) -port.grpc=$$(( $(FILER_PORT) + 10000 )) -master=127.0.0.1:$(MASTER_PORT) -dataCenter=defaultDataCenter -ip=127.0.0.1 -s3 -s3.port=$(S3_PORT) -s3.config=/tmp/seaweedfs-s3.json > /tmp/seaweedfs-sse-filer.log 2>&1 & - @sleep 5 + # Start weed mini with embedded S3 using the JSON config (with verbose logging) + @echo "Starting weed mini with embedded S3..." + @AWS_ACCESS_KEY_ID=$(ACCESS_KEY) AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) GLOG_v=4 $(SEAWEEDFS_BINARY) mini \ + -dir=/tmp/seaweedfs-test-sse \ + -s3.port=$(S3_PORT) \ + -s3.config=/tmp/seaweedfs-s3.json \ + -ip=127.0.0.1 \ + > /tmp/seaweedfs-sse-mini.log 2>&1 & echo $$! > /tmp/weed-mini.pid - # Wait for S3 service to be ready - use port-based checking for reliability - @echo "$(YELLOW)Waiting for S3 service to be ready...$(NC)" - @for i in $$(seq 1 20); do \ - if netstat -an 2>/dev/null | grep -q ":$(S3_PORT).*LISTEN" || \ - ss -an 2>/dev/null | grep -q ":$(S3_PORT).*LISTEN" || \ - lsof -i :$(S3_PORT) >/dev/null 2>&1; then \ - echo "$(GREEN)S3 service is listening on port $(S3_PORT)$(NC)"; \ - sleep 1; \ + @echo "Checking S3 service is ready..." + @for i in $$(seq 1 30); do \ + if curl -s http://127.0.0.1:$(S3_PORT) > /dev/null 2>&1; then \ + echo "✅ S3 service is ready"; \ break; \ fi; \ - if [ $$i -eq 20 ]; then \ - echo "$(RED)S3 service failed to start within 20 seconds$(NC)"; \ - echo "=== Detailed Logs ==="; \ - echo "Master log:"; tail -30 /tmp/seaweedfs-sse-master.log || true; \ - echo "Volume log:"; tail -30 /tmp/seaweedfs-sse-volume.log || true; \ - echo "Filer log:"; tail -30 /tmp/seaweedfs-sse-filer.log || true; \ - echo "=== Port Status ==="; \ - netstat -an 2>/dev/null | grep ":$(S3_PORT)" || \ - ss -an 2>/dev/null | grep ":$(S3_PORT)" || \ - echo "No port listening on $(S3_PORT)"; \ - echo "=== Process Status ==="; \ - ps aux | grep -E "weed.*(filer|s3).*$(S3_PORT)" | grep -v grep || echo "No S3 process found"; \ - exit 1; \ - fi; \ - echo "Waiting for S3 service... ($$i/20)"; \ sleep 1; \ done - - # Additional wait for filer gRPC to be ready - @echo "$(YELLOW)Waiting for filer gRPC to be ready...$(NC)" - @sleep 2 - @echo "$(GREEN)SeaweedFS server started successfully for SSE testing$(NC)" - @echo "Master: http://localhost:$(MASTER_PORT)" - @echo "Volume: http://localhost:$(VOLUME_PORT)" - @echo "Filer: http://localhost:$(FILER_PORT)" - @echo "S3: http://localhost:$(S3_PORT)" - @echo "Volume Max Size: $(VOLUME_MAX_SIZE_MB)MB" - @echo "SSE-KMS Support: Enabled" # GitHub Actions compatible quick test subset test-quick-with-server: build-weed diff --git a/test/s3/tagging/Makefile b/test/s3/tagging/Makefile index c495d1a40..0ae7b1518 100644 --- a/test/s3/tagging/Makefile +++ b/test/s3/tagging/Makefile @@ -72,41 +72,17 @@ start-server: check-deps 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 -filer -filer.maxMB=64 -s3 -ip.bind 0.0.0.0 -dir=./test-volume-data -master.raftHashicorp -master.electionTimeout 1s -master.volumeSizeLimitMB=100 -volume.max=100 -volume.preStopSeconds=1 -master.port=$(MASTER_PORT) -volume.port=$(VOLUME_PORT) -filer.port=$(FILER_PORT) -s3.port=$(S3_PORT) -metricsPort=9329 -s3.allowDeleteBucketNotEmpty=true -s3.config=../../../docker/compose/s3.json -master.peers=none" - @$(WEED_BINARY) server \ - -filer \ + @echo "🔍 DEBUG: Command: $(WEED_BINARY) mini -dir=./test-volume-data -s3.port=$(S3_PORT)" + @$(WEED_BINARY) mini \ -filer.maxMB=64 \ - -s3 \ - -ip.bind 0.0.0.0 \ -dir=./test-volume-data \ -master.raftHashicorp \ - -master.electionTimeout 1s \ - -master.volumeSizeLimitMB=100 \ - -volume.max=100 \ - -volume.preStopSeconds=1 \ - -master.port=$(MASTER_PORT) \ - -volume.port=$(VOLUME_PORT) \ - -filer.port=$(FILER_PORT) \ -s3.port=$(S3_PORT) \ - -metricsPort=9329 \ - -s3.allowDeleteBucketNotEmpty=true \ - -s3.config=../../../docker/compose/s3.json \ - -master.peers=none \ - > 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 + > weed-test.log 2>&1 & \ + echo $$! > weed-server.pid @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)"; \ @@ -125,8 +101,6 @@ start-server: check-deps 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 $(MASTER_PORT) || echo "Port $(MASTER_PORT) not bound"; \ - netstat -tlnp 2>/dev/null | grep $(VOLUME_PORT) || echo "Port $(VOLUME_PORT) not bound"; \ fi; \ if [ $$i -eq 30 ]; then \ echo "⚠️ Server taking longer than expected (30s), checking logs..."; \ @@ -141,7 +115,7 @@ start-server: check-deps 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 "($(S3_PORT)|$(MASTER_PORT)|$(VOLUME_PORT))" || echo "No ports bound"; \ + netstat -tlnp 2>/dev/null | grep -E "($(S3_PORT))" || echo "No ports bound"; \ echo "=== Full server logs ==="; \ if [ -f weed-test.log ]; then \ cat weed-test.log; \ diff --git a/test/s3/versioning/Makefile b/test/s3/versioning/Makefile index 08a18fd96..7e939f90e 100644 --- a/test/s3/versioning/Makefile +++ b/test/s3/versioning/Makefile @@ -43,13 +43,13 @@ build-weed: @echo "Building SeaweedFS binary..." @cd ../../../weed && go build -o weed_binary . @chmod +x $(WEED_BINARY) - @echo "✅ SeaweedFS binary built at $(WEED_BINARY)" + @echo "OK SeaweedFS binary built at $(WEED_BINARY)" check-deps: build-weed @echo "Checking dependencies..." - @echo "🔍 DEBUG: Checking Go installation..." + @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: 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}')" @@ -57,7 +57,7 @@ check-deps: build-weed @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" + @echo "OK All dependencies are available" # Start SeaweedFS server for testing start-server: check-deps @@ -81,21 +81,11 @@ start-server: check-deps @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.allowDeleteBucketNotEmpty=true -s3.config=../../../docker/compose/s3.json -filer -filer.maxMB=64 -master.volumeSizeLimitMB=50 -master.peers=none -volume.max=100 -dir=./test-volume-data -volume.preStopSeconds=1 -metricsPort=9324" - @$(WEED_BINARY) server \ - -debug \ - -s3 \ - -s3.port=$(S3_PORT) \ - -s3.allowDeleteBucketNotEmpty=true \ - -s3.config=../../../docker/compose/s3.json \ - -filer \ - -filer.maxMB=64 \ - -master.volumeSizeLimitMB=50 \ - -master.peers=none \ - -volume.max=100 \ + @echo "🔍 DEBUG: Command: $(WEED_BINARY) mini -dir=./test-volume-data -s3.port=$(S3_PORT) -s3.config=../../../docker/compose/s3.json" + @$(WEED_BINARY) mini \ -dir=./test-volume-data \ - -volume.preStopSeconds=1 \ - -metricsPort=9324 \ + -s3.port=$(S3_PORT) \ + -s3.config=../../../docker/compose/s3.json \ > 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..." @@ -222,13 +212,13 @@ test-with-server: start-server test-versioning-with-configs: check-deps @echo "Testing with different S3 configurations..." @echo "Testing with empty folder allowed..." - @$(WEED_BINARY) server -s3 -s3.port=$(S3_PORT) -filer -master.volumeSizeLimitMB=100 -master.peers=none -volume.max=100 > weed-test-config1.log 2>&1 & echo $$! > weed-config1.pid + @$(WEED_BINARY) mini -s3.port=$(S3_PORT) > weed-test-config1.log 2>&1 & echo $$! > weed-config1.pid @sleep 5 @go test -v -timeout=5m -run "TestVersioningBasicWorkflow" . || true @if [ -f weed-config1.pid ]; then kill -TERM $$(cat weed-config1.pid) 2>/dev/null || true; rm -f weed-config1.pid; fi @sleep 2 @echo "Testing with delete bucket not empty disabled..." - @$(WEED_BINARY) server -s3 -s3.port=$(S3_PORT) -s3.allowDeleteBucketNotEmpty=false -filer -master.volumeSizeLimitMB=100 -master.peers=none -volume.max=100 > weed-test-config2.log 2>&1 & echo $$! > weed-config2.pid + @$(WEED_BINARY) mini -s3.port=$(S3_PORT) -s3.allowDeleteBucketNotEmpty=false > weed-test-config2.log 2>&1 & echo $$! > weed-config2.pid @sleep 5 @go test -v -timeout=5m -run "TestVersioningBasicWorkflow" . || true @if [ -f weed-config2.pid ]; then kill -TERM $$(cat weed-config2.pid) 2>/dev/null || true; rm -f weed-config2.pid; fi @@ -271,19 +261,14 @@ debug-server: @echo "Starting SeaweedFS server in debug mode..." @$(MAKE) stop-server @mkdir -p ./test-volume-data - @$(WEED_BINARY) server \ + @$(WEED_BINARY) mini \ -debug \ - -s3 \ -s3.port=$(S3_PORT) \ -s3.allowDeleteBucketNotEmpty=true \ -s3.config=../../../docker/compose/s3.json \ - -filer \ -filer.maxMB=16 \ - -master.volumeSizeLimitMB=50 \ -master.peers=none \ - -volume.max=100 \ -dir=./test-volume-data \ - -volume.preStopSeconds=1 \ -metricsPort=9324 # Run a single test for debugging @@ -320,24 +305,24 @@ health-check: # Simple server start without process cleanup (for CI troubleshooting) start-server-simple: check-deps @echo "Starting SeaweedFS server (simple mode)..." - @$(WEED_BINARY) server \ - -debug \ + @$(WEED_BINARY) mini \ + -dir=$(SERVER_DIR) \ -s3 \ -s3.port=$(S3_PORT) \ - -s3.allowDeleteBucketNotEmpty=true \ - -s3.config=../../../docker/compose/s3.json \ - -filer \ - -filer.maxMB=64 \ - -master.volumeSizeLimitMB=50 \ - -master.peers=none \ - -volume.max=100 \ - -volume.preStopSeconds=1 \ - -metricsPort=9324 \ - > weed-test.log 2>&1 & echo $$! > weed-server.pid - @echo "Server PID: $$(cat weed-server.pid)" - @echo "Waiting for server to start..." - @sleep 10 - @curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1 && echo "✅ Server started successfully" || echo "❌ Server failed to start" + -s3.config=$(S3_CONFIG) \ + > weed-server.log 2>&1 & \ + echo $$! > weed-server.pid + + @echo "Waiting for S3 server to be ready..." + @for i in $$(seq 1 30); do \ + if echo | nc -z localhost $(S3_PORT); then \ + echo "S3 server is ready!"; \ + exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo "S3 server failed to start"; \ + exit 1 # Simple test run without server management test-versioning-simple: check-deps diff --git a/weed/command/mini.go b/weed/command/mini.go index 3fcfbc6d4..17430e916 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -1062,9 +1062,7 @@ func startMiniWorker() { // Set admin client workerInstance.SetAdminClient(adminClient) - // Start metrics server for health checks and monitoring (uses shared metrics port like other services) - // This allows Kubernetes probes to check worker health via /health endpoint - go stats_collect.StartMetricsServer(*miniMetricsHttpIp, *miniMetricsHttpPort) + // Metrics server is already started in the main init function above, so no need to start it again here // Start the worker err = workerInstance.Start() From e439e33888c8b643c0351448a6a932bd3bfe5d88 Mon Sep 17 00:00:00 2001 From: "steve.wei" Date: Fri, 26 Dec 2025 03:28:31 +0800 Subject: [PATCH 29/66] fix(filer): check error from FindEntry (#7878) * fix(filer): check error from FindEntry * remove --------- Co-authored-by: Chris Lu --- weed/filer/filer.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/weed/filer/filer.go b/weed/filer/filer.go index 382eb644f..b3114d112 100644 --- a/weed/filer/filer.go +++ b/weed/filer/filer.go @@ -2,6 +2,7 @@ package filer import ( "context" + "errors" "fmt" "os" "sort" @@ -262,7 +263,10 @@ func (f *Filer) ensureParentDirectoryEntry(ctx context.Context, entry *Entry, di // check the store directly glog.V(4).InfofCtx(ctx, "find uncached directory: %s", dirPath) - dirEntry, _ := f.FindEntry(ctx, util.FullPath(dirPath)) + dirEntry, findErr := f.FindEntry(ctx, util.FullPath(dirPath)) + if findErr != nil && !errors.Is(findErr, filer_pb.ErrNotFound) { + return findErr + } // no such existing directory if dirEntry == nil { From e8a41ec053b6a7a634ed49533bce845b5fd329c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E5=A4=A9=E9=A3=9E=E9=95=9C?= <42763234+yuntianfeijing@users.noreply.github.com> Date: Fri, 26 Dec 2025 03:36:38 +0800 Subject: [PATCH 30/66] Fix the issue where fuse command on a node cannot specify multiple configuration directory paths (#7874) Changes: Modified weed/command/fuse.go to add a function GetFuseCommandName to return the name of the fuse command. Modified weed/weed.go to conditionally initialize the global HTTP client only if the command is not "fuse". Modified weed/command/fuse_std.go to parse parameters and ensure the global HTTP client is initialized for the fuse command. Tests: Use /etc/fstab like: fuse /repos fuse.weed filer=192.168.1.101:7202,filer.path=/hpc/repos,config_dir=/etc/seaweedfs/seaweedfs_01 0 0 fuse /opt/ohpc/pub fuse.weed filer=192.168.1.102:7202,filer.path=/hpc_cluster/pub,config_dir=/etc/seaweedfs/seaweedfs_02 0 0 Co-authored-by: zhangxl56 --- weed/command/fuse.go | 4 ++++ weed/command/fuse_std.go | 7 +++++++ weed/weed.go | 5 +++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/weed/command/fuse.go b/weed/command/fuse.go index a3b7fb81e..6a6dd8d5d 100644 --- a/weed/command/fuse.go +++ b/weed/command/fuse.go @@ -28,3 +28,7 @@ var cmdFuse = &Command{ To check valid options look "weed mount --help" `, } + +func GetFuseCommandName() string { + return cmdFuse.Name() +} diff --git a/weed/command/fuse_std.go b/weed/command/fuse_std.go index 2cc6fa8ab..bd274f651 100644 --- a/weed/command/fuse_std.go +++ b/weed/command/fuse_std.go @@ -12,6 +12,9 @@ import ( "strings" "syscall" "time" + + "github.com/seaweedfs/seaweedfs/weed/util" + util_http "github.com/seaweedfs/seaweedfs/weed/util/http" ) type parameter struct { @@ -219,6 +222,8 @@ func runFuse(cmd *Command, args []string) bool { } case "fusermount.path": fusermountPath = parameter.value + case "config_dir": + util.ConfigurationFileDirectory.Set(parameter.value) default: t := parameter.name if parameter.value != "true" { @@ -228,6 +233,8 @@ func runFuse(cmd *Command, args []string) bool { } } + util_http.InitGlobalHttpClient() + // the master start the child, release it then finish himself if masterProcess { arg0, err := os.Executable() diff --git a/weed/weed.go b/weed/weed.go index cde071179..f940cdacd 100644 --- a/weed/weed.go +++ b/weed/weed.go @@ -85,8 +85,9 @@ func main() { } return } - - util_http.InitGlobalHttpClient() + if args[0] != command.GetFuseCommandName() { + util_http.InitGlobalHttpClient() + } for _, cmd := range commands { if cmd.Name() == args[0] && cmd.Run != nil { cmd.Flag.Usage = func() { cmd.Usage() } From 225e3d0302967b92b9e8adc15bcb0d3b190eb388 Mon Sep 17 00:00:00 2001 From: Deyu Han Date: Thu, 25 Dec 2025 13:18:16 -0800 Subject: [PATCH 31/66] Add read only user (#7862) * add readonly user * add args * address comments * avoid same user name * Prevents timing attacks * doc --------- Co-authored-by: Chris Lu --- weed/admin/Makefile | 4 +- weed/admin/dash/auth_middleware.go | 19 ++++++++- weed/admin/dash/middleware.go | 51 ++++++++++++++++++++-- weed/admin/handlers/admin_handlers.go | 61 +++++++++++++++------------ weed/admin/handlers/auth_handlers.go | 13 +++++- weed/command/admin.go | 52 ++++++++++++++++++----- weed/command/mini.go | 21 ++++++++- 7 files changed, 171 insertions(+), 50 deletions(-) diff --git a/weed/admin/Makefile b/weed/admin/Makefile index b79ddc1ab..605545d3b 100644 --- a/weed/admin/Makefile +++ b/weed/admin/Makefile @@ -160,6 +160,4 @@ $(WEED_BINARY): $(TEMPL_GO_FILES) $(GO_FILES) # Auto-generate templ files when .templ files change %_templ.go: %.templ @echo "Regenerating $@ from $<" - @templ generate - -.PHONY: $(TEMPL_GO_FILES) \ No newline at end of file + @templ generate diff --git a/weed/admin/dash/auth_middleware.go b/weed/admin/dash/auth_middleware.go index 87da65659..5da81481a 100644 --- a/weed/admin/dash/auth_middleware.go +++ b/weed/admin/dash/auth_middleware.go @@ -1,6 +1,7 @@ package dash import ( + "crypto/subtle" "net/http" "github.com/gin-contrib/sessions" @@ -25,17 +26,31 @@ func (s *AdminServer) ShowLogin(c *gin.Context) { } // HandleLogin handles login form submission -func (s *AdminServer) HandleLogin(username, password string) gin.HandlerFunc { +func (s *AdminServer) HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword string) gin.HandlerFunc { return func(c *gin.Context) { loginUsername := c.PostForm("username") loginPassword := c.PostForm("password") - if loginUsername == username && loginPassword == password { + var role string + var authenticated bool + + // Check admin credentials + if adminPassword != "" && loginUsername == adminUser && subtle.ConstantTimeCompare([]byte(loginPassword), []byte(adminPassword)) == 1 { + role = "admin" + authenticated = true + } else if readOnlyPassword != "" && loginUsername == readOnlyUser && subtle.ConstantTimeCompare([]byte(loginPassword), []byte(readOnlyPassword)) == 1 { + // Check read-only credentials + role = "readonly" + authenticated = true + } + + if authenticated { session := sessions.Default(c) // Clear any existing invalid session data before setting new values session.Clear() session.Set("authenticated", true) session.Set("username", loginUsername) + session.Set("role", role) if err := session.Save(); err != nil { // Log the detailed error server-side for diagnostics glog.Errorf("Failed to save session for user %s: %v", loginUsername, err) diff --git a/weed/admin/dash/middleware.go b/weed/admin/dash/middleware.go index a4cfedfd0..98f3c3d7f 100644 --- a/weed/admin/dash/middleware.go +++ b/weed/admin/dash/middleware.go @@ -2,17 +2,30 @@ package dash import ( "net/http" + "strings" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) +// setAuthContext sets username and role in context for use in handlers +func setAuthContext(c *gin.Context, username, role interface{}) { + c.Set("username", username) + if role != nil { + c.Set("role", role) + } else { + // Default to admin for backward compatibility + c.Set("role", "admin") + } +} + // RequireAuth checks if user is authenticated func RequireAuth() gin.HandlerFunc { return func(c *gin.Context) { session := sessions.Default(c) authenticated := session.Get("authenticated") username := session.Get("username") + role := session.Get("role") if authenticated != true || username == nil { c.Redirect(http.StatusTemporaryRedirect, "/login") @@ -20,8 +33,8 @@ func RequireAuth() gin.HandlerFunc { return } - // Set username in context for use in handlers - c.Set("username", username) + // Set username and role in context for use in handlers + setAuthContext(c, username, role) c.Next() } } @@ -33,6 +46,7 @@ func RequireAuthAPI() gin.HandlerFunc { session := sessions.Default(c) authenticated := session.Get("authenticated") username := session.Get("username") + role := session.Get("role") if authenticated != true || username == nil { c.JSON(http.StatusUnauthorized, gin.H{ @@ -43,8 +57,37 @@ func RequireAuthAPI() gin.HandlerFunc { return } - // Set username in context for use in handlers - c.Set("username", username) + // Set username and role in context for use in handlers + setAuthContext(c, username, role) + c.Next() + } +} + +// RequireWriteAccess checks if user has admin role (write access) +// Returns JSON error for API endpoints, redirects for HTML endpoints +func RequireWriteAccess() gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists { + role = "admin" // Default for backward compatibility + } + + roleStr, ok := role.(string) + if !ok || roleStr != "admin" { + // Check if this is an API request (path starts with /api) or HTML request + path := c.Request.URL.Path + if strings.HasPrefix(path, "/api") { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Insufficient permissions", + "message": "This operation requires admin access. Read-only users can only view data.", + }) + } else { + c.Redirect(http.StatusSeeOther, "/admin?error=Insufficient permissions") + } + c.Abort() + return + } + c.Next() } } diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index 31fa08113..5bf4c6a5e 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -5,9 +5,11 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/view/app" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" + "github.com/seaweedfs/seaweedfs/weed/stats" ) // AdminHandlers contains all the HTTP handlers for the admin interface @@ -44,10 +46,13 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers { } // SetupRoutes configures all the routes for the admin interface -func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, password string) { +func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, adminPassword, readOnlyUser, readOnlyPassword string) { // Health check (no auth required) r.GET("/health", h.HealthCheck) + // Prometheus metrics endpoint (no auth required) + r.GET("/metrics", gin.WrapH(promhttp.HandlerFor(stats.Gather, promhttp.HandlerOpts{}))) + // Favicon route (no auth required) - redirect to static version r.GET("/favicon.ico", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/static/favicon.ico") @@ -56,7 +61,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, if authRequired { // Authentication routes (no auth required) r.GET("/login", h.authHandlers.ShowLogin) - r.POST("/login", h.authHandlers.HandleLogin(username, password)) + r.POST("/login", h.authHandlers.HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword)) r.GET("/logout", h.authHandlers.HandleLogout) // Protected routes group @@ -96,9 +101,9 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, protected.GET("/maintenance", h.maintenanceHandlers.ShowMaintenanceQueue) protected.GET("/maintenance/workers", h.maintenanceHandlers.ShowMaintenanceWorkers) protected.GET("/maintenance/config", h.maintenanceHandlers.ShowMaintenanceConfig) - protected.POST("/maintenance/config", h.maintenanceHandlers.UpdateMaintenanceConfig) + protected.POST("/maintenance/config", dash.RequireWriteAccess(), h.maintenanceHandlers.UpdateMaintenanceConfig) protected.GET("/maintenance/config/:taskType", h.maintenanceHandlers.ShowTaskConfig) - protected.POST("/maintenance/config/:taskType", h.maintenanceHandlers.UpdateTaskConfig) + protected.POST("/maintenance/config/:taskType", dash.RequireWriteAccess(), h.maintenanceHandlers.UpdateTaskConfig) protected.GET("/maintenance/tasks/:id", h.maintenanceHandlers.ShowTaskDetail) // API routes for AJAX calls @@ -115,45 +120,45 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, s3Api := api.Group("/s3") { s3Api.GET("/buckets", h.adminServer.ListBucketsAPI) - s3Api.POST("/buckets", h.adminServer.CreateBucket) - s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket) + s3Api.POST("/buckets", dash.RequireWriteAccess(), h.adminServer.CreateBucket) + s3Api.DELETE("/buckets/:bucket", dash.RequireWriteAccess(), h.adminServer.DeleteBucket) s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails) - s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota) - s3Api.PUT("/buckets/:bucket/owner", h.adminServer.UpdateBucketOwner) + s3Api.PUT("/buckets/:bucket/quota", dash.RequireWriteAccess(), h.adminServer.UpdateBucketQuota) + s3Api.PUT("/buckets/:bucket/owner", dash.RequireWriteAccess(), h.adminServer.UpdateBucketOwner) } // User management API routes usersApi := api.Group("/users") { usersApi.GET("", h.userHandlers.GetUsers) - usersApi.POST("", h.userHandlers.CreateUser) + usersApi.POST("", dash.RequireWriteAccess(), h.userHandlers.CreateUser) usersApi.GET("/:username", h.userHandlers.GetUserDetails) - usersApi.PUT("/:username", h.userHandlers.UpdateUser) - usersApi.DELETE("/:username", h.userHandlers.DeleteUser) - usersApi.POST("/:username/access-keys", h.userHandlers.CreateAccessKey) - usersApi.DELETE("/:username/access-keys/:accessKeyId", h.userHandlers.DeleteAccessKey) + usersApi.PUT("/:username", dash.RequireWriteAccess(), h.userHandlers.UpdateUser) + usersApi.DELETE("/:username", dash.RequireWriteAccess(), h.userHandlers.DeleteUser) + usersApi.POST("/:username/access-keys", dash.RequireWriteAccess(), h.userHandlers.CreateAccessKey) + usersApi.DELETE("/:username/access-keys/:accessKeyId", dash.RequireWriteAccess(), h.userHandlers.DeleteAccessKey) usersApi.GET("/:username/policies", h.userHandlers.GetUserPolicies) - usersApi.PUT("/:username/policies", h.userHandlers.UpdateUserPolicies) + usersApi.PUT("/:username/policies", dash.RequireWriteAccess(), h.userHandlers.UpdateUserPolicies) } // Object Store Policy management API routes objectStorePoliciesApi := api.Group("/object-store/policies") { objectStorePoliciesApi.GET("", h.policyHandlers.GetPolicies) - objectStorePoliciesApi.POST("", h.policyHandlers.CreatePolicy) + objectStorePoliciesApi.POST("", dash.RequireWriteAccess(), h.policyHandlers.CreatePolicy) objectStorePoliciesApi.GET("/:name", h.policyHandlers.GetPolicy) - objectStorePoliciesApi.PUT("/:name", h.policyHandlers.UpdatePolicy) - objectStorePoliciesApi.DELETE("/:name", h.policyHandlers.DeletePolicy) + objectStorePoliciesApi.PUT("/:name", dash.RequireWriteAccess(), h.policyHandlers.UpdatePolicy) + objectStorePoliciesApi.DELETE("/:name", dash.RequireWriteAccess(), h.policyHandlers.DeletePolicy) objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy) } // File management API routes filesApi := api.Group("/files") { - filesApi.DELETE("/delete", h.fileBrowserHandlers.DeleteFile) - filesApi.DELETE("/delete-multiple", h.fileBrowserHandlers.DeleteMultipleFiles) - filesApi.POST("/create-folder", h.fileBrowserHandlers.CreateFolder) - filesApi.POST("/upload", h.fileBrowserHandlers.UploadFile) + filesApi.DELETE("/delete", dash.RequireWriteAccess(), h.fileBrowserHandlers.DeleteFile) + filesApi.DELETE("/delete-multiple", dash.RequireWriteAccess(), h.fileBrowserHandlers.DeleteMultipleFiles) + filesApi.POST("/create-folder", dash.RequireWriteAccess(), h.fileBrowserHandlers.CreateFolder) + filesApi.POST("/upload", dash.RequireWriteAccess(), h.fileBrowserHandlers.UploadFile) filesApi.GET("/download", h.fileBrowserHandlers.DownloadFile) filesApi.GET("/view", h.fileBrowserHandlers.ViewFile) filesApi.GET("/properties", h.fileBrowserHandlers.GetFileProperties) @@ -162,32 +167,32 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, // Volume management API routes volumeApi := api.Group("/volumes") { - volumeApi.POST("/:id/:server/vacuum", h.clusterHandlers.VacuumVolume) + volumeApi.POST("/:id/:server/vacuum", dash.RequireWriteAccess(), h.clusterHandlers.VacuumVolume) } // Maintenance API routes maintenanceApi := api.Group("/maintenance") { - maintenanceApi.POST("/scan", h.adminServer.TriggerMaintenanceScan) + maintenanceApi.POST("/scan", dash.RequireWriteAccess(), h.adminServer.TriggerMaintenanceScan) maintenanceApi.GET("/tasks", h.adminServer.GetMaintenanceTasks) maintenanceApi.GET("/tasks/:id", h.adminServer.GetMaintenanceTask) maintenanceApi.GET("/tasks/:id/detail", h.adminServer.GetMaintenanceTaskDetailAPI) - maintenanceApi.POST("/tasks/:id/cancel", h.adminServer.CancelMaintenanceTask) + maintenanceApi.POST("/tasks/:id/cancel", dash.RequireWriteAccess(), h.adminServer.CancelMaintenanceTask) maintenanceApi.GET("/workers", h.adminServer.GetMaintenanceWorkersAPI) maintenanceApi.GET("/workers/:id", h.adminServer.GetMaintenanceWorker) maintenanceApi.GET("/workers/:id/logs", h.adminServer.GetWorkerLogs) maintenanceApi.GET("/stats", h.adminServer.GetMaintenanceStats) maintenanceApi.GET("/config", h.adminServer.GetMaintenanceConfigAPI) - maintenanceApi.PUT("/config", h.adminServer.UpdateMaintenanceConfigAPI) + maintenanceApi.PUT("/config", dash.RequireWriteAccess(), h.adminServer.UpdateMaintenanceConfigAPI) } // Message Queue API routes mqApi := api.Group("/mq") { mqApi.GET("/topics/:namespace/:topic", h.mqHandlers.GetTopicDetailsAPI) - mqApi.POST("/topics/create", h.mqHandlers.CreateTopicAPI) - mqApi.POST("/topics/retention/update", h.mqHandlers.UpdateTopicRetentionAPI) - mqApi.POST("/retention/purge", h.adminServer.TriggerTopicRetentionPurgeAPI) + mqApi.POST("/topics/create", dash.RequireWriteAccess(), h.mqHandlers.CreateTopicAPI) + mqApi.POST("/topics/retention/update", dash.RequireWriteAccess(), h.mqHandlers.UpdateTopicRetentionAPI) + mqApi.POST("/retention/purge", dash.RequireWriteAccess(), h.adminServer.TriggerTopicRetentionPurgeAPI) } } } else { diff --git a/weed/admin/handlers/auth_handlers.go b/weed/admin/handlers/auth_handlers.go index 07596b8e4..ff6f6250e 100644 --- a/weed/admin/handlers/auth_handlers.go +++ b/weed/admin/handlers/auth_handlers.go @@ -3,6 +3,7 @@ package handlers import ( "net/http" + "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" @@ -22,6 +23,14 @@ func NewAuthHandlers(adminServer *dash.AdminServer) *AuthHandlers { // ShowLogin displays the login page func (a *AuthHandlers) ShowLogin(c *gin.Context) { + session := sessions.Default(c) + + // If already authenticated, redirect to admin + if session.Get("authenticated") == true { + c.Redirect(http.StatusSeeOther, "/admin") + return + } + errorMessage := c.Query("error") // Render login template @@ -35,8 +44,8 @@ func (a *AuthHandlers) ShowLogin(c *gin.Context) { } // HandleLogin handles login form submission -func (a *AuthHandlers) HandleLogin(username, password string) gin.HandlerFunc { - return a.adminServer.HandleLogin(username, password) +func (a *AuthHandlers) HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword string) gin.HandlerFunc { + return a.adminServer.HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword) } // HandleLogout handles user logout diff --git a/weed/command/admin.go b/weed/command/admin.go index c8a4a4b12..f07b76780 100644 --- a/weed/command/admin.go +++ b/weed/command/admin.go @@ -33,13 +33,15 @@ var ( ) type AdminOptions struct { - port *int - grpcPort *int - master *string - masters *string // deprecated, for backward compatibility - adminUser *string - adminPassword *string - dataDir *string + port *int + grpcPort *int + master *string + masters *string // deprecated, for backward compatibility + adminUser *string + adminPassword *string + readOnlyUser *string + readOnlyPassword *string + dataDir *string } func init() { @@ -52,6 +54,8 @@ func init() { a.adminUser = cmdAdmin.Flag.String("adminUser", "admin", "admin interface username") a.adminPassword = cmdAdmin.Flag.String("adminPassword", "", "admin interface password (if empty, auth is disabled)") + a.readOnlyUser = cmdAdmin.Flag.String("readOnlyUser", "", "read-only user username (optional, for view-only access)") + a.readOnlyPassword = cmdAdmin.Flag.String("readOnlyPassword", "", "read-only user password (optional, for view-only access; requires adminPassword to be set)") } var cmdAdmin = &Command{ @@ -84,7 +88,11 @@ var cmdAdmin = &Command{ Authentication: - If adminPassword is not set, the admin interface runs without authentication - - If adminPassword is set, users must login with adminUser/adminPassword + - If adminPassword is set, users must login with adminUser/adminPassword (full access) + - Optional read-only access: set readOnlyUser and readOnlyPassword for view-only access + - Read-only users can view cluster status and configurations but cannot make changes + - IMPORTANT: When read-only credentials are configured, adminPassword MUST also be set + - This ensures an admin account exists to manage and authorize read-only access - Sessions are secured with auto-generated session keys Security Configuration: @@ -139,6 +147,26 @@ func runAdmin(cmd *Command, args []string) bool { return false } + // Security validation: prevent empty username when password is set + if *a.adminPassword != "" && *a.adminUser == "" { + fmt.Println("Error: -adminUser cannot be empty when -adminPassword is set") + return false + } + if *a.readOnlyPassword != "" && *a.readOnlyUser == "" { + fmt.Println("Error: -readOnlyUser is required when -readOnlyPassword is set") + return false + } + // Security validation: prevent username conflicts between admin and read-only users + if *a.adminUser != "" && *a.readOnlyUser != "" && *a.adminUser == *a.readOnlyUser { + fmt.Println("Error: -adminUser and -readOnlyUser must be different when both are configured") + return false + } + // Security validation: admin password is required for read-only user + if *a.readOnlyPassword != "" && *a.adminPassword == "" { + fmt.Println("Error: -adminPassword must be set when -readOnlyPassword is configured") + return false + } + // Set default gRPC port if not specified if *a.grpcPort == 0 { *a.grpcPort = *a.port + 10000 @@ -160,7 +188,10 @@ func runAdmin(cmd *Command, args []string) bool { fmt.Printf("Data Directory: Not specified (configuration will be in-memory only)\n") } if *a.adminPassword != "" { - fmt.Printf("Authentication: Enabled (user: %s)\n", *a.adminUser) + fmt.Printf("Authentication: Enabled (admin user: %s)\n", *a.adminUser) + if *a.readOnlyPassword != "" { + fmt.Printf("Read-only access: Enabled (read-only user: %s)\n", *a.readOnlyUser) + } } else { fmt.Printf("Authentication: Disabled\n") } @@ -274,8 +305,9 @@ func startAdminServer(ctx context.Context, options AdminOptions) error { }() // Create handlers and setup routes + authRequired := *options.adminPassword != "" adminHandlers := handlers.NewAdminHandlers(adminServer) - adminHandlers.SetupRoutes(r, *options.adminPassword != "", *options.adminUser, *options.adminPassword) + adminHandlers.SetupRoutes(r, authRequired, *options.adminUser, *options.adminPassword, *options.readOnlyUser, *options.readOnlyPassword) // Server configuration addr := fmt.Sprintf(":%d", *options.port) diff --git a/weed/command/mini.go b/weed/command/mini.go index 17430e916..6aa30acbd 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -72,7 +72,7 @@ This command starts all components in one process (master, volume, filer, S3 gateway, WebDAV gateway, and Admin UI). All settings are optimized for small/dev use cases: -- Volume size limit: 128MB (small files) +- Volume size limit: auto configured based on disk space (64MB-1024MB) - Volume max: 0 (auto-configured based on free disk space) - Pre-stop seconds: 1 (faster shutdown) - Master peers: none (single master mode) @@ -260,6 +260,8 @@ func initMiniAdminFlags() { miniAdminOptions.dataDir = cmdMini.Flag.String("admin.dataDir", "", "directory to store admin configuration and data files") miniAdminOptions.adminUser = cmdMini.Flag.String("admin.user", "admin", "admin interface username") miniAdminOptions.adminPassword = cmdMini.Flag.String("admin.password", "", "admin interface password (if empty, auth is disabled)") + miniAdminOptions.readOnlyUser = cmdMini.Flag.String("admin.readOnlyUser", "", "read-only user username (optional, for view-only access)") + miniAdminOptions.readOnlyPassword = cmdMini.Flag.String("admin.readOnlyPassword", "", "read-only user password (optional, for view-only access; requires admin.password to be set)") } func init() { @@ -921,6 +923,23 @@ func startMiniAdminWithWorker(allServicesReady chan struct{}) { // Set admin options *miniAdminOptions.master = masterAddr + // Security validation: prevent empty username when password is set + if *miniAdminOptions.adminPassword != "" && *miniAdminOptions.adminUser == "" { + glog.Fatalf("Error: -admin.user cannot be empty when -admin.password is set") + } + if *miniAdminOptions.readOnlyPassword != "" && *miniAdminOptions.readOnlyUser == "" { + glog.Fatalf("Error: -admin.readOnlyUser is required when -admin.readOnlyPassword is set") + } + // Security validation: prevent username conflicts between admin and read-only users + if *miniAdminOptions.adminUser != "" && *miniAdminOptions.readOnlyUser != "" && + *miniAdminOptions.adminUser == *miniAdminOptions.readOnlyUser { + glog.Fatalf("Error: -admin.user and -admin.readOnlyUser must be different when both are configured") + } + // Security validation: admin password is required for read-only user + if *miniAdminOptions.readOnlyPassword != "" && *miniAdminOptions.adminPassword == "" { + glog.Fatalf("Error: -admin.password must be set when -admin.readOnlyPassword is configured") + } + // gRPC port should have been initialized by ensureAllPortsAvailableOnIP in runMini // If it's still 0, that indicates a problem with the port initialization sequence if *miniAdminOptions.grpcPort == 0 { From c260e6a22ef0b9c8531cd738f401cdd37dde8ce7 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Thu, 25 Dec 2025 16:14:05 -0800 Subject: [PATCH 32/66] Fix issue #7880: Tasks use Volume IDs instead of ip:port (#7881) * Fix issue #7880: Tasks use Volume IDs instead of ip:port When volume servers are registered with custom IDs, tasks were attempting to connect using the ID instead of the actual ip:port address, causing connection failures. Modified task detection logic in balance, erasure coding, and vacuum tasks to resolve volume server IDs to their actual ip:port addresses using ActiveTopology information. * Use server addresses directly instead of translating from IDs Modified VolumeHealthMetrics to include ServerAddress field populated directly from topology DataNodeInfo.Address. Updated task detection logic to use addresses directly without runtime lookups. Changes: - Added ServerAddress field to VolumeHealthMetrics - Updated maintenance scanner to populate ServerAddress - Modified task detection to use ServerAddress for Node fields - Updated DestinationPlan to include TargetAddress - Removed runtime address lookups in favor of direct address usage * Address PR comments: add ServerAddress field, improve error handling - Add missing ServerAddress field to VolumeHealthMetrics struct - Add warning in vacuum detection when server not found in topology - Improve error handling in erasure coding to abort task if sources missing - Make vacuum task stricter by skipping if server not found in topology * Refactor: Extract common address resolution logic into shared utility - Created weed/worker/tasks/util/address.go with ResolveServerAddress function - Updated balance, erasure_coding, and vacuum detection to use the shared utility - Removed code duplication and improved maintainability - Consistent error handling across all task types * Fix critical issues in task address resolution - Vacuum: Require topology availability and fail if server not found (no fallback to ID) - Ensure all task types consistently fail early when topology is incomplete - Prevent creation of tasks that would fail due to missing server addresses * Address additional PR feedback - Add validation for empty addresses in ResolveServerAddress - Remove redundant serverAddress variable in vacuum detection - Improve robustness of address resolution * Improve error logging in vacuum detection - Include actual error details in log message for better diagnostics - Make error messages consistent with other task types --- weed/admin/maintenance/maintenance_scanner.go | 2 ++ weed/admin/maintenance/maintenance_types.go | 1 + .../maintenance/pending_operations_test.go | 8 ++--- weed/admin/topology/structs.go | 1 + weed/worker/tasks/balance/detection.go | 12 ++++++-- weed/worker/tasks/erasure_coding/detection.go | 30 +++++++++++++++---- weed/worker/tasks/util/address.go | 23 ++++++++++++++ weed/worker/tasks/vacuum/detection.go | 18 +++++++++-- weed/worker/types/data_types.go | 3 +- 9 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 weed/worker/tasks/util/address.go diff --git a/weed/admin/maintenance/maintenance_scanner.go b/weed/admin/maintenance/maintenance_scanner.go index 6f3b46be2..ddbf44f55 100644 --- a/weed/admin/maintenance/maintenance_scanner.go +++ b/weed/admin/maintenance/maintenance_scanner.go @@ -115,6 +115,7 @@ func (ms *MaintenanceScanner) getVolumeHealthMetrics() ([]*VolumeHealthMetrics, metric := &VolumeHealthMetrics{ VolumeID: volInfo.Id, Server: node.Id, + ServerAddress: node.Address, DiskType: diskType, // Track which disk this volume is on DiskId: volInfo.DiskId, // Use disk ID from volume info DataCenter: dc.Id, // Data center from current loop @@ -207,6 +208,7 @@ func (ms *MaintenanceScanner) convertToTaskMetrics(metrics []*VolumeHealthMetric simplified = append(simplified, &types.VolumeHealthMetrics{ VolumeID: metric.VolumeID, Server: metric.Server, + ServerAddress: metric.ServerAddress, DiskType: metric.DiskType, DiskId: metric.DiskId, DataCenter: metric.DataCenter, diff --git a/weed/admin/maintenance/maintenance_types.go b/weed/admin/maintenance/maintenance_types.go index fe5d5fa55..7b7e83818 100644 --- a/weed/admin/maintenance/maintenance_types.go +++ b/weed/admin/maintenance/maintenance_types.go @@ -362,6 +362,7 @@ type TaskDetectionResult struct { type VolumeHealthMetrics struct { VolumeID uint32 `json:"volume_id"` Server string `json:"server"` + ServerAddress string `json:"server_address"` DiskType string `json:"disk_type"` // Disk type (e.g., "hdd", "ssd") or disk path (e.g., "/data1") DiskId uint32 `json:"disk_id"` // ID of the disk in Store.Locations array DataCenter string `json:"data_center"` // Data center of the server diff --git a/weed/admin/maintenance/pending_operations_test.go b/weed/admin/maintenance/pending_operations_test.go index 64bb591fb..75d511e8f 100644 --- a/weed/admin/maintenance/pending_operations_test.go +++ b/weed/admin/maintenance/pending_operations_test.go @@ -110,10 +110,10 @@ func TestPendingOperations_VolumeFiltering(t *testing.T) { // Create volume metrics metrics := []*types.VolumeHealthMetrics{ - {VolumeID: 100, Server: "node1"}, - {VolumeID: 101, Server: "node2"}, - {VolumeID: 102, Server: "node3"}, - {VolumeID: 103, Server: "node1"}, + {VolumeID: 100, Server: "node1", ServerAddress: "192.168.1.1:8080"}, + {VolumeID: 101, Server: "node2", ServerAddress: "192.168.1.2:8080"}, + {VolumeID: 102, Server: "node3", ServerAddress: "192.168.1.3:8080"}, + {VolumeID: 103, Server: "node1", ServerAddress: "192.168.1.1:8080"}, } // Add pending operations on volumes 101 and 103 diff --git a/weed/admin/topology/structs.go b/weed/admin/topology/structs.go index 103ee5abe..06903352e 100644 --- a/weed/admin/topology/structs.go +++ b/weed/admin/topology/structs.go @@ -97,6 +97,7 @@ type ActiveTopology struct { // DestinationPlan represents a planned destination for a volume/shard operation type DestinationPlan struct { TargetNode string `json:"target_node"` + TargetAddress string `json:"target_address"` TargetDisk uint32 `json:"target_disk"` TargetRack string `json:"target_rack"` TargetDC string `json:"target_dc"` diff --git a/weed/worker/tasks/balance/detection.go b/weed/worker/tasks/balance/detection.go index 6d433c719..c9f4ebfd5 100644 --- a/weed/worker/tasks/balance/detection.go +++ b/weed/worker/tasks/balance/detection.go @@ -8,6 +8,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/worker_pb" "github.com/seaweedfs/seaweedfs/weed/worker/tasks/base" + "github.com/seaweedfs/seaweedfs/weed/worker/tasks/util" "github.com/seaweedfs/seaweedfs/weed/worker/types" ) @@ -122,7 +123,7 @@ func Detection(metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterI // Unified sources and targets - the only way to specify locations Sources: []*worker_pb.TaskSource{ { - Node: selectedVolume.Server, + Node: selectedVolume.ServerAddress, DiskId: sourceDisk, VolumeId: selectedVolume.VolumeID, EstimatedSize: selectedVolume.Size, @@ -132,7 +133,7 @@ func Detection(metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterI }, Targets: []*worker_pb.TaskTarget{ { - Node: destinationPlan.TargetNode, + Node: destinationPlan.TargetAddress, DiskId: destinationPlan.TargetDisk, VolumeId: selectedVolume.VolumeID, EstimatedSize: destinationPlan.ExpectedSize, @@ -231,8 +232,15 @@ func planBalanceDestination(activeTopology *topology.ActiveTopology, selectedVol return nil, fmt.Errorf("no suitable destination found for balance operation") } + // Get the target server address + targetAddress, err := util.ResolveServerAddress(bestDisk.NodeID, activeTopology) + if err != nil { + return nil, fmt.Errorf("failed to resolve address for target server %s: %v", bestDisk.NodeID, err) + } + return &topology.DestinationPlan{ TargetNode: bestDisk.NodeID, + TargetAddress: targetAddress, TargetDisk: bestDisk.DiskID, TargetRack: bestDisk.Rack, TargetDC: bestDisk.DataCenter, diff --git a/weed/worker/tasks/erasure_coding/detection.go b/weed/worker/tasks/erasure_coding/detection.go index c5568fe26..1beb910a3 100644 --- a/weed/worker/tasks/erasure_coding/detection.go +++ b/weed/worker/tasks/erasure_coding/detection.go @@ -11,6 +11,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding" "github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding/placement" "github.com/seaweedfs/seaweedfs/weed/worker/tasks/base" + "github.com/seaweedfs/seaweedfs/weed/worker/tasks/util" "github.com/seaweedfs/seaweedfs/weed/worker/types" ) @@ -183,6 +184,13 @@ func Detection(metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterI glog.V(2).Infof("Added pending EC shard task %s to ActiveTopology for volume %d with %d cleanup sources and %d shard destinations", taskID, metric.VolumeID, len(sources), len(multiPlan.Plans)) + // Convert sources + sourcesProto, err := convertTaskSourcesToProtobuf(sources, metric.VolumeID, clusterInfo.ActiveTopology) + if err != nil { + glog.Warningf("Failed to convert sources for EC task on volume %d: %v, skipping", metric.VolumeID, err) + continue + } + // Create unified sources and targets for EC task result.TypedParams = &worker_pb.TaskParams{ TaskId: taskID, // Link to ActiveTopology pending task @@ -191,7 +199,7 @@ func Detection(metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterI VolumeSize: metric.Size, // Store original volume size for tracking changes // Unified sources - all sources that will be processed/cleaned up - Sources: convertTaskSourcesToProtobuf(sources, metric.VolumeID), + Sources: sourcesProto, // Unified targets - all EC shard destinations Targets: createECTargets(multiPlan), @@ -296,8 +304,15 @@ func planECDestinations(activeTopology *topology.ActiveTopology, metric *types.V dcCount := make(map[string]int) for _, disk := range selectedDisks { + // Get the target server address + targetAddress, err := util.ResolveServerAddress(disk.NodeID, activeTopology) + if err != nil { + return nil, fmt.Errorf("failed to resolve address for target server %s: %v", disk.NodeID, err) + } + plan := &topology.DestinationPlan{ TargetNode: disk.NodeID, + TargetAddress: targetAddress, TargetDisk: disk.DiskID, TargetRack: disk.Rack, TargetDC: disk.DataCenter, @@ -358,7 +373,7 @@ func createECTargets(multiPlan *topology.MultiDestinationPlan) []*worker_pb.Task // Create targets with assigned shard IDs for i, plan := range multiPlan.Plans { target := &worker_pb.TaskTarget{ - Node: plan.TargetNode, + Node: plan.TargetAddress, DiskId: plan.TargetDisk, Rack: plan.TargetRack, DataCenter: plan.TargetDC, @@ -388,12 +403,17 @@ func createECTargets(multiPlan *topology.MultiDestinationPlan) []*worker_pb.Task } // convertTaskSourcesToProtobuf converts topology.TaskSourceSpec to worker_pb.TaskSource -func convertTaskSourcesToProtobuf(sources []topology.TaskSourceSpec, volumeID uint32) []*worker_pb.TaskSource { +func convertTaskSourcesToProtobuf(sources []topology.TaskSourceSpec, volumeID uint32, activeTopology *topology.ActiveTopology) ([]*worker_pb.TaskSource, error) { var protobufSources []*worker_pb.TaskSource for _, source := range sources { + serverAddress, err := util.ResolveServerAddress(source.ServerID, activeTopology) + if err != nil { + return nil, fmt.Errorf("failed to resolve address for source server %s: %v", source.ServerID, err) + } + pbSource := &worker_pb.TaskSource{ - Node: source.ServerID, + Node: serverAddress, DiskId: source.DiskID, DataCenter: source.DataCenter, Rack: source.Rack, @@ -418,7 +438,7 @@ func convertTaskSourcesToProtobuf(sources []topology.TaskSourceSpec, volumeID ui protobufSources = append(protobufSources, pbSource) } - return protobufSources + return protobufSources, nil } // createECTaskParams creates clean EC task parameters (destinations now in unified targets) diff --git a/weed/worker/tasks/util/address.go b/weed/worker/tasks/util/address.go new file mode 100644 index 000000000..516edb8db --- /dev/null +++ b/weed/worker/tasks/util/address.go @@ -0,0 +1,23 @@ +package util + +import ( + "fmt" + + "github.com/seaweedfs/seaweedfs/weed/admin/topology" +) + +// ResolveServerAddress resolves a server ID to its network address using the active topology +func ResolveServerAddress(serverID string, activeTopology *topology.ActiveTopology) (string, error) { + if activeTopology == nil { + return "", fmt.Errorf("topology not available") + } + allNodes := activeTopology.GetAllNodes() + nodeInfo, exists := allNodes[serverID] + if !exists { + return "", fmt.Errorf("server %s not found in topology", serverID) + } + if nodeInfo.Address == "" { + return "", fmt.Errorf("server %s has no address in topology", serverID) + } + return nodeInfo.Address, nil +} diff --git a/weed/worker/tasks/vacuum/detection.go b/weed/worker/tasks/vacuum/detection.go index bd86a2742..da59a4a7f 100644 --- a/weed/worker/tasks/vacuum/detection.go +++ b/weed/worker/tasks/vacuum/detection.go @@ -7,6 +7,7 @@ import ( "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/worker_pb" "github.com/seaweedfs/seaweedfs/weed/worker/tasks/base" + "github.com/seaweedfs/seaweedfs/weed/worker/tasks/util" "github.com/seaweedfs/seaweedfs/weed/worker/types" ) @@ -48,7 +49,9 @@ func Detection(metrics []*types.VolumeHealthMetrics, clusterInfo *types.ClusterI // Create typed parameters for vacuum task result.TypedParams = createVacuumTaskParams(result, metric, vacuumConfig, clusterInfo) - results = append(results, result) + if result.TypedParams != nil { + results = append(results, result) + } } else { // Debug why volume was not selected if debugCount < 5 { // Limit debug output to first 5 volumes @@ -102,6 +105,17 @@ func createVacuumTaskParams(task *types.TaskDetectionResult, metric *types.Volum // Use DC and rack information directly from VolumeHealthMetrics sourceDC, sourceRack := metric.DataCenter, metric.Rack + // Get server address from topology (required for vacuum tasks) + if clusterInfo == nil || clusterInfo.ActiveTopology == nil { + glog.Errorf("Topology not available for vacuum task on volume %d, skipping", task.VolumeID) + return nil + } + address, err := util.ResolveServerAddress(task.Server, clusterInfo.ActiveTopology) + if err != nil { + glog.Errorf("Failed to resolve address for server %s for vacuum task on volume %d, skipping task: %v", task.Server, task.VolumeID, err) + return nil + } + // Create typed protobuf parameters with unified sources return &worker_pb.TaskParams{ TaskId: task.TaskID, // Link to ActiveTopology pending task (if integrated) @@ -112,7 +126,7 @@ func createVacuumTaskParams(task *types.TaskDetectionResult, metric *types.Volum // Unified sources array Sources: []*worker_pb.TaskSource{ { - Node: task.Server, + Node: address, VolumeId: task.VolumeID, EstimatedSize: metric.Size, DataCenter: sourceDC, diff --git a/weed/worker/types/data_types.go b/weed/worker/types/data_types.go index c8a67edc7..64ba5e11c 100644 --- a/weed/worker/types/data_types.go +++ b/weed/worker/types/data_types.go @@ -18,7 +18,8 @@ type ClusterInfo struct { // VolumeHealthMetrics contains health information about a volume (simplified) type VolumeHealthMetrics struct { VolumeID uint32 - Server string + Server string // Volume server ID + ServerAddress string // Volume server address (ip:port) DiskType string // Disk type (e.g., "hdd", "ssd") or disk path (e.g., "/data1") DiskId uint32 // ID of the disk in Store.Locations array DataCenter string // Data center of the server From 5aa111708d9fdf4708e8aec960699947c1fbacd4 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 26 Dec 2025 10:58:18 -0800 Subject: [PATCH 33/66] grpc: reduce client idle pings to avoid ENHANCE_YOUR_CALM (#7885) * grpc: reduce client idle pings to avoid ENHANCE_YOUR_CALM (too_many_pings) * test: use context.WithTimeout and pb constants for keepalive * test(kafka): use separate dial and client contexts in NewDirectBrokerClient * test(kafka): fix client context usage in NewDirectBrokerClient --- .../seaweedfs/client/FilerGrpcClient.java | 2 +- .../loadtest/mock_million_record_test.go | 28 ++++++++++++------- weed/pb/grpc_client_server.go | 8 ++++-- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/other/java/client/src/main/java/seaweedfs/client/FilerGrpcClient.java b/other/java/client/src/main/java/seaweedfs/client/FilerGrpcClient.java index f8334b734..e559431dd 100644 --- a/other/java/client/src/main/java/seaweedfs/client/FilerGrpcClient.java +++ b/other/java/client/src/main/java/seaweedfs/client/FilerGrpcClient.java @@ -87,7 +87,7 @@ public class FilerGrpcClient { .maxHeaderListSize(16 * 1024 * 1024) .keepAliveTime(KEEP_ALIVE_TIME_SECONDS, TimeUnit.SECONDS) .keepAliveTimeout(KEEP_ALIVE_TIMEOUT_SECONDS, TimeUnit.SECONDS) - .keepAliveWithoutCalls(true) + .keepAliveWithoutCalls(false) .withOption(io.grpc.netty.shaded.io.netty.channel.ChannelOption.SO_RCVBUF, 16 * 1024 * 1024) .withOption(io.grpc.netty.shaded.io.netty.channel.ChannelOption.SO_SNDBUF, 16 * 1024 * 1024); diff --git a/test/kafka/loadtest/mock_million_record_test.go b/test/kafka/loadtest/mock_million_record_test.go index ada018cbb..83518fb74 100644 --- a/test/kafka/loadtest/mock_million_record_test.go +++ b/test/kafka/loadtest/mock_million_record_test.go @@ -14,6 +14,8 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/keepalive" + "github.com/seaweedfs/seaweedfs/weed/pb" + "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/mq_pb" "github.com/seaweedfs/seaweedfs/weed/pb/schema_pb" @@ -119,22 +121,28 @@ type PublisherSession struct { } func NewDirectBrokerClient(brokerAddr string) (*DirectBrokerClient, error) { - ctx, cancel := context.WithCancel(context.Background()) + // Use a short-lived context for dialing so we don't store a canceled + // context in the returned client. The client's operational context + // (used by methods) should be cancellable independently. + dialCtx, dialCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer dialCancel() - // Add connection timeout and keepalive settings - conn, err := grpc.DialContext(ctx, brokerAddr, + // Add keepalive settings; use exported server constants to keep values in sync. + conn, err := grpc.DialContext(dialCtx, brokerAddr, grpc.WithTransportCredentials(insecure.NewCredentials()), - grpc.WithTimeout(30*time.Second), grpc.WithKeepaliveParams(keepalive.ClientParameters{ - Time: 30 * time.Second, // Increased from 10s to 30s - Timeout: 10 * time.Second, // Increased from 5s to 10s - PermitWithoutStream: false, // Changed to false to reduce pings + Time: pb.GrpcKeepAliveTime, // align with server MinTime + Timeout: pb.GrpcKeepAliveTimeout, // align with server timeout + PermitWithoutStream: false, // reduce pings when idle })) if err != nil { - cancel() return nil, fmt.Errorf("failed to connect to broker: %v", err) } + // Create a long-lived context for the client's lifetime and store it + // in the returned DirectBrokerClient so callers can cancel when done. + clientCtx, clientCancel := context.WithCancel(context.Background()) + client := mq_pb.NewSeaweedMessagingClient(conn) return &DirectBrokerClient{ @@ -142,8 +150,8 @@ func NewDirectBrokerClient(brokerAddr string) (*DirectBrokerClient, error) { conn: conn, client: client, publishers: make(map[string]*PublisherSession), - ctx: ctx, - cancel: cancel, + ctx: clientCtx, + cancel: clientCancel, }, nil } diff --git a/weed/pb/grpc_client_server.go b/weed/pb/grpc_client_server.go index 4a869bb95..6d09b7f6e 100644 --- a/weed/pb/grpc_client_server.go +++ b/weed/pb/grpc_client_server.go @@ -94,9 +94,11 @@ func GrpcDial(ctx context.Context, address string, waitForReady bool, opts ...gr grpc.WaitForReady(waitForReady), ), grpc.WithKeepaliveParams(keepalive.ClientParameters{ - Time: GrpcKeepAliveTime, // client ping server if no activity for this long - Timeout: GrpcKeepAliveTimeout, // ping timeout - PermitWithoutStream: true, + Time: GrpcKeepAliveTime, // client ping server if no activity for this long + Timeout: GrpcKeepAliveTimeout, // ping timeout + // Disable pings when there are no active streams to avoid triggering + // server enforcement for too-frequent pings from idle clients. + PermitWithoutStream: false, })) for _, opt := range opts { if opt != nil { From b866907461c36bd09f0ddce0824b6018ca57f281 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Dec 2025 12:30:30 -0800 Subject: [PATCH 34/66] fs.meta.save: fix directory entry parent path in FullEntry construction (#7886) * Checkpoint from VS Code for coding agent session * Fix fs.meta.save to correctly save directory's own metadata Co-authored-by: chrislusf <1543151+chrislusf@users.noreply.github.com> * address error --------- Co-authored-by: Chris Lu Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: chrislusf <1543151+chrislusf@users.noreply.github.com> --- weed/shell/command_fs_meta_save.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/weed/shell/command_fs_meta_save.go b/weed/shell/command_fs_meta_save.go index ce982820d..377c5e12e 100644 --- a/weed/shell/command_fs_meta_save.go +++ b/weed/shell/command_fs_meta_save.go @@ -2,6 +2,8 @@ package shell import ( "compress/gzip" + "context" + "errors" "flag" "fmt" "io" @@ -146,6 +148,29 @@ func doTraverseBfsAndSaving(filerClient filer_pb.FilerClient, writer io.Writer, var dirCount, fileCount uint64 + // also save the directory itself (path) if it exists in the filer + if e, getErr := filer_pb.GetEntry(context.Background(), filerClient, util.FullPath(path)); getErr != nil { + // Entry not found is expected and can be ignored; log other errors. + if !errors.Is(getErr, filer_pb.ErrNotFound) { + fmt.Fprintf(writer, "failed to get entry %s: %v\n", path, getErr) + } + } else if e != nil { + parentDir, _ := util.FullPath(path).DirAndName() + protoMessage := &filer_pb.FullEntry{ + Dir: parentDir, + Entry: e, + } + if genErr := genFn(protoMessage, outputChan); genErr != nil { + fmt.Fprintf(writer, "marshall error: %v\n", genErr) + } else { + if e.IsDirectory { + atomic.AddUint64(&dirCount, 1) + } else { + atomic.AddUint64(&fileCount, 1) + } + } + } + err := filer_pb.TraverseBfs(filerClient, util.FullPath(path), func(parentPath util.FullPath, entry *filer_pb.Entry) { if strings.HasPrefix(string(parentPath), filer.SystemLogDir) { From 2b3ff3cd0576464efb8e93cf409cbcfcafa6b263 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 26 Dec 2025 12:42:00 -0800 Subject: [PATCH 35/66] verbose mode --- weed/shell/command_volume_fix_replication.go | 23 ++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/weed/shell/command_volume_fix_replication.go b/weed/shell/command_volume_fix_replication.go index 96bd41711..9c760ef9d 100644 --- a/weed/shell/command_volume_fix_replication.go +++ b/weed/shell/command_volume_fix_replication.go @@ -69,6 +69,7 @@ func (c *commandVolumeFixReplication) Do(args []string, commandEnv *CommandEnv, applyChanges := volFixReplicationCommand.Bool("apply", false, "apply the fix") // TODO: remove this alias applyChangesAlias := volFixReplicationCommand.Bool("force", false, "apply the fix (alias for -apply)") + verbose := volFixReplicationCommand.Bool("verbose", false, "show volumes being checked and their statuses") doDelete := volFixReplicationCommand.Bool("doDelete", true, "Also delete over-replicated volumes besides fixing under-replication") doCheck := volFixReplicationCommand.Bool("doCheck", true, "Also check synchronization before deleting") maxParallelization := volFixReplicationCommand.Int("maxParallelization", DefaultMaxParallelization, "run up to X tasks in parallel, whenever possible") @@ -93,6 +94,9 @@ func (c *commandVolumeFixReplication) Do(args []string, commandEnv *CommandEnv, fixedVolumeReplicas := map[string]int{} // collect topology information + if *verbose { + fmt.Fprintf(writer, "wait 15 seconds and then collect topology information...\n") + } topologyInfo, _, err := collectTopologyInfo(commandEnv, 15*time.Second) if err != nil { return err @@ -102,6 +106,10 @@ func (c *commandVolumeFixReplication) Do(args []string, commandEnv *CommandEnv, // collect all data nodes volumeReplicas, allLocations := collectVolumeReplicaLocations(topologyInfo) + if *verbose { + fmt.Fprintf(writer, "collected topology: %d locations, %d volumes to check\n", len(allLocations), len(volumeReplicas)) + } + if len(allLocations) == 0 { return fmt.Errorf("no data nodes at all") } @@ -111,16 +119,23 @@ func (c *commandVolumeFixReplication) Do(args []string, commandEnv *CommandEnv, for vid, replicas := range volumeReplicas { replica := replicas[0] replicaPlacement, _ := super_block.NewReplicaPlacementFromByte(byte(replica.info.ReplicaPlacement)) + + // build locations list for optional verbose output + locations := make([]string, 0, len(replicas)) + for _, r := range replicas { + locations = append(locations, r.location.String()) + } + + if *verbose { + fmt.Fprintf(writer, "checking volume %d replication %s has %d replicas [%s]\n", replica.info.Id, replicaPlacement, len(replicas), strings.Join(locations, ", ")) + } + switch { case replicaPlacement.GetCopyCount() > len(replicas) || !satisfyReplicaCurrentLocation(replicaPlacement, replicas): underReplicatedVolumeIds = append(underReplicatedVolumeIds, vid) fmt.Fprintf(writer, "volume %d replication %s, but under replicated %+d\n", replica.info.Id, replicaPlacement, len(replicas)) case isMisplaced(replicas, replicaPlacement): misplacedVolumeIds = append(misplacedVolumeIds, vid) - locations := make([]string, 0, len(replicas)) - for _, r := range replicas { - locations = append(locations, r.location.String()) - } fmt.Fprintf(writer, "volume %d replication %s is not well placed [%s]\n", replica.info.Id, replicaPlacement, strings.Join(locations, ", ")) case replicaPlacement.GetCopyCount() < len(replicas): overReplicatedVolumeIds = append(overReplicatedVolumeIds, vid) From 95716a2f872a8a3e212519bbe09a726d3dd2782c Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 26 Dec 2025 12:55:19 -0800 Subject: [PATCH 36/66] less verbose --- weed/pb/grpc_client_server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/weed/pb/grpc_client_server.go b/weed/pb/grpc_client_server.go index 6d09b7f6e..32572d1f2 100644 --- a/weed/pb/grpc_client_server.go +++ b/weed/pb/grpc_client_server.go @@ -115,7 +115,7 @@ func getOrCreateConnection(address string, waitForReady bool, opts ...grpc.DialO existingConnection, found := grpcClients[address] if found { - glog.V(3).Infof("gRPC cache hit for %s (version %d)", address, existingConnection.version) + glog.V(4).Infof("gRPC cache hit for %s (version %d)", address, existingConnection.version) return existingConnection, nil } From f07ba2c5aabfb41fd52b54b04e44a57d2495d907 Mon Sep 17 00:00:00 2001 From: "steve.wei" Date: Sat, 27 Dec 2025 05:15:17 +0800 Subject: [PATCH 37/66] fix: support standard HTTP headers in S3 multipart upload (#7884) Co-authored-by: Chris Lu --- weed/s3api/s3_metadata_util.go | 19 +++++++++++++++++++ weed/s3api/s3api_object_handlers_multipart.go | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/weed/s3api/s3_metadata_util.go b/weed/s3api/s3_metadata_util.go index 37363752a..b11ce147a 100644 --- a/weed/s3api/s3_metadata_util.go +++ b/weed/s3api/s3_metadata_util.go @@ -34,6 +34,25 @@ func ParseS3Metadata(r *http.Request, existing map[string][]byte, isReplace bool metadata["Content-Encoding"] = []byte(ce) } + // Other Standard HTTP headers defined in https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html + standardHeaders := []string{ + "Cache-Control", + "Content-Disposition", + "Content-Language", + "Expires", + } + for _, header := range standardHeaders { + if value := r.Header.Get(header); value != "" { + metadata[header] = []byte(value) + } + } + + // Handle Response-Content-Disposition (used in presigned URLs) + // This should be stored as Content-Disposition + if rcd := r.Header.Get("Response-Content-Disposition"); rcd != "" { + metadata["Content-Disposition"] = []byte(rcd) + } + // Object tagging if tags := r.Header.Get(s3_constants.AmzObjectTagging); tags != "" { // Use url.ParseQuery for robust parsing and automatic URL decoding diff --git a/weed/s3api/s3api_object_handlers_multipart.go b/weed/s3api/s3api_object_handlers_multipart.go index becbd9bf9..7fcebef38 100644 --- a/weed/s3api/s3api_object_handlers_multipart.go +++ b/weed/s3api/s3api_object_handlers_multipart.go @@ -12,10 +12,12 @@ import ( "net/url" "strconv" "strings" + "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/s3" "github.com/google/uuid" + "github.com/pquerna/cachecontrol/cacheobject" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" @@ -64,6 +66,22 @@ func (s3a *S3ApiServer) NewMultipartUploadHandler(w http.ResponseWriter, r *http return } + // Validate Cache-Control header format if present + if r.Header.Get("Cache-Control") != "" { + if _, err := cacheobject.ParseRequestCacheControl(r.Header.Get("Cache-Control")); err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidDigest) + return + } + } + + // Validate Expires header format if present + if r.Header.Get("Expires") != "" { + if _, err := time.Parse(http.TimeFormat, r.Header.Get("Expires")); err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrMalformedDate) + return + } + } + createMultipartUploadInput := &s3.CreateMultipartUploadInput{ Bucket: aws.String(bucket), Key: objectKey(aws.String(object)), From 82dac3df03a82e8de1763ca95da2cab87b983436 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 26 Dec 2025 13:21:15 -0800 Subject: [PATCH 38/66] s3: do not persist multi part "Response-Content-Disposition" in request header (#7887) * fix: support standard HTTP headers in S3 multipart upload * fix(s3api): validate standard HTTP headers correctly and avoid persisting Response-Content-Disposition --------- Co-authored-by: steve.wei --- weed/s3api/s3_metadata_util.go | 7 ++----- weed/s3api/s3api_object_handlers_multipart.go | 10 +++++----- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/weed/s3api/s3_metadata_util.go b/weed/s3api/s3_metadata_util.go index b11ce147a..71ef623f9 100644 --- a/weed/s3api/s3_metadata_util.go +++ b/weed/s3api/s3_metadata_util.go @@ -47,11 +47,8 @@ func ParseS3Metadata(r *http.Request, existing map[string][]byte, isReplace bool } } - // Handle Response-Content-Disposition (used in presigned URLs) - // This should be stored as Content-Disposition - if rcd := r.Header.Get("Response-Content-Disposition"); rcd != "" { - metadata["Content-Disposition"] = []byte(rcd) - } + // Do NOT persist Response-Content-Disposition: it is a GET-only + // presigned-download override and must not be stored as upload metadata. // Object tagging if tags := r.Header.Get(s3_constants.AmzObjectTagging); tags != "" { diff --git a/weed/s3api/s3api_object_handlers_multipart.go b/weed/s3api/s3api_object_handlers_multipart.go index 7fcebef38..88e754461 100644 --- a/weed/s3api/s3api_object_handlers_multipart.go +++ b/weed/s3api/s3api_object_handlers_multipart.go @@ -67,16 +67,16 @@ func (s3a *S3ApiServer) NewMultipartUploadHandler(w http.ResponseWriter, r *http } // Validate Cache-Control header format if present - if r.Header.Get("Cache-Control") != "" { - if _, err := cacheobject.ParseRequestCacheControl(r.Header.Get("Cache-Control")); err != nil { - s3err.WriteErrorResponse(w, r, s3err.ErrInvalidDigest) + if cacheControl := r.Header.Get("Cache-Control"); cacheControl != "" { + if _, err := cacheobject.ParseRequestCacheControl(cacheControl); err != nil { + s3err.WriteErrorResponse(w, r, s3err.ErrInvalidRequest) return } } // Validate Expires header format if present - if r.Header.Get("Expires") != "" { - if _, err := time.Parse(http.TimeFormat, r.Header.Get("Expires")); err != nil { + if expires := r.Header.Get("Expires"); expires != "" { + if _, err := time.Parse(http.TimeFormat, expires); err != nil { s3err.WriteErrorResponse(w, r, s3err.ErrMalformedDate) return } From c688b697009c6942dd5fc18b4da20e86a8e029c9 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 26 Dec 2025 13:26:25 -0800 Subject: [PATCH 39/66] reduce logs --- weed/server/filer_grpc_server_dlm.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/weed/server/filer_grpc_server_dlm.go b/weed/server/filer_grpc_server_dlm.go index 7e8f93102..0dc744c48 100644 --- a/weed/server/filer_grpc_server_dlm.go +++ b/weed/server/filer_grpc_server_dlm.go @@ -3,6 +3,7 @@ package weed_server import ( "context" "fmt" + "strings" "time" "github.com/seaweedfs/seaweedfs/weed/cluster/lock_manager" @@ -51,7 +52,9 @@ func (fs *FilerServer) DistributedLock(ctx context.Context, req *filer_pb.LockRe if err != nil { resp.Error = fmt.Sprintf("%v", err) - glog.V(0).Infof("FILER LOCK: Error - name=%s error=%s", req.Name, resp.Error) + if !strings.Contains(resp.Error, "lock already owned") { + glog.V(0).Infof("FILER LOCK: Error - name=%s error=%s", req.Name, resp.Error) + } } if movedTo != "" { resp.LockHostMovedTo = string(movedTo) From 935f41bff661af3480c3cab9100a6d318a0aa7e4 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Fri, 26 Dec 2025 15:44:30 -0800 Subject: [PATCH 40/66] filer.backup: ignore missing volume/lookup errors when -ignore404Error is set (#7889) * filer.backup: ignore missing volume/lookup errors when -ignore404Error is set (#7888) * simplify --- weed/command/filer_backup.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/weed/command/filer_backup.go b/weed/command/filer_backup.go index 996260c1e..3387fccbf 100644 --- a/weed/command/filer_backup.go +++ b/weed/command/filer_backup.go @@ -146,10 +146,17 @@ func doFilerBackup(grpcDialOption grpc.DialOption, backupOption *FilerBackupOpti if err == nil { return nil } + // ignore HTTP 404 from remote reads if errors.Is(err, http.ErrNotFound) { glog.V(0).Infof("got 404 error, ignore it: %s", err.Error()) return nil } + // also ignore missing volume/lookup errors coming from LookupFileId or vid map + errStr := err.Error() + if strings.Contains(errStr, "LookupFileId") || (strings.Contains(errStr, "volume id") && strings.Contains(errStr, "not found")) { + glog.V(0).Infof("got missing-volume error, ignore it: %s", errStr) + return nil + } return err } } else { From 8d6bcddf60c918f57120031ca27a67d32faab710 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sat, 27 Dec 2025 00:09:14 -0800 Subject: [PATCH 41/66] Add S3 volume encryption support with -s3.encryptVolumeData flag (#7890) * Add S3 volume encryption support with -s3.encryptVolumeData flag This change adds volume-level encryption support for S3 uploads, similar to the existing -filer.encryptVolumeData option. Each chunk is encrypted with its own auto-generated CipherKey when the flag is enabled. Changes: - Add -s3.encryptVolumeData flag to weed s3, weed server, and weed mini - Wire Cipher option through S3ApiServer and ChunkedUploadOption - Add integration tests for multi-chunk range reads with encryption - Tests verify encryption works across chunk boundaries Usage: weed s3 -encryptVolumeData weed server -s3 -s3.encryptVolumeData weed mini -s3.encryptVolumeData Integration tests: go test -v -tags=integration -timeout 5m ./test/s3/sse/... * Add GitHub Actions CI for S3 volume encryption tests - Add test-volume-encryption target to Makefile that starts server with -s3.encryptVolumeData - Add s3-volume-encryption job to GitHub Actions workflow - Tests run with integration build tag and 10m timeout - Server logs uploaded on failure for debugging * Fix S3 client credentials to use environment variables The test was using hardcoded credentials "any"/"any" but the Makefile sets AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY to "some_access_key1"/ "some_secret_key1". Updated getS3Client() to read from environment variables with fallback to "any"/"any" for manual testing. * Change bucket creation errors from skip to fatal Tests should fail, not skip, when bucket creation fails. This ensures that credential mismatches and other configuration issues are caught rather than silently skipped. * Make copy and multipart test jobs fail instead of succeed Changed exit 0 to exit 1 for s3-sse-copy-operations and s3-sse-multipart jobs. These jobs document known limitations but should fail to ensure the issues are tracked and addressed, not silently ignored. * Hardcode S3 credentials to match Makefile Changed from environment variables to hardcoded credentials "some_access_key1"/"some_secret_key1" to match the Makefile configuration. This ensures tests work reliably. * fix Double Encryption * fix Chunk Size Mismatch * Added IsCompressed * is gzipped * fix copying * only perform HEAD request when len(cipherKey) > 0 * Revert "Make copy and multipart test jobs fail instead of succeed" This reverts commit bc34a7eb3c103ae7ab2000da2a6c3925712eb226. * fix security vulnerability * fix security * Update s3api_object_handlers_copy.go * Update s3api_object_handlers_copy.go * jwt to get content length --- .github/workflows/s3-sse-tests.yml | 50 +++ test/s3/sse/Makefile | 33 ++ test/s3/sse/s3_volume_encryption_test.go | 448 +++++++++++++++++++++++ weed/command/mini.go | 1 + weed/command/s3.go | 3 + weed/command/server.go | 1 + weed/operation/upload_chunked.go | 5 +- weed/operation/upload_content.go | 8 +- weed/s3api/s3api_key_rotation.go | 4 +- weed/s3api/s3api_object_handlers.go | 2 + weed/s3api/s3api_object_handlers_copy.go | 48 ++- weed/s3api/s3api_object_handlers_put.go | 3 +- weed/s3api/s3api_server.go | 5 +- 13 files changed, 593 insertions(+), 18 deletions(-) create mode 100644 test/s3/sse/s3_volume_encryption_test.go diff --git a/.github/workflows/s3-sse-tests.yml b/.github/workflows/s3-sse-tests.yml index 946e4735e..2a8c0b332 100644 --- a/.github/workflows/s3-sse-tests.yml +++ b/.github/workflows/s3-sse-tests.yml @@ -345,3 +345,53 @@ jobs: name: s3-sse-performance-logs path: test/s3/sse/weed-test*.log retention-days: 7 + + s3-volume-encryption: + name: S3 Volume Encryption Test + runs-on: ubuntu-22.04 + timeout-minutes: 20 + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + id: go + + - name: Install SeaweedFS + run: | + go install -buildvcs=false + + - name: Run S3 Volume Encryption Integration Tests + timeout-minutes: 15 + working-directory: test/s3/sse + run: | + set -x + echo "=== System Information ===" + uname -a + free -h + + # Run volume encryption tests with -s3.encryptVolumeData flag + echo "🚀 Running S3 volume encryption integration tests..." + make test-volume-encryption || { + echo "❌ Volume encryption tests failed, checking logs..." + if [ -f /tmp/seaweedfs-sse-mini.log ]; then + echo "=== Server logs ===" + tail -100 /tmp/seaweedfs-sse-mini.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@v6 + with: + name: s3-volume-encryption-logs + path: /tmp/seaweedfs-sse-*.log + retention-days: 3 + diff --git a/test/s3/sse/Makefile b/test/s3/sse/Makefile index e646ef901..87c171486 100644 --- a/test/s3/sse/Makefile +++ b/test/s3/sse/Makefile @@ -470,3 +470,36 @@ dev-kms: setup-openbao @echo "OpenBao: $(OPENBAO_ADDR)" @echo "Token: $(OPENBAO_TOKEN)" @echo "Use 'make test-ssekms-integration' to run tests" + +# Volume encryption integration tests +test-volume-encryption: build-weed + @echo "🚀 Starting S3 volume encryption integration tests..." + @echo "Starting SeaweedFS cluster with volume encryption enabled..." + @# Start server with -s3.encryptVolumeData flag + @mkdir -p /tmp/seaweedfs-test-sse + @rm -f /tmp/seaweedfs-sse-*.log || true + @sed -e 's/ACCESS_KEY_PLACEHOLDER/$(ACCESS_KEY)/g' \ + -e 's/SECRET_KEY_PLACEHOLDER/$(SECRET_KEY)/g' \ + s3-config-template.json > /tmp/seaweedfs-s3.json + @echo "Starting weed mini with S3 volume encryption..." + @AWS_ACCESS_KEY_ID=$(ACCESS_KEY) AWS_SECRET_ACCESS_KEY=$(SECRET_KEY) GLOG_v=4 $(SEAWEEDFS_BINARY) mini \ + -dir=/tmp/seaweedfs-test-sse \ + -s3.port=$(S3_PORT) \ + -s3.config=/tmp/seaweedfs-s3.json \ + -s3.encryptVolumeData \ + -ip=127.0.0.1 \ + > /tmp/seaweedfs-sse-mini.log 2>&1 & echo $$! > /tmp/weed-mini.pid + @echo "Checking S3 service is ready..." + @for i in $$(seq 1 30); do \ + if curl -s http://127.0.0.1:$(S3_PORT) > /dev/null 2>&1; then \ + echo "✅ S3 service is ready"; \ + break; \ + fi; \ + sleep 1; \ + done + @echo "Running volume encryption integration tests..." + @trap '$(MAKE) -C $(TEST_DIR) stop-seaweedfs-safe || true' EXIT; \ + cd $(SEAWEEDFS_ROOT) && go test -v -tags=integration -timeout=10m -run "TestS3VolumeEncryption" ./test/s3/sse || exit 1; \ + echo "✅ Volume encryption tests completed successfully"; \ + $(MAKE) -C $(TEST_DIR) stop-seaweedfs-safe || true + diff --git a/test/s3/sse/s3_volume_encryption_test.go b/test/s3/sse/s3_volume_encryption_test.go new file mode 100644 index 000000000..349763c10 --- /dev/null +++ b/test/s3/sse/s3_volume_encryption_test.go @@ -0,0 +1,448 @@ +//go:build integration +// +build integration + +package sse + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" +) + +// TestS3VolumeEncryptionRoundtrip tests that data uploaded with encryptVolumeData +// enabled can be read back correctly. This requires the S3 server to be started with +// -encryptVolumeData=true for the test to verify encryption is working. +// +// To run this test: +// 1. Start SeaweedFS: weed server -s3 -s3.encryptVolumeData=true +// 2. Run: go test -v -run TestS3VolumeEncryptionRoundtrip +func TestS3VolumeEncryptionRoundtrip(t *testing.T) { + svc := getS3Client(t) + bucket := fmt.Sprintf("volume-encryption-test-%d", time.Now().Unix()) + + // Create bucket + _, err := svc.CreateBucket(&s3.CreateBucketInput{ + Bucket: aws.String(bucket), + }) + if err != nil { + t.Fatalf("Failed to create bucket: %v", err) + } + defer cleanupBucket(t, svc, bucket) + + testCases := []struct { + name string + key string + content string + rangeReq string // Optional range request + }{ + { + name: "small file", + key: "small.txt", + content: "Hello, encrypted world!", + }, + { + name: "medium file", + key: "medium.txt", + content: strings.Repeat("SeaweedFS volume encryption test content. ", 1000), + }, + { + name: "binary content", + key: "binary.bin", + content: string([]byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD, 0x00, 0x80}), + }, + { + name: "range request", + key: "range-test.txt", + content: "0123456789ABCDEFGHIJ", + rangeReq: "bytes=5-10", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Upload + _, err := svc.PutObject(&s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(tc.key), + Body: strings.NewReader(tc.content), + }) + if err != nil { + t.Fatalf("PutObject failed: %v", err) + } + + // Download + getInput := &s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(tc.key), + } + if tc.rangeReq != "" { + getInput.Range = aws.String(tc.rangeReq) + } + + result, err := svc.GetObject(getInput) + if err != nil { + t.Fatalf("GetObject failed: %v", err) + } + defer result.Body.Close() + + data, err := io.ReadAll(result.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + + // Verify content + expected := tc.content + if tc.rangeReq != "" { + // For "bytes=5-10", we expect characters at positions 5-10 (inclusive) + expected = tc.content[5:11] + } + + if string(data) != expected { + t.Errorf("Content mismatch:\n expected: %q\n got: %q", expected, string(data)) + } else { + t.Logf("Successfully uploaded and downloaded %s (%d bytes)", tc.key, len(data)) + } + }) + } +} + +// TestS3VolumeEncryptionMultiChunk tests large files that span multiple chunks +// to ensure encryption works correctly across chunk boundaries. +func TestS3VolumeEncryptionMultiChunk(t *testing.T) { + svc := getS3Client(t) + bucket := fmt.Sprintf("volume-encryption-multichunk-%d", time.Now().Unix()) + + // Create bucket + _, err := svc.CreateBucket(&s3.CreateBucketInput{ + Bucket: aws.String(bucket), + }) + if err != nil { + t.Fatalf("Failed to create bucket: %v", err) + } + defer cleanupBucket(t, svc, bucket) + + // Create a file larger than default chunk size (8MB) + // Use 10MB to ensure multiple chunks + largeContent := make([]byte, 10*1024*1024) + for i := range largeContent { + largeContent[i] = byte(i % 256) + } + + key := "large-file.bin" + + // Upload + _, err = svc.PutObject(&s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + Body: bytes.NewReader(largeContent), + }) + if err != nil { + t.Fatalf("PutObject failed for large file: %v", err) + } + + // Download full file + result, err := svc.GetObject(&s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + if err != nil { + t.Fatalf("GetObject failed: %v", err) + } + defer result.Body.Close() + + downloadedData, err := io.ReadAll(result.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + + if len(downloadedData) != len(largeContent) { + t.Errorf("Size mismatch: expected %d, got %d", len(largeContent), len(downloadedData)) + } + + if !bytes.Equal(downloadedData, largeContent) { + t.Errorf("Content mismatch in multi-chunk file") + // Find first mismatch + for i := 0; i < len(downloadedData) && i < len(largeContent); i++ { + if downloadedData[i] != largeContent[i] { + t.Errorf("First mismatch at byte %d: expected %02x, got %02x", i, largeContent[i], downloadedData[i]) + break + } + } + } else { + t.Logf("Successfully uploaded and downloaded %d byte multi-chunk file", len(downloadedData)) + } + + // Test range request spanning chunk boundary (around 8MB) + rangeStart := int64(8*1024*1024 - 1000) + rangeEnd := int64(8*1024*1024 + 1000) + rangeResult, err := svc.GetObject(&s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + Range: aws.String(fmt.Sprintf("bytes=%d-%d", rangeStart, rangeEnd)), + }) + if err != nil { + t.Fatalf("Range GetObject failed: %v", err) + } + defer rangeResult.Body.Close() + + rangeData, err := io.ReadAll(rangeResult.Body) + if err != nil { + t.Fatalf("Failed to read range response: %v", err) + } + + expectedRange := largeContent[rangeStart : rangeEnd+1] + if !bytes.Equal(rangeData, expectedRange) { + t.Errorf("Range request content mismatch at chunk boundary") + } else { + t.Logf("Successfully retrieved range spanning chunk boundary (%d bytes)", len(rangeData)) + } +} + +// TestS3VolumeEncryptionMultiChunkRangeRead tests range reads that span multiple chunks: +// - Part of chunk 1 +// - Whole chunk 2 +// - Part of chunk 3 +// This is critical for verifying that cipher decryption works correctly when +// reading across chunk boundaries with the cipherKey from each chunk. +func TestS3VolumeEncryptionMultiChunkRangeRead(t *testing.T) { + svc := getS3Client(t) + bucket := fmt.Sprintf("volume-encryption-multirange-%d", time.Now().Unix()) + + // Create bucket + _, err := svc.CreateBucket(&s3.CreateBucketInput{ + Bucket: aws.String(bucket), + }) + if err != nil { + t.Fatalf("Failed to create bucket: %v", err) + } + defer cleanupBucket(t, svc, bucket) + + // Default chunk size is 8MB. Create a file with 3+ chunks (25MB) + // to ensure we have multiple complete chunks + const chunkSize = 8 * 1024 * 1024 // 8MB + const fileSize = 25 * 1024 * 1024 // 25MB = 3 chunks + partial 4th + + largeContent := make([]byte, fileSize) + for i := range largeContent { + // Use recognizable pattern: each byte encodes its position + largeContent[i] = byte(i % 256) + } + + key := "multi-chunk-range-test.bin" + + // Upload + _, err = svc.PutObject(&s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + Body: bytes.NewReader(largeContent), + }) + if err != nil { + t.Fatalf("PutObject failed: %v", err) + } + + t.Logf("Uploaded %d byte file (%d chunks of %d bytes)", fileSize, (fileSize+chunkSize-1)/chunkSize, chunkSize) + + // Test cases for range reads spanning multiple chunks + testCases := []struct { + name string + rangeStart int64 + rangeEnd int64 + desc string + }{ + { + name: "part_chunk1_whole_chunk2_part_chunk3", + rangeStart: chunkSize - 1000, // 1000 bytes before end of chunk 1 + rangeEnd: 2*chunkSize + 1000, // 1000 bytes into chunk 3 + desc: "Spans from end of chunk 1 through entire chunk 2 into beginning of chunk 3", + }, + { + name: "last_byte_chunk1_through_first_byte_chunk3", + rangeStart: chunkSize - 1, // Last byte of chunk 1 + rangeEnd: 2 * chunkSize, // First byte of chunk 3 + desc: "Minimal span: last byte of chunk 1, entire chunk 2, first byte of chunk 3", + }, + { + name: "middle_chunk1_to_middle_chunk3", + rangeStart: chunkSize / 2, // Middle of chunk 1 + rangeEnd: 2*chunkSize + chunkSize/2, // Middle of chunk 3 + desc: "From middle of chunk 1 to middle of chunk 3", + }, + { + name: "half_chunk1_whole_chunk2_half_chunk3", + rangeStart: chunkSize / 2, // Half of chunk 1 + rangeEnd: 2*chunkSize + chunkSize/2 - 1, // Half of chunk 3 + desc: "Half of each boundary chunk, whole middle chunk", + }, + { + name: "single_byte_each_boundary", + rangeStart: chunkSize - 1, // 1 byte from chunk 1 + rangeEnd: 2 * chunkSize, // 1 byte from chunk 3 + desc: "1 byte from chunk 1, entire chunk 2, 1 byte from chunk 3", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Logf("Test: %s", tc.desc) + t.Logf("Range: bytes=%d-%d (spanning bytes at offsets across chunk boundaries)", tc.rangeStart, tc.rangeEnd) + + result, err := svc.GetObject(&s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + Range: aws.String(fmt.Sprintf("bytes=%d-%d", tc.rangeStart, tc.rangeEnd)), + }) + if err != nil { + t.Fatalf("Range GetObject failed: %v", err) + } + defer result.Body.Close() + + rangeData, err := io.ReadAll(result.Body) + if err != nil { + t.Fatalf("Failed to read range response: %v", err) + } + + expectedLen := tc.rangeEnd - tc.rangeStart + 1 + if int64(len(rangeData)) != expectedLen { + t.Errorf("Size mismatch: expected %d bytes, got %d", expectedLen, len(rangeData)) + } + + expectedRange := largeContent[tc.rangeStart : tc.rangeEnd+1] + if !bytes.Equal(rangeData, expectedRange) { + t.Errorf("Content mismatch in multi-chunk range read") + // Find first mismatch for debugging + for i := 0; i < len(rangeData) && i < len(expectedRange); i++ { + if rangeData[i] != expectedRange[i] { + globalOffset := tc.rangeStart + int64(i) + chunkNum := globalOffset / chunkSize + offsetInChunk := globalOffset % chunkSize + t.Errorf("First mismatch at byte %d (chunk %d, offset %d in chunk): expected %02x, got %02x", + i, chunkNum, offsetInChunk, expectedRange[i], rangeData[i]) + break + } + } + } else { + t.Logf("✓ Successfully read %d bytes spanning chunks (offsets %d-%d)", len(rangeData), tc.rangeStart, tc.rangeEnd) + } + }) + } +} + +// TestS3VolumeEncryptionCopy tests that copying encrypted objects works correctly. +func TestS3VolumeEncryptionCopy(t *testing.T) { + svc := getS3Client(t) + bucket := fmt.Sprintf("volume-encryption-copy-%d", time.Now().Unix()) + + // Create bucket + _, err := svc.CreateBucket(&s3.CreateBucketInput{ + Bucket: aws.String(bucket), + }) + if err != nil { + t.Fatalf("Failed to create bucket: %v", err) + } + defer cleanupBucket(t, svc, bucket) + + srcKey := "source-object.txt" + dstKey := "copied-object.txt" + content := "Content to be copied with volume-level encryption" + + // Upload source + _, err = svc.PutObject(&s3.PutObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(srcKey), + Body: strings.NewReader(content), + }) + if err != nil { + t.Fatalf("PutObject failed: %v", err) + } + + // Copy object + _, err = svc.CopyObject(&s3.CopyObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(dstKey), + CopySource: aws.String(fmt.Sprintf("%s/%s", bucket, srcKey)), + }) + if err != nil { + t.Fatalf("CopyObject failed: %v", err) + } + + // Read copied object + result, err := svc.GetObject(&s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(dstKey), + }) + if err != nil { + t.Fatalf("GetObject failed for copied object: %v", err) + } + defer result.Body.Close() + + data, err := io.ReadAll(result.Body) + if err != nil { + t.Fatalf("Failed to read copied object: %v", err) + } + + if string(data) != content { + t.Errorf("Copied content mismatch:\n expected: %q\n got: %q", content, string(data)) + } else { + t.Logf("Successfully copied encrypted object") + } +} + +// Helper functions + +func getS3Client(t *testing.T) *s3.S3 { + // Use credentials that match the Makefile configuration + // ACCESS_KEY ?= some_access_key1 + // SECRET_KEY ?= some_secret_key1 + sess, err := session.NewSession(&aws.Config{ + Region: aws.String("us-east-1"), + Endpoint: aws.String("http://localhost:8333"), + DisableSSL: aws.Bool(true), + S3ForcePathStyle: aws.Bool(true), + Credentials: credentials.NewStaticCredentials("some_access_key1", "some_secret_key1", ""), + }) + if err != nil { + t.Fatalf("Failed to create session: %v", err) + } + return s3.New(sess) +} + +func cleanupBucket(t *testing.T, svc *s3.S3, bucket string) { + ctx := context.Background() + _ = ctx + + // List and delete all objects + listResult, err := svc.ListObjectsV2(&s3.ListObjectsV2Input{ + Bucket: aws.String(bucket), + }) + if err != nil { + t.Logf("Warning: failed to list objects for cleanup: %v", err) + return + } + + for _, obj := range listResult.Contents { + _, err := svc.DeleteObject(&s3.DeleteObjectInput{ + Bucket: aws.String(bucket), + Key: obj.Key, + }) + if err != nil { + t.Logf("Warning: failed to delete object %s: %v", *obj.Key, err) + } + } + + // Delete bucket + _, err = svc.DeleteBucket(&s3.DeleteBucketInput{ + Bucket: aws.String(bucket), + }) + if err != nil { + t.Logf("Warning: failed to delete bucket %s: %v", bucket, err) + } +} diff --git a/weed/command/mini.go b/weed/command/mini.go index 6aa30acbd..39a271ee9 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -229,6 +229,7 @@ func initMiniS3Flags() { miniS3Options.concurrentFileUploadLimit = cmdMini.Flag.Int("s3.concurrentFileUploadLimit", 0, "limit number of concurrent file uploads") miniS3Options.enableIam = cmdMini.Flag.Bool("s3.iam", true, "enable embedded IAM API on the same port") miniS3Options.dataCenter = cmdMini.Flag.String("s3.dataCenter", "", "prefer to read and write to volumes in this data center") + miniS3Options.cipher = cmdMini.Flag.Bool("s3.encryptVolumeData", false, "encrypt data on volume servers for S3 uploads") miniS3Options.config = miniS3Config miniS3Options.iamConfig = miniIamConfig miniS3Options.auditLogConfig = cmdMini.Flag.String("s3.auditLogConfig", "", "path to the audit log config file") diff --git a/weed/command/s3.go b/weed/command/s3.go index d7140ded5..afd7a8f9c 100644 --- a/weed/command/s3.go +++ b/weed/command/s3.go @@ -62,6 +62,7 @@ type S3Options struct { enableIam *bool debug *bool debugPort *int + cipher *bool } func init() { @@ -93,6 +94,7 @@ func init() { s3StandaloneOptions.enableIam = cmdS3.Flag.Bool("iam", true, "enable embedded IAM API on the same port") s3StandaloneOptions.debug = cmdS3.Flag.Bool("debug", false, "serves runtime profiling data via pprof on the port specified by -debug.port") s3StandaloneOptions.debugPort = cmdS3.Flag.Int("debug.port", 6060, "http port for debugging") + s3StandaloneOptions.cipher = cmdS3.Flag.Bool("encryptVolumeData", false, "encrypt data on volume servers") } var cmdS3 = &Command{ @@ -290,6 +292,7 @@ func (s3opt *S3Options) startS3Server() bool { ConcurrentUploadLimit: int64(*s3opt.concurrentUploadLimitMB) * 1024 * 1024, ConcurrentFileUploadLimit: int64(*s3opt.concurrentFileUploadLimit), EnableIam: *s3opt.enableIam, // Embedded IAM API (enabled by default) + Cipher: *s3opt.cipher, // encrypt data on volume servers }) if s3ApiServer_err != nil { glog.Fatalf("S3 API Server startup error: %v", s3ApiServer_err) diff --git a/weed/command/server.go b/weed/command/server.go index 5e59ea246..ae2e421ba 100644 --- a/weed/command/server.go +++ b/weed/command/server.go @@ -175,6 +175,7 @@ func init() { s3Options.concurrentUploadLimitMB = cmdServer.Flag.Int("s3.concurrentUploadLimitMB", 0, "limit total concurrent upload size for S3, 0 means unlimited") s3Options.concurrentFileUploadLimit = cmdServer.Flag.Int("s3.concurrentFileUploadLimit", 0, "limit number of concurrent file uploads for S3, 0 means unlimited") s3Options.enableIam = cmdServer.Flag.Bool("s3.iam", true, "enable embedded IAM API on the same S3 port") + s3Options.cipher = cmdServer.Flag.Bool("s3.encryptVolumeData", false, "encrypt data on volume servers for S3 uploads") sftpOptions.port = cmdServer.Flag.Int("sftp.port", 2022, "SFTP server listen port") sftpOptions.sshPrivateKey = cmdServer.Flag.String("sftp.sshPrivateKey", "", "path to the SSH private key file for host authentication") diff --git a/weed/operation/upload_chunked.go b/weed/operation/upload_chunked.go index 352b329f8..394bf4362 100644 --- a/weed/operation/upload_chunked.go +++ b/weed/operation/upload_chunked.go @@ -34,6 +34,7 @@ type ChunkedUploadOption struct { SaveSmallInline bool Jwt security.EncodedJwt MimeType string + Cipher bool // encrypt data on volume servers AssignFunc func(ctx context.Context, count int) (*VolumeAssignRequest, *AssignResult, error) UploadFunc func(ctx context.Context, data []byte, option *UploadOption) (*UploadResult, error) // Optional: for testing } @@ -172,7 +173,7 @@ uploadLoop: uploadOption := &UploadOption{ UploadUrl: uploadUrl, - Cipher: false, + Cipher: opt.Cipher, IsInputCompressed: false, MimeType: opt.MimeType, PairMap: nil, @@ -220,8 +221,8 @@ uploadLoop: ETag: uploadResult.ContentMd5, Fid: fid, CipherKey: uploadResult.CipherKey, + IsCompressed: uploadResult.Gzip > 0, } - fileChunksLock.Lock() fileChunks = append(fileChunks, chunk) glog.V(4).Infof("uploaded chunk %d to %s [%d,%d)", len(fileChunks), chunk.FileId, offset, offset+int64(chunk.Size)) diff --git a/weed/operation/upload_content.go b/weed/operation/upload_content.go index a2fff4792..56c358174 100644 --- a/weed/operation/upload_content.go +++ b/weed/operation/upload_content.go @@ -249,8 +249,10 @@ func (uploader *Uploader) doUploadData(ctx context.Context, data []byte, option compressed, compressErr := util.GzipData(data) // fmt.Printf("data is compressed from %d ==> %d\n", len(data), len(compressed)) if compressErr == nil { - data = compressed - contentIsGzipped = true + if len(compressed) < len(data) { + data = compressed + contentIsGzipped = true + } } } else if option.IsInputCompressed { // just to get the clear data length @@ -290,7 +292,7 @@ func (uploader *Uploader) doUploadData(ctx context.Context, data []byte, option uploadResult.Name = option.Filename uploadResult.Mime = option.MimeType uploadResult.CipherKey = cipherKey - uploadResult.Size = uint32(clearDataLen) + uploadResult.Size = uint32(len(data)) if contentIsGzipped { uploadResult.Gzip = 1 } diff --git a/weed/s3api/s3api_key_rotation.go b/weed/s3api/s3api_key_rotation.go index f2d406fb7..1881f3696 100644 --- a/weed/s3api/s3api_key_rotation.go +++ b/weed/s3api/s3api_key_rotation.go @@ -182,7 +182,7 @@ func (s3a *S3ApiServer) rotateSSECChunk(chunk *filer_pb.FileChunk, sourceKey, de } // Download encrypted data - encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size)) + encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey) if err != nil { return nil, fmt.Errorf("download chunk data: %w", err) } @@ -251,7 +251,7 @@ func (s3a *S3ApiServer) rotateSSEKMSChunk(chunk *filer_pb.FileChunk, srcKeyID, d } // Download data (this would be encrypted with the old KMS key) - chunkData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size)) + chunkData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey) if err != nil { return nil, fmt.Errorf("download chunk data: %w", err) } diff --git a/weed/s3api/s3api_object_handlers.go b/weed/s3api/s3api_object_handlers.go index 67c40d0c3..1a4e104cf 100644 --- a/weed/s3api/s3api_object_handlers.go +++ b/weed/s3api/s3api_object_handlers.go @@ -1019,6 +1019,8 @@ func (s3a *S3ApiServer) streamFromVolumeServers(w http.ResponseWriter, r *http.R if isRangeRequest { w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", offset, offset+size-1, totalSize)) w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + } else { + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) } headerSetTime = time.Since(tHeaderSet) diff --git a/weed/s3api/s3api_object_handlers_copy.go b/weed/s3api/s3api_object_handlers_copy.go index 26775f9ae..d4ef3b52e 100644 --- a/weed/s3api/s3api_object_handlers_copy.go +++ b/weed/s3api/s3api_object_handlers_copy.go @@ -846,7 +846,7 @@ func (s3a *S3ApiServer) copySingleChunk(chunk *filer_pb.FileChunk, dstPath strin } // Download and upload the chunk - chunkData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size)) + chunkData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey) if err != nil { return nil, fmt.Errorf("download chunk data: %w", err) } @@ -881,7 +881,7 @@ func (s3a *S3ApiServer) copySingleChunkForRange(originalChunk, rangeChunk *filer offsetInChunk := overlapStart - chunkStart // Download and upload the chunk portion - chunkData, err := s3a.downloadChunkData(srcUrl, fileId, offsetInChunk, int64(rangeChunk.Size)) + chunkData, err := s3a.downloadChunkData(srcUrl, fileId, offsetInChunk, int64(rangeChunk.Size), originalChunk.CipherKey) if err != nil { return nil, fmt.Errorf("download chunk range data: %w", err) } @@ -1199,10 +1199,40 @@ func (s3a *S3ApiServer) uploadChunkData(chunkData []byte, assignResult *filer_pb } // downloadChunkData downloads chunk data from the source URL -func (s3a *S3ApiServer) downloadChunkData(srcUrl, fileId string, offset, size int64) ([]byte, error) { +func (s3a *S3ApiServer) downloadChunkData(srcUrl, fileId string, offset, size int64, cipherKey []byte) ([]byte, error) { jwt := filer.JwtForVolumeServer(fileId) + // Only perform HEAD request for encrypted chunks to get physical size + if offset == 0 && len(cipherKey) > 0 { + req, err := http.NewRequest(http.MethodHead, srcUrl, nil) + if err == nil { + if jwt != "" { + req.Header.Set("Authorization", "BEARER "+string(jwt)) + } + resp, err := util_http.GetGlobalHttpClient().Do(req) + if err == nil { + defer util_http.CloseResponse(resp) + if resp.StatusCode == http.StatusOK { + contentLengthStr := resp.Header.Get("Content-Length") + if contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64); err == nil { + // Validate contentLength fits in int32 range before comparison + if contentLength > int64(2147483647) { // math.MaxInt32 + return nil, fmt.Errorf("content length %d exceeds maximum int32 size", contentLength) + } + if contentLength > size { + size = contentLength + } + } + } + } + } + } + // Validate size fits in int32 range before conversion to int + if size > int64(2147483647) { // math.MaxInt32 + return nil, fmt.Errorf("chunk size %d exceeds maximum int32 size", size) + } + sizeInt := int(size) var chunkData []byte - shouldRetry, err := util_http.ReadUrlAsStream(context.Background(), srcUrl, jwt, nil, false, false, offset, int(size), func(data []byte) { + shouldRetry, err := util_http.ReadUrlAsStream(context.Background(), srcUrl, jwt, nil, false, false, offset, sizeInt, func(data []byte) { chunkData = append(chunkData, data...) }) if err != nil { @@ -1334,7 +1364,7 @@ func (s3a *S3ApiServer) copyMultipartSSEKMSChunk(chunk *filer_pb.FileChunk, dest } // Download encrypted chunk data - encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size)) + encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey) if err != nil { return nil, fmt.Errorf("download encrypted chunk data: %w", err) } @@ -1433,7 +1463,7 @@ func (s3a *S3ApiServer) copyMultipartSSECChunk(chunk *filer_pb.FileChunk, copySo } // Download encrypted chunk data - encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size)) + encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey) if err != nil { return nil, nil, fmt.Errorf("download encrypted chunk data: %w", err) } @@ -1714,7 +1744,7 @@ func (s3a *S3ApiServer) copyCrossEncryptionChunk(chunk *filer_pb.FileChunk, sour } // Download encrypted chunk data - encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size)) + encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey) if err != nil { return nil, fmt.Errorf("download encrypted chunk data: %w", err) } @@ -2076,7 +2106,7 @@ func (s3a *S3ApiServer) copyChunkWithReencryption(chunk *filer_pb.FileChunk, cop } // Download encrypted chunk data - encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size)) + encryptedData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey) if err != nil { return nil, fmt.Errorf("download encrypted chunk data: %w", err) } @@ -2295,7 +2325,7 @@ func (s3a *S3ApiServer) copyChunkWithSSEKMSReencryption(chunk *filer_pb.FileChun } // Download chunk data - chunkData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size)) + chunkData, err := s3a.downloadChunkData(srcUrl, fileId, 0, int64(chunk.Size), chunk.CipherKey) if err != nil { return nil, fmt.Errorf("download chunk data: %w", err) } diff --git a/weed/s3api/s3api_object_handlers_put.go b/weed/s3api/s3api_object_handlers_put.go index e9e523138..959893e57 100644 --- a/weed/s3api/s3api_object_handlers_put.go +++ b/weed/s3api/s3api_object_handlers_put.go @@ -299,7 +299,7 @@ func (s3a *S3ApiServer) putToFiler(r *http.Request, filePath string, dataReader // Apply bucket default encryption if no explicit encryption was provided // This implements AWS S3 behavior where bucket default encryption automatically applies - if !hasExplicitEncryption(customerKey, sseKMSKey, sseS3Key) { + if !hasExplicitEncryption(customerKey, sseKMSKey, sseS3Key) && !s3a.cipher { glog.V(4).Infof("putToFiler: no explicit encryption detected, checking for bucket default encryption") // Apply bucket default encryption and get the result @@ -392,6 +392,7 @@ func (s3a *S3ApiServer) putToFiler(r *http.Request, filePath string, dataReader DataCenter: s3a.option.DataCenter, SaveSmallInline: false, // S3 API always creates chunks, never stores inline MimeType: r.Header.Get("Content-Type"), + Cipher: s3a.cipher, // encrypt data on volume servers AssignFunc: assignFunc, }) if err != nil { diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index ffb50e8c1..7a8062a7a 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -51,6 +51,7 @@ type S3ApiServerOption struct { ConcurrentUploadLimit int64 ConcurrentFileUploadLimit int64 EnableIam bool // Enable embedded IAM API on the same port + Cipher bool // encrypt data on volume servers } type S3ApiServer struct { @@ -70,7 +71,8 @@ type S3ApiServer struct { inFlightDataSize int64 inFlightUploads int64 inFlightDataLimitCond *sync.Cond - embeddedIam *EmbeddedIamApi // Embedded IAM API server (when enabled) + embeddedIam *EmbeddedIamApi // Embedded IAM API server (when enabled) + cipher bool // encrypt data on volume servers } func NewS3ApiServer(router *mux.Router, option *S3ApiServerOption) (s3ApiServer *S3ApiServer, err error) { @@ -154,6 +156,7 @@ func NewS3ApiServerWithStore(router *mux.Router, option *S3ApiServerOption, expl bucketConfigCache: NewBucketConfigCache(60 * time.Minute), // Increased TTL since cache is now event-driven policyEngine: policyEngine, // Initialize bucket policy engine inFlightDataLimitCond: sync.NewCond(new(sync.Mutex)), + cipher: option.Cipher, } // Set s3a reference in circuit breaker for upload limiting From 6de6061ce9aaa5637d001453e07f079f3aaaf05b Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sat, 27 Dec 2025 02:12:57 -0800 Subject: [PATCH 42/66] admin: add cursor-based pagination to file browser (#7891) * adjust menu items * admin: add cursor-based pagination to file browser - Implement cursor-based pagination using lastFileName parameter - Add customizable page size selector (20/50/100/200 entries) - Add compact pagination controls in header and footer - Remove summary cards for cleaner UI - Make directory names clickable to return to first page - Support forward-only navigation (Next button) - Preserve cursor position when changing page size - Remove sorting to align with filer's storage order approach * Update file_browser_templ.go * admin: remove directory icons from breadcrumbs * Update file_browser_templ.go * admin: address PR comments - Fix fragile EOF check: use io.EOF instead of string comparison - Cap page size at 200 to prevent potential DoS - Remove unused helper functions from template - Use safer templ script for page size selector to prevent XSS * admin: cleanup redundant first button * Update file_browser_templ.go * admin: remove entry counting logic * admin: remove unused variables in file browser data * admin: remove unused logic for FirstFileName and HasPrevPage * admin: remove unused TotalEntries and TotalSize fields * Update file_browser_data.go --- weed/admin/dash/file_browser_data.go | 244 +++++----- weed/admin/handlers/file_browser_handlers.go | 15 +- weed/admin/view/app/file_browser.templ | 183 +++---- weed/admin/view/app/file_browser_templ.go | 473 ++++++++++++------- weed/admin/view/layout/layout.templ | 13 +- weed/admin/view/layout/layout_templ.go | 32 +- 6 files changed, 541 insertions(+), 419 deletions(-) diff --git a/weed/admin/dash/file_browser_data.go b/weed/admin/dash/file_browser_data.go index bd561e5ad..6e6e44c9d 100644 --- a/weed/admin/dash/file_browser_data.go +++ b/weed/admin/dash/file_browser_data.go @@ -3,7 +3,6 @@ package dash import ( "context" "path" - "sort" "strings" "time" @@ -34,34 +33,49 @@ type BreadcrumbItem struct { // FileBrowserData contains all data needed for the file browser view type FileBrowserData struct { - Username string `json:"username"` - CurrentPath string `json:"current_path"` - ParentPath string `json:"parent_path"` - Breadcrumbs []BreadcrumbItem `json:"breadcrumbs"` - Entries []FileEntry `json:"entries"` - TotalEntries int `json:"total_entries"` - TotalSize int64 `json:"total_size"` - LastUpdated time.Time `json:"last_updated"` - IsBucketPath bool `json:"is_bucket_path"` - BucketName string `json:"bucket_name"` + Username string `json:"username"` + CurrentPath string `json:"current_path"` + ParentPath string `json:"parent_path"` + Breadcrumbs []BreadcrumbItem `json:"breadcrumbs"` + Entries []FileEntry `json:"entries"` + + LastUpdated time.Time `json:"last_updated"` + IsBucketPath bool `json:"is_bucket_path"` + BucketName string `json:"bucket_name"` + // Pagination fields + PageSize int `json:"page_size"` + HasNextPage bool `json:"has_next_page"` + LastFileName string `json:"last_file_name"` // Cursor for next page + CurrentLastFileName string `json:"current_last_file_name"` // Cursor from current request (for page size changes) } -// GetFileBrowser retrieves file browser data for a given path -func (s *AdminServer) GetFileBrowser(dir string) (*FileBrowserData, error) { +// GetFileBrowser retrieves file browser data for a given path with cursor-based pagination +func (s *AdminServer) GetFileBrowser(dir string, lastFileName string, pageSize int) (*FileBrowserData, error) { if dir == "" { dir = "/" } + // Set defaults for pagination + if pageSize < 1 { + pageSize = 20 // Default page size + } + var entries []FileEntry - var totalSize int64 + + // Fetch entries using cursor-based pagination + // We fetch pageSize+1 to determine if there's a next page + fetchLimit := pageSize + 1 + var fetchedCount int + var lastEntryName string - // Get directory listing from filer err := s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error { + // Fetch entries starting from the cursor (lastFileName) stream, err := client.ListEntries(context.Background(), &filer_pb.ListEntriesRequest{ Directory: dir, Prefix: "", - Limit: 1000, - InclusiveStartFrom: false, + Limit: uint32(fetchLimit), + StartFromFileName: lastFileName, + InclusiveStartFrom: false, // Don't include the cursor file itself }) if err != nil { return err @@ -81,97 +95,102 @@ func (s *AdminServer) GetFileBrowser(dir string) (*FileBrowserData, error) { continue } - fullPath := path.Join(dir, entry.Name) + fetchedCount++ - var modTime time.Time - if entry.Attributes != nil && entry.Attributes.Mtime > 0 { - modTime = time.Unix(entry.Attributes.Mtime, 0) - } + // Only add entries up to pageSize (the +1 is just to check for next page) + if fetchedCount <= pageSize { + fullPath := path.Join(dir, entry.Name) - var mode string - var uid, gid uint32 - var size int64 - var replication, collection string - var ttlSec int32 - - if entry.Attributes != nil { - mode = FormatFileMode(entry.Attributes.FileMode) - uid = entry.Attributes.Uid - gid = entry.Attributes.Gid - size = int64(entry.Attributes.FileSize) - ttlSec = entry.Attributes.TtlSec - } - - // Get replication and collection from entry extended attributes or chunks - if entry.Extended != nil { - if repl, ok := entry.Extended["replication"]; ok { - replication = string(repl) + var modTime time.Time + if entry.Attributes != nil && entry.Attributes.Mtime > 0 { + modTime = time.Unix(entry.Attributes.Mtime, 0) } - if coll, ok := entry.Extended["collection"]; ok { - collection = string(coll) + + var mode string + var uid, gid uint32 + var size int64 + var replication, collection string + var ttlSec int32 + + if entry.Attributes != nil { + mode = FormatFileMode(entry.Attributes.FileMode) + uid = entry.Attributes.Uid + gid = entry.Attributes.Gid + size = int64(entry.Attributes.FileSize) + ttlSec = entry.Attributes.TtlSec } - } - // Determine MIME type based on file extension - mime := "application/octet-stream" - if entry.IsDirectory { - mime = "inode/directory" - } else { - ext := strings.ToLower(path.Ext(entry.Name)) - switch ext { - case ".txt", ".log": - mime = "text/plain" - case ".html", ".htm": - mime = "text/html" - case ".css": - mime = "text/css" - case ".js": - mime = "application/javascript" - case ".json": - mime = "application/json" - case ".xml": - mime = "application/xml" - case ".pdf": - mime = "application/pdf" - case ".jpg", ".jpeg": - mime = "image/jpeg" - case ".png": - mime = "image/png" - case ".gif": - mime = "image/gif" - case ".svg": - mime = "image/svg+xml" - case ".mp4": - mime = "video/mp4" - case ".mp3": - mime = "audio/mpeg" - case ".zip": - mime = "application/zip" - case ".tar": - mime = "application/x-tar" - case ".gz": - mime = "application/gzip" + // Get replication and collection from entry extended attributes + if entry.Extended != nil { + if repl, ok := entry.Extended["replication"]; ok { + replication = string(repl) + } + if coll, ok := entry.Extended["collection"]; ok { + collection = string(coll) + } } - } - fileEntry := FileEntry{ - Name: entry.Name, - FullPath: fullPath, - IsDirectory: entry.IsDirectory, - Size: size, - ModTime: modTime, - Mode: mode, - Uid: uid, - Gid: gid, - Mime: mime, - Replication: replication, - Collection: collection, - TtlSec: ttlSec, - } + // Determine MIME type based on file extension + mime := "application/octet-stream" + if entry.IsDirectory { + mime = "inode/directory" + } else { + ext := strings.ToLower(path.Ext(entry.Name)) + switch ext { + case ".txt", ".log": + mime = "text/plain" + case ".html", ".htm": + mime = "text/html" + case ".css": + mime = "text/css" + case ".js": + mime = "application/javascript" + case ".json": + mime = "application/json" + case ".xml": + mime = "application/xml" + case ".pdf": + mime = "application/pdf" + case ".jpg", ".jpeg": + mime = "image/jpeg" + case ".png": + mime = "image/png" + case ".gif": + mime = "image/gif" + case ".svg": + mime = "image/svg+xml" + case ".mp4": + mime = "video/mp4" + case ".mp3": + mime = "audio/mpeg" + case ".zip": + mime = "application/zip" + case ".tar": + mime = "application/x-tar" + case ".gz": + mime = "application/gzip" + } + } + + fileEntry := FileEntry{ + Name: entry.Name, + FullPath: fullPath, + IsDirectory: entry.IsDirectory, + Size: size, + ModTime: modTime, + Mode: mode, + Uid: uid, + Gid: gid, + Mime: mime, + Replication: replication, + Collection: collection, + TtlSec: ttlSec, + } + + entries = append(entries, fileEntry) + + lastEntryName = entry.Name - entries = append(entries, fileEntry) - if !entry.IsDirectory { - totalSize += size } } @@ -182,13 +201,8 @@ func (s *AdminServer) GetFileBrowser(dir string) (*FileBrowserData, error) { return nil, err } - // Sort entries: directories first, then files, both alphabetically - sort.Slice(entries, func(i, j int) bool { - if entries[i].IsDirectory != entries[j].IsDirectory { - return entries[i].IsDirectory - } - return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) - }) + // Determine if there's a next page + hasNextPage := fetchedCount > pageSize // Generate breadcrumbs breadcrumbs := s.generateBreadcrumbs(dir) @@ -214,15 +228,19 @@ func (s *AdminServer) GetFileBrowser(dir string) (*FileBrowserData, error) { } return &FileBrowserData{ - CurrentPath: dir, - ParentPath: parentPath, - Breadcrumbs: breadcrumbs, - Entries: entries, - TotalEntries: len(entries), - TotalSize: totalSize, + CurrentPath: dir, + ParentPath: parentPath, + Breadcrumbs: breadcrumbs, + Entries: entries, + LastUpdated: time.Now(), IsBucketPath: isBucketPath, BucketName: bucketName, + // Pagination metadata + PageSize: pageSize, + HasNextPage: hasNextPage, + LastFileName: lastEntryName, // Store for next page navigation + CurrentLastFileName: lastFileName, // Store input cursor for page size changes }, nil } diff --git a/weed/admin/handlers/file_browser_handlers.go b/weed/admin/handlers/file_browser_handlers.go index d8f79337d..b7c44a69d 100644 --- a/weed/admin/handlers/file_browser_handlers.go +++ b/weed/admin/handlers/file_browser_handlers.go @@ -63,8 +63,19 @@ func (h *FileBrowserHandlers) ShowFileBrowser(c *gin.Context) { // Normalize Windows-style paths for consistency path = util.CleanWindowsPath(path) - // Get file browser data - browserData, err := h.adminServer.GetFileBrowser(path) + // Get pagination parameters + lastFileName := c.DefaultQuery("lastFileName", "") + + pageSize, err := strconv.Atoi(c.DefaultQuery("limit", "20")) + if err != nil || pageSize < 1 { + pageSize = 20 + } + if pageSize > 200 { + pageSize = 200 + } + + // Get file browser data with cursor-based pagination + browserData, err := h.adminServer.GetFileBrowser(path, lastFileName, pageSize) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get file browser data: " + err.Error()}) return diff --git a/weed/admin/view/app/file_browser.templ b/weed/admin/view/app/file_browser.templ index 83db7df0f..6ae00b81b 100644 --- a/weed/admin/view/app/file_browser.templ +++ b/weed/admin/view/app/file_browser.templ @@ -7,6 +7,10 @@ import ( "github.com/seaweedfs/seaweedfs/weed/admin/dash" ) +script changePageSize(path string, lastFileName string) { + window.location.href = '/files?path=' + encodeURIComponent(path) + '&lastFileName=' + encodeURIComponent(lastFileName) + '&limit=' + this.value +} + templ FileBrowser(data dash.FileBrowserData) {

@@ -45,15 +49,15 @@ templ FileBrowser(data dash.FileBrowserData) { for i, crumb := range data.Breadcrumbs { if i == len(data.Breadcrumbs)-1 { } else {
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" class=\"text-decoration-none\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
  • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if crumb.Name == "Root" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Name) + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 58, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 62, Col: 19} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -137,161 +155,211 @@ func FileBrowser(data dash.FileBrowserData) templ.Component { } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
    Total Entries
    ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TotalEntries)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 77, Col: 46} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
    Directories
    ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", countDirectories(data.Entries))) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 97, Col: 59} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
    Files
    ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", countFiles(data.Entries))) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 117, Col: 53} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
    Total Size
    ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(data.TotalSize)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 137, Col: 37} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.CurrentPath == "/" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Root Directory") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "Root Directory") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if data.CurrentPath == "/buckets" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Object Store Buckets Directory Manage Buckets") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "Object Store Buckets Directory Manage Buckets") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(filepath.Base(data.CurrentPath)) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(filepath.Base(data.CurrentPath)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 83, Col: 154} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, changePageSize(data.CurrentPath, data.CurrentLastFileName)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.HasNextPage { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" class=\"btn btn-outline-primary\" title=\"Next page\">Next ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } if data.ParentPath != data.CurrentPath { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "Up") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" class=\"btn btn-outline-secondary\" title=\"Go up one directory\"> Up") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(data.Entries) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
    NameSizeTypeModifiedPermissionsActions
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, entry := range data.Entries { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
    NameSizeTypeModifiedPermissionsActions
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\">
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if entry.IsDirectory { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" class=\"text-decoration-none\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 199, Col: 25} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 144, Col: 25} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -301,7 +369,7 @@ func FileBrowser(data dash.FileBrowserData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 203, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 148, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if entry.IsDirectory { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -345,19 +413,19 @@ func FileBrowser(data dash.FileBrowserData) templ.Component { var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(formatBytes(entry.Size)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 211, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 156, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if entry.IsDirectory { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "Directory") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "Directory") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -365,14 +433,14 @@ func FileBrowser(data dash.FileBrowserData) templ.Component { var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(getMimeDisplayName(entry.Mime)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 219, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 164, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -380,174 +448,259 @@ func FileBrowser(data dash.FileBrowserData) templ.Component { var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(entry.ModTime.Format("2006-01-02 15:04")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 225, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 170, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var23 string templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Mode) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 231, Col: 146} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 176, Col: 146} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !entry.IsDirectory { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
    Empty Directory

    This directory contains no files or subdirectories.

    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
    Empty Directory

    This directory contains no files or subdirectories.

    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "
    Last updated: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var28 string - templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 271, Col: 66} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, changePageSize(data.CurrentPath, data.CurrentLastFileName)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
    Create New Folder
    Folder names cannot contain / or \\ characters.
    Upload Files
    Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking.
    entries per page
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if data.HasNextPage { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "Next ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
    Last updated: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var30 string - templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentPath) + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastUpdated.Format("2006-01-02 15:04:05")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 328, Col: 79} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/file_browser.templ`, Line: 246, Col: 66} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\">
    0%
    Preparing upload...
    ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
  • Create New Folder
    Folder names cannot contain / or \\ characters.
    Upload Files
    Choose one or more files to upload to the current directory. You can select multiple files by holding Ctrl (Cmd on Mac) while clicking.
    0%
    Preparing upload...
    ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -555,26 +708,6 @@ func FileBrowser(data dash.FileBrowserData) templ.Component { }) } -func countDirectories(entries []dash.FileEntry) int { - count := 0 - for _, entry := range entries { - if entry.IsDirectory { - count++ - } - } - return count -} - -func countFiles(entries []dash.FileEntry) int { - count := 0 - for _, entry := range entries { - if !entry.IsDirectory { - count++ - } - } - return count -} - func getFileIcon(mime string) string { switch { case strings.HasPrefix(mime, "image/"): diff --git a/weed/admin/view/layout/layout.templ b/weed/admin/view/layout/layout.templ index cd192fa44..85eb8beed 100644 --- a/weed/admin/view/layout/layout.templ +++ b/weed/admin/view/layout/layout.templ @@ -218,6 +218,8 @@ templ Layout(c *gin.Context, content templ.Component) { } + +
    SYSTEM
    • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
    MAINTENANCE
    • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -151,7 +151,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var3 templ.SafeURL templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 258, Col: 117} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 261, Col: 117} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -186,7 +186,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 259, Col: 109} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 262, Col: 109} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -204,7 +204,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var7 templ.SafeURL templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 262, Col: 110} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 265, Col: 110} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -239,7 +239,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 263, Col: 109} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 266, Col: 109} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -272,7 +272,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var11 templ.SafeURL templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 275, Col: 106} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 278, Col: 106} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -307,7 +307,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 276, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 279, Col: 105} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -328,12 +328,12 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { return templ_7745c5c3_Err } if currentPath == "/maintenance" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Maintenance Queue") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "Job Queue") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "Maintenance Queue") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "Job Queue") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -343,12 +343,12 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { return templ_7745c5c3_Err } if currentPath == "/maintenance/workers" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "Maintenance Workers") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "Workers") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "Maintenance Workers") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "Workers") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -368,7 +368,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year())) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 323, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 326, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -381,7 +381,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 323, Col: 102} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 326, Col: 102} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -433,7 +433,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 347, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 350, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { @@ -446,7 +446,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 361, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 364, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -464,7 +464,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 368, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 371, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { From ef20873c31e64cd5d47fb004eb5bfad4e08f3468 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sat, 27 Dec 2025 12:25:33 -0800 Subject: [PATCH 43/66] S3: Fix Content-Encoding header not preserved (#7894) (#7895) * S3: Fix Content-Encoding header not preserved (#7894) The Content-Encoding header was not being returned in S3 GET/HEAD responses because it wasn't being stored in metadata during PUT operations. Root cause: The putToFiler function only stored a hardcoded list of standard HTTP headers (Cache-Control, Expires, Content-Disposition) but was missing Content-Encoding and Content-Language. Fix: Added Content-Encoding and Content-Language to the list of standard headers that are stored in entry.Extended during PUT operations. This matches the behavior of ParseS3Metadata (used for multipart uploads) and ensures consistency across all S3 operations. Fixes #7894 * Update s3api_object_handlers_put.go --- weed/s3api/s3_content_encoding_test.go | 204 ++++++++++++++++++++++++ weed/s3api/s3api_object_handlers_put.go | 7 +- 2 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 weed/s3api/s3_content_encoding_test.go diff --git a/weed/s3api/s3_content_encoding_test.go b/weed/s3api/s3_content_encoding_test.go new file mode 100644 index 000000000..a50a8bb3c --- /dev/null +++ b/weed/s3api/s3_content_encoding_test.go @@ -0,0 +1,204 @@ +package s3api + +import ( + "bytes" + "net/http/httptest" + "testing" + + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestContentEncodingPreservation tests that Content-Encoding and Content-Language headers +// are preserved during S3 PUT and GET operations. +// This is a regression test for issue #7894 +func TestContentEncodingPreservation(t *testing.T) { + testCases := []struct { + name string + contentEncoding string + contentLanguage string + body string + }{ + { + name: "gzip encoding", + contentEncoding: "gzip", + contentLanguage: "", + body: "Hello, SeaweedFS with gzip!", + }, + { + name: "zstd encoding", + contentEncoding: "zstd", + contentLanguage: "", + body: "Hello, SeaweedFS with zstd!", + }, + { + name: "deflate encoding", + contentEncoding: "deflate", + contentLanguage: "", + body: "Hello, SeaweedFS with deflate!", + }, + { + name: "br (Brotli) encoding", + contentEncoding: "br", + contentLanguage: "", + body: "Hello, SeaweedFS with Brotli!", + }, + { + name: "multiple encodings", + contentEncoding: "gzip, deflate", + contentLanguage: "", + body: "Hello, SeaweedFS with multiple encodings!", + }, + { + name: "encoding with language", + contentEncoding: "gzip", + contentLanguage: "en-US", + body: "Hello, SeaweedFS with language!", + }, + { + name: "language only", + contentEncoding: "", + contentLanguage: "fr-FR", + body: "Bonjour, SeaweedFS!", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create a mock S3 API server + s3a := &S3ApiServer{ + option: &S3ApiServerOption{ + BucketsPath: "/tmp/test-buckets", + }, + } + + bucket := "test-bucket" + key := "test-object.txt" + + // Create PUT request with Content-Encoding and/or Content-Language headers + putReq := httptest.NewRequest("PUT", "/"+bucket+"/"+key, bytes.NewBufferString(tc.body)) + putReq.Header.Set("Content-Type", "text/plain") + if tc.contentEncoding != "" { + putReq.Header.Set("Content-Encoding", tc.contentEncoding) + } + if tc.contentLanguage != "" { + putReq.Header.Set("Content-Language", tc.contentLanguage) + } + + // Test that ParseS3Metadata correctly extracts the headers + metadata, errCode := ParseS3Metadata(putReq, nil, false) + require.Equal(t, 0, int(errCode), "ParseS3Metadata should succeed") + + // Verify Content-Encoding is stored in metadata + if tc.contentEncoding != "" { + assert.Equal(t, []byte(tc.contentEncoding), metadata["Content-Encoding"], + "Content-Encoding should be stored in metadata") + } else { + assert.NotContains(t, metadata, "Content-Encoding", + "Content-Encoding should not be in metadata when not provided") + } + + // Verify Content-Language is stored in metadata + if tc.contentLanguage != "" { + assert.Equal(t, []byte(tc.contentLanguage), metadata["Content-Language"], + "Content-Language should be stored in metadata") + } else { + assert.NotContains(t, metadata, "Content-Language", + "Content-Language should not be in metadata when not provided") + } + + // Simulate GET response - verify headers are set correctly + getResp := httptest.NewRecorder() + getReq := httptest.NewRequest("GET", "/"+bucket+"/"+key, nil) + + // Create a mock entry with the metadata + entry := &filer_pb.Entry{ + Name: key, + Attributes: &filer_pb.FuseAttributes{ + Mtime: 1234567890, + FileSize: uint64(len(tc.body)), + Mime: "text/plain", + }, + Extended: metadata, + } + + // Call setResponseHeaders to set headers from metadata + s3a.setResponseHeaders(getResp, getReq, entry, int64(len(tc.body))) + + // Verify Content-Encoding header is returned + if tc.contentEncoding != "" { + actualEncoding := getResp.Header().Get("Content-Encoding") + assert.Equal(t, tc.contentEncoding, actualEncoding, + "Content-Encoding header should be preserved in GET response") + } else { + assert.Empty(t, getResp.Header().Get("Content-Encoding"), + "Content-Encoding should not be set when not provided") + } + + // Verify Content-Language header is returned + if tc.contentLanguage != "" { + actualLanguage := getResp.Header().Get("Content-Language") + assert.Equal(t, tc.contentLanguage, actualLanguage, + "Content-Language header should be preserved in GET response") + } else { + assert.Empty(t, getResp.Header().Get("Content-Language"), + "Content-Language should not be set when not provided") + } + }) + } +} + +// TestContentEncodingWithOtherHeaders verifies that Content-Encoding works +// correctly alongside other standard headers +func TestContentEncodingWithOtherHeaders(t *testing.T) { + s3a := &S3ApiServer{ + option: &S3ApiServerOption{ + BucketsPath: "/tmp/test-buckets", + }, + } + + bucket := "test-bucket" + key := "test-object.txt" + body := "Test content" + + // Create PUT request with multiple headers + putReq := httptest.NewRequest("PUT", "/"+bucket+"/"+key, bytes.NewBufferString(body)) + putReq.Header.Set("Content-Type", "text/plain") + putReq.Header.Set("Content-Encoding", "gzip") + putReq.Header.Set("Content-Language", "en-US") + putReq.Header.Set("Cache-Control", "max-age=3600") + putReq.Header.Set("Content-Disposition", "attachment; filename=test.txt") + + // Parse metadata + metadata, errCode := ParseS3Metadata(putReq, nil, false) + require.Equal(t, 0, int(errCode)) + + // Verify all headers are stored + assert.Equal(t, []byte("gzip"), metadata["Content-Encoding"]) + assert.Equal(t, []byte("en-US"), metadata["Content-Language"]) + assert.Equal(t, []byte("max-age=3600"), metadata["Cache-Control"]) + assert.Equal(t, []byte("attachment; filename=test.txt"), metadata["Content-Disposition"]) + + // Simulate GET response + getResp := httptest.NewRecorder() + getReq := httptest.NewRequest("GET", "/"+bucket+"/"+key, nil) + + entry := &filer_pb.Entry{ + Name: key, + Attributes: &filer_pb.FuseAttributes{ + Mtime: 1234567890, + FileSize: uint64(len(body)), + Mime: "text/plain", + }, + Extended: metadata, + } + + s3a.setResponseHeaders(getResp, getReq, entry, int64(len(body))) + + // Verify all headers are returned + assert.Equal(t, "gzip", getResp.Header().Get("Content-Encoding")) + assert.Equal(t, "en-US", getResp.Header().Get("Content-Language")) + assert.Equal(t, "max-age=3600", getResp.Header().Get("Cache-Control")) + assert.Equal(t, "attachment; filename=test.txt", getResp.Header().Get("Content-Disposition")) +} diff --git a/weed/s3api/s3api_object_handlers_put.go b/weed/s3api/s3api_object_handlers_put.go index 959893e57..9e325801d 100644 --- a/weed/s3api/s3api_object_handlers_put.go +++ b/weed/s3api/s3api_object_handlers_put.go @@ -559,8 +559,11 @@ func (s3a *S3ApiServer) putToFiler(r *http.Request, filePath string, dataReader // Go's HTTP server canonicalizes headers (e.g., x-amz-meta-foo → X-Amz-Meta-Foo) // We store them as they come in (after canonicalization) to preserve the user's intent entry.Extended[k] = []byte(v[0]) - } else if k == "Cache-Control" || k == "Expires" || k == "Content-Disposition" { - entry.Extended[k] = []byte(v[0]) + } else { + switch k { + case "Cache-Control", "Expires", "Content-Disposition", "Content-Encoding", "Content-Language": + entry.Extended[k] = []byte(v[0]) + } } if k == "Response-Content-Disposition" { entry.Extended["Content-Disposition"] = []byte(v[0]) From 915a7d4a54f30e7e7de3fcc2941ecebca1e5ad30 Mon Sep 17 00:00:00 2001 From: Sheya Bernstein Date: Sat, 27 Dec 2025 21:40:05 +0000 Subject: [PATCH 44/66] feat: Add probes to worker service (#7896) * feat: Add probes to worker service * feat: Add probes to worker service * Merge branch 'master' into pr/7896 * refactor --------- Co-authored-by: Chris Lu --- .gitignore | 4 ++++ k8s/charts/seaweedfs/values.yaml | 4 ++-- weed/command/worker.go | 38 ++++++++++++++++++++++++++++---- weed/worker/worker.go | 4 ++++ 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index b895a8f08..10bc81f63 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,7 @@ test/s3/remote_cache/primary-server.pid /test/erasure_coding/filerldb2 /test/s3/cors/test-mini-data /test/s3/filer_group/test-volume-data + +# ID and PID files +*.id +*.pid diff --git a/k8s/charts/seaweedfs/values.yaml b/k8s/charts/seaweedfs/values.yaml index c4fd3a841..a94d7f183 100644 --- a/k8s/charts/seaweedfs/values.yaml +++ b/k8s/charts/seaweedfs/values.yaml @@ -1306,7 +1306,7 @@ worker: extraEnvironmentVars: {} # Health checks for worker pods - # Workers expose metrics on the metricsPort with a /health endpoint for readiness checks. + # Workers expose /health (liveness) and /ready (readiness) endpoints on the metricsPort livenessProbe: enabled: true httpGet: @@ -1321,7 +1321,7 @@ worker: readinessProbe: enabled: true httpGet: - path: /health + path: /ready port: metrics initialDelaySeconds: 20 periodSeconds: 15 diff --git a/weed/command/worker.go b/weed/command/worker.go index 84ea55a0d..1ff6678a0 100644 --- a/weed/command/worker.go +++ b/weed/command/worker.go @@ -1,6 +1,7 @@ package command import ( + "net/http" "os" "os/signal" "path/filepath" @@ -13,6 +14,7 @@ import ( statsCollect "github.com/seaweedfs/seaweedfs/weed/stats" "github.com/seaweedfs/seaweedfs/weed/util" "github.com/seaweedfs/seaweedfs/weed/util/grace" + "github.com/seaweedfs/seaweedfs/weed/util/version" "github.com/seaweedfs/seaweedfs/weed/worker" "github.com/seaweedfs/seaweedfs/weed/worker/tasks" "github.com/seaweedfs/seaweedfs/weed/worker/types" @@ -24,6 +26,7 @@ import ( // TODO: Implement additional task packages (add to default capabilities when ready): // _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/remote" - for uploading volumes to remote/cloud storage // _ "github.com/seaweedfs/seaweedfs/weed/worker/tasks/replication" - for fixing replication issues and maintaining data consistency + "github.com/prometheus/client_golang/prometheus/promhttp" ) var cmdWorker = &Command{ @@ -57,6 +60,8 @@ var ( workerMetricsIp = cmdWorker.Flag.String("metricsIp", "0.0.0.0", "Prometheus metrics listen IP") workerDebug = cmdWorker.Flag.Bool("debug", false, "serves runtime profiling data via pprof on the port specified by -debug.port") workerDebugPort = cmdWorker.Flag.Int("debug.port", 6060, "http port for debugging") + + workerServerHeader = "SeaweedFS Worker " + version.VERSION ) func init() { @@ -257,8 +262,33 @@ type WorkerStatus struct { TasksFailed int `json:"tasks_failed"` } -// startWorkerMetricsServer starts the HTTP metrics server for the worker -func startWorkerMetricsServer(ip string, port int, _ *worker.Worker) { - // Use the standard SeaweedFS metrics server for consistency with other components - statsCollect.StartMetricsServer(ip, port) +func workerHealthHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Server", workerServerHeader) + w.WriteHeader(http.StatusOK) +} + +func workerReadyHandler(workerInstance *worker.Worker) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Server", workerServerHeader) + + admin := workerInstance.GetAdmin() + if admin == nil || !admin.IsConnected() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + w.WriteHeader(http.StatusOK) + } +} + +func startWorkerMetricsServer(ip string, port int, w *worker.Worker) { + mux := http.NewServeMux() + mux.HandleFunc("/health", workerHealthHandler) + mux.HandleFunc("/ready", workerReadyHandler(w)) + mux.Handle("/metrics", promhttp.HandlerFor(statsCollect.Gather, promhttp.HandlerOpts{})) + + glog.V(0).Infof("Starting worker metrics server at %s", statsCollect.JoinHostPort(ip, port)) + if err := http.ListenAndServe(statsCollect.JoinHostPort(ip, port), mux); err != nil { + glog.Errorf("Worker metrics server failed to start: %v", err) + } } diff --git a/weed/worker/worker.go b/weed/worker/worker.go index bbd1f4662..97e6e7a1e 100644 --- a/weed/worker/worker.go +++ b/weed/worker/worker.go @@ -896,6 +896,10 @@ func (w *Worker) GetPerformanceMetrics() *types.WorkerPerformance { } } +func (w *Worker) GetAdmin() AdminClient { + return w.getAdmin() +} + // messageProcessingLoop processes incoming admin messages func (w *Worker) messageProcessingLoop() { glog.Infof("MESSAGE LOOP STARTED: Worker %s message processing loop started", w.id) From a3c090e6064048f7f296dcf9f0201f8e6e319692 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Sat, 27 Dec 2025 18:37:53 -0800 Subject: [PATCH 45/66] adjust layout --- weed/admin/handlers/admin_handlers.go | 28 +- weed/admin/view/app/cluster_collections.templ | 10 +- .../view/app/cluster_collections_templ.go | 8 +- weed/admin/view/app/cluster_ec_shards.templ | 12 +- .../admin/view/app/cluster_ec_shards_templ.go | 8 +- weed/admin/view/app/cluster_ec_volumes.templ | 10 +- .../view/app/cluster_ec_volumes_templ.go | 8 +- .../view/app/cluster_volume_servers.templ | 2 +- .../view/app/cluster_volume_servers_templ.go | 2 +- weed/admin/view/app/cluster_volumes.templ | 8 +- weed/admin/view/app/cluster_volumes_templ.go | 8 +- weed/admin/view/app/collection_details.templ | 6 +- .../view/app/collection_details_templ.go | 4 +- weed/admin/view/app/ec_volume_details.templ | 2 +- .../admin/view/app/ec_volume_details_templ.go | 2 +- weed/admin/view/app/volume_details.templ | 8 +- weed/admin/view/app/volume_details_templ.go | 8 +- weed/admin/view/layout/layout.templ | 30 +- weed/admin/view/layout/layout_templ.go | 352 ++++++++++++------ 19 files changed, 328 insertions(+), 188 deletions(-) diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index 5bf4c6a5e..216e4801b 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -85,12 +85,14 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, protected.GET("/cluster/masters", h.clusterHandlers.ShowClusterMasters) protected.GET("/cluster/filers", h.clusterHandlers.ShowClusterFilers) protected.GET("/cluster/volume-servers", h.clusterHandlers.ShowClusterVolumeServers) - protected.GET("/cluster/volumes", h.clusterHandlers.ShowClusterVolumes) - protected.GET("/cluster/volumes/:id/:server", h.clusterHandlers.ShowVolumeDetails) - protected.GET("/cluster/collections", h.clusterHandlers.ShowClusterCollections) - protected.GET("/cluster/collections/:name", h.clusterHandlers.ShowCollectionDetails) - protected.GET("/cluster/ec-shards", h.clusterHandlers.ShowClusterEcShards) - protected.GET("/cluster/ec-volumes/:id", h.clusterHandlers.ShowEcVolumeDetails) + + // Storage management routes + protected.GET("/storage/volumes", h.clusterHandlers.ShowClusterVolumes) + protected.GET("/storage/volumes/:id/:server", h.clusterHandlers.ShowVolumeDetails) + protected.GET("/storage/collections", h.clusterHandlers.ShowClusterCollections) + protected.GET("/storage/collections/:name", h.clusterHandlers.ShowCollectionDetails) + protected.GET("/storage/ec-shards", h.clusterHandlers.ShowClusterEcShards) + protected.GET("/storage/ec-volumes/:id", h.clusterHandlers.ShowEcVolumeDetails) // Message Queue management routes protected.GET("/mq/brokers", h.mqHandlers.ShowBrokers) @@ -213,12 +215,14 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, r.GET("/cluster/masters", h.clusterHandlers.ShowClusterMasters) r.GET("/cluster/filers", h.clusterHandlers.ShowClusterFilers) r.GET("/cluster/volume-servers", h.clusterHandlers.ShowClusterVolumeServers) - r.GET("/cluster/volumes", h.clusterHandlers.ShowClusterVolumes) - r.GET("/cluster/volumes/:id/:server", h.clusterHandlers.ShowVolumeDetails) - r.GET("/cluster/collections", h.clusterHandlers.ShowClusterCollections) - r.GET("/cluster/collections/:name", h.clusterHandlers.ShowCollectionDetails) - r.GET("/cluster/ec-shards", h.clusterHandlers.ShowClusterEcShards) - r.GET("/cluster/ec-volumes/:id", h.clusterHandlers.ShowEcVolumeDetails) + + // Storage management routes + r.GET("/storage/volumes", h.clusterHandlers.ShowClusterVolumes) + r.GET("/storage/volumes/:id/:server", h.clusterHandlers.ShowVolumeDetails) + r.GET("/storage/collections", h.clusterHandlers.ShowClusterCollections) + r.GET("/storage/collections/:name", h.clusterHandlers.ShowCollectionDetails) + r.GET("/storage/ec-shards", h.clusterHandlers.ShowClusterEcShards) + r.GET("/storage/ec-volumes/:id", h.clusterHandlers.ShowEcVolumeDetails) // Message Queue management routes r.GET("/mq/brokers", h.mqHandlers.ShowBrokers) diff --git a/weed/admin/view/app/cluster_collections.templ b/weed/admin/view/app/cluster_collections.templ index d4765ea86..52482927f 100644 --- a/weed/admin/view/app/cluster_collections.templ +++ b/weed/admin/view/app/cluster_collections.templ @@ -149,12 +149,12 @@ templ ClusterCollections(data dash.ClusterCollectionsData) { for _, collection := range data.Collections { - + {collection.Name} - +
      if collection.VolumeCount > 0 { @@ -166,7 +166,7 @@ templ ClusterCollections(data dash.ClusterCollectionsData) { - +
      if collection.EcVolumeCount > 0 { @@ -330,10 +330,10 @@ templ ClusterCollections(data dash.ClusterCollectionsData) { '
      ' + '
      Quick Actions
      ' + '
      ' + - '' + + '' + 'View Volumes' + '' + - '' + + '' + 'View EC Volumes' + '' + '' + diff --git a/weed/admin/view/app/cluster_collections_templ.go b/weed/admin/view/app/cluster_collections_templ.go index e3630d7a6..1e0234cbd 100644 --- a/weed/admin/view/app/cluster_collections_templ.go +++ b/weed/admin/view/app/cluster_collections_templ.go @@ -114,7 +114,7 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 templ.SafeURL - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/cluster/collections/%s", collection.Name))) + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/storage/collections/%s", collection.Name))) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 152, Col: 123} } @@ -140,7 +140,7 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var9 templ.SafeURL - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/cluster/volumes?collection=%s", collection.Name))) + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/storage/volumes?collection=%s", collection.Name))) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 157, Col: 130} } @@ -173,7 +173,7 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var11 templ.SafeURL - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/cluster/ec-shards?collection=%s", collection.Name))) + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/storage/ec-shards?collection=%s", collection.Name))) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_collections.templ`, Line: 169, Col: 132} } @@ -403,7 +403,7 @@ func ClusterCollections(data dash.ClusterCollectionsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
      ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
      ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/cluster_ec_shards.templ b/weed/admin/view/app/cluster_ec_shards.templ index a3e8fc0ec..19f6fd2d6 100644 --- a/weed/admin/view/app/cluster_ec_shards.templ +++ b/weed/admin/view/app/cluster_ec_shards.templ @@ -22,7 +22,7 @@ templ ClusterEcShards(data dash.ClusterEcShardsData) { Collection: {data.FilterCollection} } - + Clear Filter @@ -205,11 +205,11 @@ templ ClusterEcShards(data dash.ClusterEcShardsData) { if data.ShowCollectionColumn { if shard.Collection != "" { - + {shard.Collection} } else { - + default } @@ -366,7 +366,7 @@ templ ClusterEcShards(data dash.ClusterEcShardsData) { } function exportEcShards() { - const url = new URL('/api/cluster/ec-shards/export', window.location.origin); + const url = new URL('/api/storage/ec-shards/export', window.location.origin); const params = new URLSearchParams(window.location.search); params.forEach((value, key) => { url.searchParams.set(key, value); @@ -380,7 +380,7 @@ templ ClusterEcShards(data dash.ClusterEcShardsData) { const volumeId = button.getAttribute('data-volume-id'); // Navigate to the EC volume details page - window.location.href = `/cluster/ec-volumes/${volumeId}`; + window.location.href = `/storage/ec-volumes/${volumeId}`; } function repairVolume(event) { @@ -388,7 +388,7 @@ templ ClusterEcShards(data dash.ClusterEcShardsData) { const button = event.target.closest('button'); const volumeId = button.getAttribute('data-volume-id'); if (confirm(`Are you sure you want to repair missing shards for volume ${volumeId}?`)) { - fetch(`/api/cluster/volumes/${volumeId}/repair`, { + fetch(`/api/storage/volumes/${volumeId}/repair`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/weed/admin/view/app/cluster_ec_shards_templ.go b/weed/admin/view/app/cluster_ec_shards_templ.go index f995e5ef4..b7c169d1e 100644 --- a/weed/admin/view/app/cluster_ec_shards_templ.go +++ b/weed/admin/view/app/cluster_ec_shards_templ.go @@ -67,7 +67,7 @@ func ClusterEcShards(data dash.ClusterEcShardsData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Clear Filter") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Clear Filter") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -328,7 +328,7 @@ func ClusterEcShards(data dash.ClusterEcShardsData) templ.Component { return templ_7745c5c3_Err } if shard.Collection != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -346,7 +346,7 @@ func ClusterEcShards(data dash.ClusterEcShardsData) templ.Component { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "default") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "default") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -663,7 +663,7 @@ func ClusterEcShards(data dash.ClusterEcShardsData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/cluster_ec_volumes.templ b/weed/admin/view/app/cluster_ec_volumes.templ index f5210082e..6e94443ae 100644 --- a/weed/admin/view/app/cluster_ec_volumes.templ +++ b/weed/admin/view/app/cluster_ec_volumes.templ @@ -25,7 +25,7 @@ templ ClusterEcVolumes(data dash.ClusterEcVolumesData) { Collection: {data.Collection} } - + Clear Filter @@ -201,11 +201,11 @@ templ ClusterEcVolumes(data dash.ClusterEcVolumesData) { if data.ShowCollectionColumn { if volume.Collection != "" { - + {volume.Collection} } else { - + default } @@ -373,13 +373,13 @@ templ ClusterEcVolumes(data dash.ClusterEcVolumesData) { function showVolumeDetails(event) { const volumeId = event.target.closest('button').getAttribute('data-volume-id'); - window.location.href = `/cluster/ec-volumes/${volumeId}`; + window.location.href = `/storage/ec-volumes/${volumeId}`; } function repairVolume(event) { const volumeId = event.target.closest('button').getAttribute('data-volume-id'); if (confirm(`Are you sure you want to repair missing shards for volume ${volumeId}?`)) { - fetch(`/api/cluster/ec-volumes/${volumeId}/repair`, { + fetch(`/api/storage/ec-volumes/${volumeId}/repair`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/weed/admin/view/app/cluster_ec_volumes_templ.go b/weed/admin/view/app/cluster_ec_volumes_templ.go index ddfd0795a..9103607a8 100644 --- a/weed/admin/view/app/cluster_ec_volumes_templ.go +++ b/weed/admin/view/app/cluster_ec_volumes_templ.go @@ -70,7 +70,7 @@ func ClusterEcVolumes(data dash.ClusterEcVolumesData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Clear Filter") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Clear Filter") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -383,7 +383,7 @@ func ClusterEcVolumes(data dash.ClusterEcVolumesData) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var13 templ.SafeURL - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(fmt.Sprintf("/cluster/ec-shards?collection=%s", volume.Collection))) + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(fmt.Sprintf("/storage/ec-shards?collection=%s", volume.Collection))) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_ec_volumes.templ`, Line: 204, Col: 123} } @@ -414,7 +414,7 @@ func ClusterEcVolumes(data dash.ClusterEcVolumesData) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var15 templ.SafeURL - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/cluster/ec-shards?collection=default")) + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL("/storage/ec-shards?collection=default")) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_ec_volumes.templ`, Line: 208, Col: 96} } @@ -757,7 +757,7 @@ func ClusterEcVolumes(data dash.ClusterEcVolumesData) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/cluster_volume_servers.templ b/weed/admin/view/app/cluster_volume_servers.templ index b6de9ad12..65b7f08a1 100644 --- a/weed/admin/view/app/cluster_volume_servers.templ +++ b/weed/admin/view/app/cluster_volume_servers.templ @@ -337,7 +337,7 @@ templ ClusterVolumeServers(data dash.ClusterVolumeServersData) { '' + 'Open Volume Server UI' + '' + - '' + + '' + 'View Volumes' + '' + '' + diff --git a/weed/admin/view/app/cluster_volume_servers_templ.go b/weed/admin/view/app/cluster_volume_servers_templ.go index c7a4ec80b..3a47df7d9 100644 --- a/weed/admin/view/app/cluster_volume_servers_templ.go +++ b/weed/admin/view/app/cluster_volume_servers_templ.go @@ -656,7 +656,7 @@ func ClusterVolumeServers(data dash.ClusterVolumeServersData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/cluster_volumes.templ b/weed/admin/view/app/cluster_volumes.templ index 8f0b59698..c3b2591da 100644 --- a/weed/admin/view/app/cluster_volumes.templ +++ b/weed/admin/view/app/cluster_volumes.templ @@ -17,7 +17,7 @@ templ ClusterVolumes(data dash.ClusterVolumesData) { Collection: {data.FilterCollection} - + Clear Filter @@ -338,11 +338,11 @@ templ ClusterVolumes(data dash.ClusterVolumesData) { if data.ShowCollectionColumn { if volume.Collection == "" { - + default } else { - + {volume.Collection} } @@ -597,7 +597,7 @@ templ ClusterVolumes(data dash.ClusterVolumesData) { const serverCell = row.querySelector('td:nth-child(2) a'); const server = serverCell ? serverCell.textContent.trim() : 'unknown'; - window.location.href = `/cluster/volumes/${volumeId}/${encodeURIComponent(server)}`; + window.location.href = `/storage/volumes/${volumeId}/${encodeURIComponent(server)}`; } function performVacuum(volumeId, server, button) { diff --git a/weed/admin/view/app/cluster_volumes_templ.go b/weed/admin/view/app/cluster_volumes_templ.go index d96a991ce..117ae8585 100644 --- a/weed/admin/view/app/cluster_volumes_templ.go +++ b/weed/admin/view/app/cluster_volumes_templ.go @@ -53,7 +53,7 @@ func ClusterVolumes(data dash.ClusterVolumesData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " Clear Filter") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " Clear Filter") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -557,7 +557,7 @@ func ClusterVolumes(data dash.ClusterVolumesData) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var21 templ.SafeURL - templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/cluster/volumes?collection=default")) + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/storage/volumes?collection=default")) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volumes.templ`, Line: 341, Col: 113} } @@ -575,7 +575,7 @@ func ClusterVolumes(data dash.ClusterVolumesData) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var22 templ.SafeURL - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/cluster/volumes?collection=%s", volume.Collection))) + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(fmt.Sprintf("/storage/volumes?collection=%s", volume.Collection))) if templ_7745c5c3_Err != nil { return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/app/cluster_volumes.templ`, Line: 345, Col: 140} } @@ -1035,7 +1035,7 @@ func ClusterVolumes(data dash.ClusterVolumesData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/admin/view/app/collection_details.templ b/weed/admin/view/app/collection_details.templ index b5c86ba18..296839b93 100644 --- a/weed/admin/view/app/collection_details.templ +++ b/weed/admin/view/app/collection_details.templ @@ -15,7 +15,7 @@ templ CollectionDetails(data dash.CollectionDetailsData) { @@ -360,13 +360,13 @@ templ CollectionDetails(data dash.CollectionDetailsData) { function showVolumeDetails(event) { const volumeId = event.target.closest('button').getAttribute('data-volume-id'); const server = event.target.closest('button').getAttribute('data-server'); - window.location.href = `/cluster/volumes/${volumeId}/${server}`; + window.location.href = `/storage/volumes/${volumeId}/${server}`; } // EC Volume details function showEcVolumeDetails(event) { const volumeId = event.target.closest('button').getAttribute('data-volume-id'); - window.location.href = `/cluster/ec-volumes/${volumeId}`; + window.location.href = `/storage/ec-volumes/${volumeId}`; } // Repair EC Volume diff --git a/weed/admin/view/app/collection_details_templ.go b/weed/admin/view/app/collection_details_templ.go index a0e781637..f2ff0ab13 100644 --- a/weed/admin/view/app/collection_details_templ.go +++ b/weed/admin/view/app/collection_details_templ.go @@ -48,7 +48,7 @@ func CollectionDetails(data dash.CollectionDetailsData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
    MANAGEMENT
    MANAGEMENT
    • File Browser
    • Object Store
    • ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -271,7 +271,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var13 templ.SafeURL templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 277, Col: 117} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 282, Col: 117} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -306,7 +306,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 278, Col: 109} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 283, Col: 109} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -324,7 +324,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var17 templ.SafeURL templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 281, Col: 110} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 286, Col: 110} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -359,7 +359,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var20 string templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 282, Col: 109} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 287, Col: 109} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { @@ -392,7 +392,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var21 templ.SafeURL templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(menuItem.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 294, Col: 106} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 299, Col: 106} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { @@ -427,7 +427,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var24 string templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(menuItem.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 295, Col: 105} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 300, Col: 105} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { @@ -488,7 +488,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var25 string templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", time.Now().Year())) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 342, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 347, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { @@ -501,7 +501,7 @@ func Layout(c *gin.Context, content templ.Component) templ.Component { var templ_7745c5c3_Var26 string templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(version.VERSION_NUMBER) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 342, Col: 102} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 347, Col: 102} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { @@ -553,7 +553,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen var templ_7745c5c3_Var28 string templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 366, Col: 17} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 371, Col: 17} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { @@ -566,7 +566,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 380, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 385, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { @@ -584,7 +584,7 @@ func LoginForm(c *gin.Context, title string, errorMessage string) templ.Componen var templ_7745c5c3_Var30 string templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 387, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/layout/layout.templ`, Line: 392, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { From 808205e38f6eb578e36a85d16682370760d26d09 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Mon, 29 Dec 2025 23:54:00 -0800 Subject: [PATCH 57/66] s3: implement Bucket Owner Enforced for object ownership (#7913) * s3: implement Bucket Owner Enforced for object ownership Objects uploaded by service accounts (or any user) are now owned by the bucket owner when the bucket has BucketOwnerEnforced ownership policy (the modern AWS default since April 2023). This provides a more intuitive ownership model where users expect objects created by their service accounts to be owned by themselves. - Modified setObjectOwnerFromRequest to check bucket ObjectOwnership - When BucketOwnerEnforced: use bucket owner's account ID - When ObjectWriter: use uploader's account ID (backward compatible) * s3: add nil check and fix ownership logic hole - Add nil check for bucketRegistry before calling GetBucketMetadata - Fix logic hole where objects could be created without owner when BucketOwnerEnforced is set but bucket owner is nil - Refactor to ensure objects always have an owner by falling back to uploader when bucket owner is unavailable - Improve logging to distinguish between different fallback scenarios Addresses code review feedback from Gemini on PR #7913 * s3: add comprehensive tests for object ownership logic Add unit tests for setObjectOwnerFromRequest covering: - BucketOwnerEnforced: uses bucket owner - ObjectWriter: uses uploader - BucketOwnerPreferred: uses uploader - Nil owner fallback scenarios - Bucket metadata errors - Nil bucketRegistry - Empty account ID handling All 8 test cases pass, verifying correct ownership assignment in all scenarios including edge cases. --- weed/s3api/s3api_object_handlers_put.go | 45 ++++- weed/s3api/s3api_object_ownership_test.go | 196 ++++++++++++++++++++++ 2 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 weed/s3api/s3api_object_ownership_test.go diff --git a/weed/s3api/s3api_object_handlers_put.go b/weed/s3api/s3api_object_handlers_put.go index 9e325801d..8c0561a89 100644 --- a/weed/s3api/s3api_object_handlers_put.go +++ b/weed/s3api/s3api_object_handlers_put.go @@ -141,7 +141,7 @@ func (s3a *S3ApiServer) PutObjectHandler(w http.ResponseWriter, r *http.Request) entry.Attributes.Mime = objectContentType // Set object owner for directory objects (same as regular objects) - s3a.setObjectOwnerFromRequest(r, entry) + s3a.setObjectOwnerFromRequest(r, bucket, entry) }); err != nil { s3err.WriteErrorResponse(w, r, s3err.ErrInternalError) return @@ -748,15 +748,44 @@ func filerErrorToS3Error(err error) s3err.ErrorCode { } } -// setObjectOwnerFromRequest sets the object owner metadata based on the authenticated user -func (s3a *S3ApiServer) setObjectOwnerFromRequest(r *http.Request, entry *filer_pb.Entry) { - amzAccountId := r.Header.Get(s3_constants.AmzAccountId) - if amzAccountId != "" { +// setObjectOwnerFromRequest sets the object owner metadata based on the bucket ownership policy. +// When BucketOwnerEnforced (the modern AWS default), the bucket owner owns all objects. +// Otherwise, the uploader's account ID is used (ObjectWriter mode). +func (s3a *S3ApiServer) setObjectOwnerFromRequest(r *http.Request, bucket string, entry *filer_pb.Entry) { + var ownerId string + + // Check if bucketRegistry is available + if s3a.bucketRegistry == nil { + // Fallback to uploader if registry unavailable + ownerId = r.Header.Get(s3_constants.AmzAccountId) + glog.V(2).Infof("setObjectOwnerFromRequest: bucketRegistry unavailable, fallback to uploader %s", ownerId) + } else { + // Check bucket ownership policy + bucketMetadata, errCode := s3a.bucketRegistry.GetBucketMetadata(bucket) + useBucketOwner := errCode == s3err.ErrNone && bucketMetadata != nil && + bucketMetadata.ObjectOwnership == s3_constants.OwnershipBucketOwnerEnforced && + bucketMetadata.Owner != nil && bucketMetadata.Owner.ID != nil + + if useBucketOwner { + ownerId = *bucketMetadata.Owner.ID + glog.V(2).Infof("setObjectOwnerFromRequest: using bucket owner %s (BucketOwnerEnforced)", ownerId) + } else { + ownerId = r.Header.Get(s3_constants.AmzAccountId) + if errCode != s3err.ErrNone || bucketMetadata == nil { + glog.V(2).Infof("setObjectOwnerFromRequest: fallback to uploader %s", ownerId) + } else if bucketMetadata.ObjectOwnership == s3_constants.OwnershipBucketOwnerEnforced { + glog.V(2).Infof("setObjectOwnerFromRequest: BucketOwnerEnforced but no owner found, fallback to uploader %s", ownerId) + } else { + glog.V(2).Infof("setObjectOwnerFromRequest: using uploader %s (ObjectWriter mode)", ownerId) + } + } + } + + if ownerId != "" { if entry.Extended == nil { entry.Extended = make(map[string][]byte) } - entry.Extended[s3_constants.ExtAmzOwnerKey] = []byte(amzAccountId) - glog.V(2).Infof("setObjectOwnerFromRequest: set object owner to %s", amzAccountId) + entry.Extended[s3_constants.ExtAmzOwnerKey] = []byte(ownerId) } } @@ -1035,7 +1064,7 @@ func (s3a *S3ApiServer) putVersionedObject(r *http.Request, bucket, object strin versionEntry.Extended[s3_constants.ExtETagKey] = []byte(etag) // Set object owner for versioned objects - s3a.setObjectOwnerFromRequest(r, versionEntry) + s3a.setObjectOwnerFromRequest(r, bucket, versionEntry) // Extract and store object lock metadata from request headers if err := s3a.extractObjectLockMetadataFromRequest(r, versionEntry); err != nil { diff --git a/weed/s3api/s3api_object_ownership_test.go b/weed/s3api/s3api_object_ownership_test.go new file mode 100644 index 000000000..849f442c7 --- /dev/null +++ b/weed/s3api/s3api_object_ownership_test.go @@ -0,0 +1,196 @@ +package s3api + +import ( + "net/http" + "testing" + + "github.com/aws/aws-sdk-go/service/s3" + "github.com/seaweedfs/seaweedfs/weed/pb/filer_pb" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants" + "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" +) + +func TestSetObjectOwnerFromRequest(t *testing.T) { + tests := []struct { + name string + bucketRegistryNil bool + bucketMetadata *BucketMetaData + bucketMetadataError s3err.ErrorCode + uploaderAccountId string + expectedOwnerId string + description string + }{ + { + name: "BucketOwnerEnforced - use bucket owner", + bucketRegistryNil: false, + bucketMetadata: &BucketMetaData{ + Name: "test-bucket", + ObjectOwnership: s3_constants.OwnershipBucketOwnerEnforced, + Owner: &s3.Owner{ + ID: stringPtr("bucket-owner-123"), + DisplayName: stringPtr("Bucket Owner"), + }, + }, + bucketMetadataError: s3err.ErrNone, + uploaderAccountId: "uploader-456", + expectedOwnerId: "bucket-owner-123", + description: "Should use bucket owner when BucketOwnerEnforced", + }, + { + name: "ObjectWriter - use uploader", + bucketRegistryNil: false, + bucketMetadata: &BucketMetaData{ + Name: "test-bucket", + ObjectOwnership: s3_constants.OwnershipObjectWriter, + Owner: &s3.Owner{ + ID: stringPtr("bucket-owner-123"), + DisplayName: stringPtr("Bucket Owner"), + }, + }, + bucketMetadataError: s3err.ErrNone, + uploaderAccountId: "uploader-456", + expectedOwnerId: "uploader-456", + description: "Should use uploader when ObjectWriter mode", + }, + { + name: "BucketOwnerPreferred - use uploader", + bucketRegistryNil: false, + bucketMetadata: &BucketMetaData{ + Name: "test-bucket", + ObjectOwnership: s3_constants.OwnershipBucketOwnerPreferred, + Owner: &s3.Owner{ + ID: stringPtr("bucket-owner-123"), + DisplayName: stringPtr("Bucket Owner"), + }, + }, + bucketMetadataError: s3err.ErrNone, + uploaderAccountId: "uploader-456", + expectedOwnerId: "uploader-456", + description: "Should use uploader when BucketOwnerPreferred mode", + }, + { + name: "BucketOwnerEnforced but owner is nil - fallback to uploader", + bucketRegistryNil: false, + bucketMetadata: &BucketMetaData{ + Name: "test-bucket", + ObjectOwnership: s3_constants.OwnershipBucketOwnerEnforced, + Owner: nil, + }, + bucketMetadataError: s3err.ErrNone, + uploaderAccountId: "uploader-456", + expectedOwnerId: "uploader-456", + description: "Should fallback to uploader when bucket owner is nil", + }, + { + name: "BucketOwnerEnforced but owner ID is nil - fallback to uploader", + bucketRegistryNil: false, + bucketMetadata: &BucketMetaData{ + Name: "test-bucket", + ObjectOwnership: s3_constants.OwnershipBucketOwnerEnforced, + Owner: &s3.Owner{ + ID: nil, + DisplayName: stringPtr("Bucket Owner"), + }, + }, + bucketMetadataError: s3err.ErrNone, + uploaderAccountId: "uploader-456", + expectedOwnerId: "uploader-456", + description: "Should fallback to uploader when bucket owner ID is nil", + }, + { + name: "Bucket metadata error - fallback to uploader", + bucketRegistryNil: false, + bucketMetadata: nil, + bucketMetadataError: s3err.ErrNoSuchBucket, + uploaderAccountId: "uploader-456", + expectedOwnerId: "uploader-456", + description: "Should fallback to uploader when bucket metadata unavailable", + }, + { + name: "Bucket registry is nil - fallback to uploader", + bucketRegistryNil: true, + uploaderAccountId: "uploader-456", + expectedOwnerId: "uploader-456", + description: "Should fallback to uploader when bucketRegistry is nil", + }, + { + name: "Empty uploader account ID - no owner set", + bucketRegistryNil: false, + bucketMetadata: &BucketMetaData{ + Name: "test-bucket", + ObjectOwnership: s3_constants.OwnershipObjectWriter, + Owner: &s3.Owner{ + ID: stringPtr("bucket-owner-123"), + DisplayName: stringPtr("Bucket Owner"), + }, + }, + bucketMetadataError: s3err.ErrNone, + uploaderAccountId: "", + expectedOwnerId: "", + description: "Should not set owner when uploader account ID is empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock S3ApiServer + s3a := &S3ApiServer{} + + // Setup bucket registry with mock behavior + if !tt.bucketRegistryNil { + // Create a minimal BucketRegistry with overridden GetBucketMetadata + s3a.bucketRegistry = &BucketRegistry{ + metadataCache: map[string]*BucketMetaData{}, + notFound: map[string]struct{}{}, + } + + // Pre-populate the cache with test metadata + if tt.bucketMetadata != nil && tt.bucketMetadataError == s3err.ErrNone { + s3a.bucketRegistry.metadataCache["test-bucket"] = tt.bucketMetadata + } else if tt.bucketMetadataError == s3err.ErrNoSuchBucket { + s3a.bucketRegistry.notFound["test-bucket"] = struct{}{} + } + } + + // Create mock request with uploader account ID + req, _ := http.NewRequest("PUT", "/test-bucket/test-object", nil) + req.Header.Set(s3_constants.AmzAccountId, tt.uploaderAccountId) + + // Create entry + entry := &filer_pb.Entry{ + Name: "test-object", + } + + // Call the function + s3a.setObjectOwnerFromRequest(req, "test-bucket", entry) + + // Verify the owner ID + if tt.expectedOwnerId == "" { + if entry.Extended != nil { + if _, exists := entry.Extended[s3_constants.ExtAmzOwnerKey]; exists { + t.Errorf("%s: Expected no owner to be set, but owner was set", tt.description) + } + } + } else { + if entry.Extended == nil { + t.Errorf("%s: Expected owner to be set, but Extended is nil", tt.description) + return + } + ownerBytes, exists := entry.Extended[s3_constants.ExtAmzOwnerKey] + if !exists { + t.Errorf("%s: Expected owner to be set, but ExtAmzOwnerKey not found", tt.description) + return + } + actualOwnerId := string(ownerBytes) + if actualOwnerId != tt.expectedOwnerId { + t.Errorf("%s: Expected owner ID %s, got %s", tt.description, tt.expectedOwnerId, actualOwnerId) + } + } + }) + } +} + +// Helper function to create string pointers +func stringPtr(s string) *string { + return &s +} From 7a18c3a16fea98ced731073b255143667d3f0dec Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 30 Dec 2025 12:40:59 -0800 Subject: [PATCH 58/66] Fix critical authentication bypass vulnerability (#7912) (#7915) * Fix critical authentication bypass vulnerability (#7912) The isRequestPostPolicySignatureV4() function was incorrectly returning true for ANY POST request with multipart/form-data content type, causing all such requests to bypass authentication in authRequest(). This allowed unauthenticated access to S3 API endpoints, as reported in issue #7912 where any credentials (or no credentials) were accepted. The fix removes isRequestPostPolicySignatureV4() entirely, preventing authTypePostPolicy from ever being set. PostPolicy signature verification is still properly handled in PostPolicyBucketHandler via doesPolicySignatureMatch(). Fixes #7912 * add AuthPostPolicy * refactor * Optimizing Auth Credentials * Update auth_credentials.go * Update auth_credentials.go --- weed/s3api/auth_credentials.go | 130 ++++++++++++++++++++++----------- weed/s3api/s3api_auth.go | 8 -- weed/s3api/s3api_server.go | 2 +- 3 files changed, 88 insertions(+), 52 deletions(-) diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index 49f2acf87..0cbed72a2 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -529,73 +529,120 @@ func (iam *IdentityAccessManagement) Auth(f http.HandlerFunc, action Action) htt identity, errCode := iam.authRequest(r, action) glog.V(3).Infof("auth error: %v", errCode) - if errCode == s3err.ErrNone { - // Store the authenticated identity in request context (secure, cannot be spoofed) - if identity != nil && identity.Name != "" { - ctx := s3_constants.SetIdentityNameInContext(r.Context(), identity.Name) - // Also store the full identity object for handlers that need it (e.g., ListBuckets) - // This is especially important for JWT users whose identity is not in the identities list - ctx = s3_constants.SetIdentityInContext(ctx, identity) - r = r.WithContext(ctx) - } - f(w, r) - return - } - s3err.WriteErrorResponse(w, r, errCode) + iam.handleAuthResult(w, r, identity, errCode, f) } } -// check whether the request has valid access keys +// AuthPostPolicy is a specialized authentication wrapper for PostPolicy requests. +// It allows requests with multipart/form-data to proceed even if classified as Anonymous, +// because the actual authentication (signature verification) for ALL PostPolicy requests is +// performed unconditionally in PostPolicyBucketHandler.doesPolicySignatureMatch(). +// This delegation only defers the initial authentication classification; it does NOT bypass +// signature verification, which is mandatory for all PostPolicy uploads. +func (iam *IdentityAccessManagement) AuthPostPolicy(f http.HandlerFunc, action Action) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !iam.isEnabled() { + f(w, r) + return + } + + // Optimization: Use authRequestWithAuthType to avoid re-parsing headers for classification + identity, errCode, authType := iam.authRequestWithAuthType(r, action) + + // Special handling for PostPolicy: if AccessDenied (likely because Anonymous to private bucket) + // AND it looks like a PostPolicy request, allow it to proceed to handler for verification. + if errCode == s3err.ErrAccessDenied { + if authType == authTypeAnonymous && + r.Method == http.MethodPost && + strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data") { + + glog.V(3).Infof("Delegating PostPolicy auth to handler") + r.Header.Set(s3_constants.AmzAuthType, "PostPolicy") + f(w, r) + return + } + } + + glog.V(3).Infof("auth error: %v", errCode) + + iam.handleAuthResult(w, r, identity, errCode, f) + } +} + +func (iam *IdentityAccessManagement) handleAuthResult(w http.ResponseWriter, r *http.Request, identity *Identity, errCode s3err.ErrorCode, f http.HandlerFunc) { + if errCode == s3err.ErrNone { + // Store the authenticated identity in request context (secure, cannot be spoofed) + if identity != nil && identity.Name != "" { + ctx := s3_constants.SetIdentityNameInContext(r.Context(), identity.Name) + // Also store the full identity object for handlers that need it (e.g., ListBuckets) + // This is especially important for JWT users whose identity is not in the identities list + ctx = s3_constants.SetIdentityInContext(ctx, identity) + r = r.WithContext(ctx) + } + f(w, r) + return + } + s3err.WriteErrorResponse(w, r, errCode) +} + +// Wrapper to maintain backward compatibility func (iam *IdentityAccessManagement) authRequest(r *http.Request, action Action) (*Identity, s3err.ErrorCode) { + identity, err, _ := iam.authRequestWithAuthType(r, action) + return identity, err +} + +// check whether the request has valid access keys +func (iam *IdentityAccessManagement) authRequestWithAuthType(r *http.Request, action Action) (*Identity, s3err.ErrorCode, authType) { var identity *Identity var s3Err s3err.ErrorCode var found bool - var authType string - switch getRequestAuthType(r) { + var amzAuthType string + + reqAuthType := getRequestAuthType(r) + + switch reqAuthType { case authTypeUnknown: glog.V(3).Infof("unknown auth type") r.Header.Set(s3_constants.AmzAuthType, "Unknown") - return identity, s3err.ErrAccessDenied + return identity, s3err.ErrAccessDenied, reqAuthType case authTypePresignedV2, authTypeSignedV2: glog.V(3).Infof("v2 auth type") identity, s3Err = iam.isReqAuthenticatedV2(r) - authType = "SigV2" + amzAuthType = "SigV2" case authTypeStreamingSigned, authTypeSigned, authTypePresigned: glog.V(3).Infof("v4 auth type") identity, s3Err = iam.reqSignatureV4Verify(r) - authType = "SigV4" - case authTypePostPolicy: - glog.V(3).Infof("post policy auth type") - r.Header.Set(s3_constants.AmzAuthType, "PostPolicy") - return identity, s3err.ErrNone + amzAuthType = "SigV4" case authTypeStreamingUnsigned: glog.V(3).Infof("unsigned streaming upload") - return identity, s3err.ErrNone + // no amzAuthType set for this case in original code? + // Actually original explicitly returned ErrNone without setting identity + return identity, s3err.ErrNone, reqAuthType case authTypeJWT: glog.V(3).Infof("jwt auth type detected, iamIntegration != nil? %t", iam.iamIntegration != nil) r.Header.Set(s3_constants.AmzAuthType, "Jwt") if iam.iamIntegration != nil { identity, s3Err = iam.authenticateJWTWithIAM(r) - authType = "Jwt" + amzAuthType = "Jwt" } else { glog.V(2).Infof("IAM integration is nil, returning ErrNotImplemented") - return identity, s3err.ErrNotImplemented + return identity, s3err.ErrNotImplemented, reqAuthType } case authTypeAnonymous: - authType = "Anonymous" + amzAuthType = "Anonymous" if identity, found = iam.lookupAnonymous(); !found { - r.Header.Set(s3_constants.AmzAuthType, authType) - return identity, s3err.ErrAccessDenied + r.Header.Set(s3_constants.AmzAuthType, amzAuthType) + return identity, s3err.ErrAccessDenied, reqAuthType } default: - return identity, s3err.ErrNotImplemented + return identity, s3err.ErrNotImplemented, reqAuthType } - if len(authType) > 0 { - r.Header.Set(s3_constants.AmzAuthType, authType) + if len(amzAuthType) > 0 { + r.Header.Set(s3_constants.AmzAuthType, amzAuthType) } if s3Err != s3err.ErrNone { - return identity, s3Err + return identity, s3Err, reqAuthType } glog.V(3).Infof("user name: %v actions: %v, action: %v", identity.Name, identity.Actions, action) @@ -636,7 +683,7 @@ func (iam *IdentityAccessManagement) authRequest(r *http.Request, action Action) // SECURITY: Fail-close on policy evaluation errors // If we can't evaluate the policy, deny access rather than falling through to IAM glog.Errorf("Error evaluating bucket policy for %s/%s: %v - denying access", bucket, object, err) - return identity, s3err.ErrAccessDenied + return identity, s3err.ErrAccessDenied, reqAuthType } else if evaluated { // A bucket policy exists and was evaluated with a matching statement if allowed { @@ -648,7 +695,7 @@ func (iam *IdentityAccessManagement) authRequest(r *http.Request, action Action) // Policy explicitly denies this action - deny access immediately // Note: Explicit Deny in bucket policy overrides all other permissions glog.V(3).Infof("Bucket policy explicitly denies %s to %s on %s/%s", identity.Name, action, bucket, object) - return identity, s3err.ErrAccessDenied + return identity, s3err.ErrAccessDenied, reqAuthType } } // If not evaluated (no policy or no matching statements), fall through to IAM/identity checks @@ -660,21 +707,21 @@ func (iam *IdentityAccessManagement) authRequest(r *http.Request, action Action) // JWT/STS identities (no Actions) use IAM authorization if len(identity.Actions) > 0 { if !identity.canDo(action, bucket, object) { - return identity, s3err.ErrAccessDenied + return identity, s3err.ErrAccessDenied, reqAuthType } } else if iam.iamIntegration != nil { if errCode := iam.authorizeWithIAM(r, identity, action, bucket, object); errCode != s3err.ErrNone { - return identity, errCode + return identity, errCode, reqAuthType } } else { - return identity, s3err.ErrAccessDenied + return identity, s3err.ErrAccessDenied, reqAuthType } } } r.Header.Set(s3_constants.AmzAccountId, identity.Account.Id) - return identity, s3err.ErrNone + return identity, s3err.ErrNone, reqAuthType } @@ -699,10 +746,7 @@ func (iam *IdentityAccessManagement) AuthSignatureOnly(r *http.Request) (*Identi glog.V(3).Infof("v4 auth type") identity, s3Err = iam.reqSignatureV4Verify(r) authType = "SigV4" - case authTypePostPolicy: - glog.V(3).Infof("post policy auth type") - r.Header.Set(s3_constants.AmzAuthType, "PostPolicy") - return identity, s3err.ErrNone + case authTypeStreamingUnsigned: glog.V(3).Infof("unsigned streaming upload") return identity, s3err.ErrNone diff --git a/weed/s3api/s3api_auth.go b/weed/s3api/s3api_auth.go index 5592fe939..6963373cd 100644 --- a/weed/s3api/s3api_auth.go +++ b/weed/s3api/s3api_auth.go @@ -41,12 +41,6 @@ func isRequestPresignedSignatureV2(r *http.Request) bool { return ok } -// Verify if request has AWS Post policy Signature Version '4'. -func isRequestPostPolicySignatureV4(r *http.Request) bool { - return strings.Contains(r.Header.Get("Content-Type"), "multipart/form-data") && - r.Method == http.MethodPost -} - // Verify if the request has AWS Streaming Signature Version '4'. This is only valid for 'PUT' operation. // Supports both with and without trailer variants: // - STREAMING-AWS4-HMAC-SHA256-PAYLOAD (original) @@ -101,8 +95,6 @@ func getRequestAuthType(r *http.Request) authType { authType = authTypePresigned } else if isRequestJWT(r) { authType = authTypeJWT - } else if isRequestPostPolicySignatureV4(r) { - authType = authTypePostPolicy } else if _, ok := r.Header["Authorization"]; !ok { authType = authTypeAnonymous } else { diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index c811d668b..5917b5195 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -573,7 +573,7 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) { // raw buckets // PostPolicy - bucket.Methods(http.MethodPost).HeadersRegexp("Content-Type", "multipart/form-data*").HandlerFunc(track(s3a.iam.Auth(s3a.cb.Limit(s3a.PostPolicyBucketHandler, ACTION_WRITE)), "POST")) + bucket.Methods(http.MethodPost).HeadersRegexp("Content-Type", "multipart/form-data*").HandlerFunc(track(s3a.iam.AuthPostPolicy(s3a.cb.Limit(s3a.PostPolicyBucketHandler, ACTION_WRITE)), "POST")) // HeadBucket bucket.Methods(http.MethodHead).HandlerFunc(track(s3a.AuthWithPublicRead(func(w http.ResponseWriter, r *http.Request) { From 73098c97922f6f2f0f49bd1ad6820663e2d9dc1c Mon Sep 17 00:00:00 2001 From: ai8future <2287988+ai8future@users.noreply.github.com> Date: Tue, 30 Dec 2025 23:28:50 +0100 Subject: [PATCH 59/66] filer.meta.backup: add -excludePaths flag to skip paths from backup (#7916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * filer.meta.backup: add -excludePaths flag to skip paths from backup Add a new -excludePaths flag that accepts comma-separated path prefixes to exclude from backup operations. This enables selective backup when certain directories (e.g., legacy buckets) should be skipped. Usage: weed filer.meta.backup -filerDir=/buckets -excludePaths=/buckets/legacy1,/buckets/legacy2 -config=backup.toml 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * filer.meta.backup: address code review feedback for -excludePaths Fixes based on CodeRabbit and Gemini review: - Cache parsed exclude paths in struct (performance) - TrimSpace and skip empty entries (handles "a,,b" and "a, b") - Add trailing slash for directory boundary matching (prevents /buckets/legacy matching /buckets/legacy_backup) - Validate paths start with '/' and warn if not - Log excluded paths at startup for debugging - Fix rename handling: check both old and new paths, handle all four combinations correctly - Add docstring to shouldExclude() - Update UsageLine and Long description with new flag 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * filer.meta.backup: address nitpick feedback - Clarify directory boundary matching behavior in help text - Add warning when root path '/' is excluded (would exclude everything) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * includePrefixes and excludePrefixes --------- Co-authored-by: C Shaw Co-authored-by: Claude Opus 4.5 Co-authored-by: Chris Lu --- weed/command/filer_meta_backup.go | 89 +++++++++++-- weed/util/path_filter.go | 128 +++++++++++++++++++ weed/util/path_filter_test.go | 201 ++++++++++++++++++++++++++++++ 3 files changed, 405 insertions(+), 13 deletions(-) create mode 100644 weed/util/path_filter.go create mode 100644 weed/util/path_filter_test.go diff --git a/weed/command/filer_meta_backup.go b/weed/command/filer_meta_backup.go index 89ef5b4bb..da4de58a5 100644 --- a/weed/command/filer_meta_backup.go +++ b/weed/command/filer_meta_backup.go @@ -26,9 +26,12 @@ type FilerMetaBackupOptions struct { grpcDialOption grpc.DialOption filerAddress *string filerDirectory *string + includePrefixes *string + excludePrefixes *string restart *bool backupFilerConfig *string + pathFilter *util.PathPrefixFilter store filer.FilerStore clientId int32 clientEpoch int32 @@ -38,20 +41,25 @@ func init() { cmdFilerMetaBackup.Run = runFilerMetaBackup // break init cycle metaBackup.filerAddress = cmdFilerMetaBackup.Flag.String("filer", "localhost:8888", "filer hostname:port") metaBackup.filerDirectory = cmdFilerMetaBackup.Flag.String("filerDir", "/", "a folder on the filer") + metaBackup.includePrefixes = cmdFilerMetaBackup.Flag.String("includePrefixes", "", "comma-separated path prefixes to include in backup (if set, only these paths are backed up)") + metaBackup.excludePrefixes = cmdFilerMetaBackup.Flag.String("excludePrefixes", "", "comma-separated path prefixes to exclude from backup") metaBackup.restart = cmdFilerMetaBackup.Flag.Bool("restart", false, "copy the full metadata before async incremental backup") metaBackup.backupFilerConfig = cmdFilerMetaBackup.Flag.String("config", "", "path to filer.toml specifying backup filer store") metaBackup.clientId = util.RandomInt32() } var cmdFilerMetaBackup = &Command{ - UsageLine: "filer.meta.backup [-filer=localhost:8888] [-filerDir=/] [-restart] -config=/path/to/backup_filer.toml", + UsageLine: "filer.meta.backup [-filer=localhost:8888] [-filerDir=/] [-includePrefixes=...] [-excludePrefixes=...] [-restart] -config=/path/to/backup_filer.toml", Short: "continuously backup filer meta data changes to anther filer store specified in a backup_filer.toml", - Long: `continuously backup filer meta data changes. + Long: `continuously backup filer meta data changes. The backup writes to another filer store specified in a backup_filer.toml. weed filer.meta.backup -config=/path/to/backup_filer.toml -filer="localhost:8888" weed filer.meta.backup -config=/path/to/backup_filer.toml -filer="localhost:8888" -restart +The -includePrefixes and -excludePrefixes flags accept comma-separated path prefixes. +Paths must be absolute (start with '/'). Matching is at directory boundaries. +When both match, the deeper prefix wins. `, } @@ -75,6 +83,23 @@ func runFilerMetaBackup(cmd *Command, args []string) bool { return true } + // Initialize path filter + metaBackup.pathFilter = util.NewPathPrefixFilter( + *metaBackup.includePrefixes, + *metaBackup.excludePrefixes, + func(format string, args ...interface{}) { + glog.Warningf(format, args...) + }, + ) + if metaBackup.pathFilter.HasFilters() { + if len(metaBackup.pathFilter.GetIncludePrefixes()) > 0 { + glog.V(0).Infof("including prefixes: %v", metaBackup.pathFilter.GetIncludePrefixes()) + } + if len(metaBackup.pathFilter.GetExcludePrefixes()) > 0 { + glog.V(0).Infof("excluding prefixes: %v", metaBackup.pathFilter.GetExcludePrefixes()) + } + } + missingPreviousBackup := false _, err := metaBackup.getOffset() if err != nil { @@ -127,12 +152,22 @@ func (metaBackup *FilerMetaBackupOptions) initStore(v *viper.Viper) error { return nil } +// shouldInclude checks if the given path should be included in backup +// based on the configured include/exclude path prefixes. +func (metaBackup *FilerMetaBackupOptions) shouldInclude(fullpath string) bool { + return metaBackup.pathFilter.ShouldInclude(fullpath) +} + func (metaBackup *FilerMetaBackupOptions) traverseMetadata() (err error) { var saveErr error traverseErr := filer_pb.TraverseBfs(metaBackup, util.FullPath(*metaBackup.filerDirectory), func(parentPath util.FullPath, entry *filer_pb.Entry) { + fullpath := string(parentPath.Child(entry.Name)) + if !metaBackup.shouldInclude(fullpath) { + return + } - println("+", parentPath.Child(entry.Name)) + println("+", fullpath) if err := metaBackup.store.InsertEntry(context.Background(), filer.FromPbEntry(string(parentPath), entry)); err != nil { saveErr = fmt.Errorf("insert entry error: %w\n", err) return @@ -167,25 +202,53 @@ func (metaBackup *FilerMetaBackupOptions) streamMetadataBackup() error { if filer_pb.IsEmpty(resp) { return nil - } else if filer_pb.IsCreate(resp) { - println("+", util.FullPath(message.NewParentPath).Child(message.NewEntry.Name)) + } + + // Compute exclusion for both old and new paths + var oldPathExcluded, newPathExcluded bool + var oldPath, newPath string + if message.OldEntry != nil { + oldPath = string(util.FullPath(resp.Directory).Child(message.OldEntry.Name)) + oldPathExcluded = !metaBackup.shouldInclude(oldPath) + } + if message.NewEntry != nil { + newPath = string(util.FullPath(message.NewParentPath).Child(message.NewEntry.Name)) + newPathExcluded = !metaBackup.shouldInclude(newPath) + } + + if filer_pb.IsCreate(resp) { + if newPathExcluded { + return nil + } + println("+", newPath) entry := filer.FromPbEntry(message.NewParentPath, message.NewEntry) return store.InsertEntry(ctx, entry) } else if filer_pb.IsDelete(resp) { - println("-", util.FullPath(resp.Directory).Child(message.OldEntry.Name)) + if oldPathExcluded { + return nil + } + println("-", oldPath) return store.DeleteEntry(ctx, util.FullPath(resp.Directory).Child(message.OldEntry.Name)) } else if filer_pb.IsUpdate(resp) { - println("~", util.FullPath(message.NewParentPath).Child(message.NewEntry.Name)) + if newPathExcluded { + return nil + } + println("~", newPath) entry := filer.FromPbEntry(message.NewParentPath, message.NewEntry) return store.UpdateEntry(ctx, entry) } else { - // renaming - println("-", util.FullPath(resp.Directory).Child(message.OldEntry.Name)) - if err := store.DeleteEntry(ctx, util.FullPath(resp.Directory).Child(message.OldEntry.Name)); err != nil { - return err + // renaming - handle all four combinations + if !oldPathExcluded { + println("-", oldPath) + if err := store.DeleteEntry(ctx, util.FullPath(resp.Directory).Child(message.OldEntry.Name)); err != nil { + return err + } } - println("+", util.FullPath(message.NewParentPath).Child(message.NewEntry.Name)) - return store.InsertEntry(ctx, filer.FromPbEntry(message.NewParentPath, message.NewEntry)) + if !newPathExcluded { + println("+", newPath) + return store.InsertEntry(ctx, filer.FromPbEntry(message.NewParentPath, message.NewEntry)) + } + return nil } } diff --git a/weed/util/path_filter.go b/weed/util/path_filter.go new file mode 100644 index 000000000..d74779571 --- /dev/null +++ b/weed/util/path_filter.go @@ -0,0 +1,128 @@ +package util + +import ( + "strings" +) + +// PathPrefixFilter provides filtering based on include and exclude path prefixes. +// When both include and exclude prefixes match a path, the deepest matching prefix wins. +// This enables fine-grained control like: exclude /buckets/legacy but include /buckets/legacy/important +type PathPrefixFilter struct { + includePrefixes []string // normalized with trailing / + excludePrefixes []string // normalized with trailing / +} + +// NewPathPrefixFilter creates a new PathPrefixFilter from comma-separated include and exclude prefix strings. +// Each prefix is normalized to have a trailing slash for directory boundary matching. +// Invalid prefixes (empty or not starting with /) are skipped with a warning via the provided warn function. +func NewPathPrefixFilter(includePrefixes, excludePrefixes string, warn func(format string, args ...interface{})) *PathPrefixFilter { + pf := &PathPrefixFilter{} + + pf.includePrefixes = parsePrefixes(includePrefixes, warn) + pf.excludePrefixes = parsePrefixes(excludePrefixes, warn) + + return pf +} + +// parsePrefixes parses a comma-separated list of prefixes and normalizes them. +func parsePrefixes(prefixList string, warn func(format string, args ...interface{})) []string { + if prefixList == "" { + return nil + } + + var result []string + for _, p := range strings.Split(prefixList, ",") { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if !strings.HasPrefix(p, "/") { + if warn != nil { + warn("prefix %q does not start with '/', skipping", p) + } + continue + } + // Normalize: ensure trailing slash for directory boundary matching + if !strings.HasSuffix(p, "/") { + p = p + "/" + } + result = append(result, p) + } + return result +} + +// HasFilters returns true if any include or exclude prefixes are configured. +func (pf *PathPrefixFilter) HasFilters() bool { + return len(pf.includePrefixes) > 0 || len(pf.excludePrefixes) > 0 +} + +// ShouldInclude returns true if the path should be included based on the configured prefixes. +// +// Logic: +// - If no filters are configured, include everything. +// - Find the deepest matching prefix from either include or exclude list. +// - If the deepest match is in includePrefixes, include the path. +// - If the deepest match is in excludePrefixes, exclude the path. +// - If no match is found and includePrefixes is non-empty, exclude (explicit include required). +// - If no match is found and includePrefixes is empty, include (default allow with excludes). +func (pf *PathPrefixFilter) ShouldInclude(fullpath string) bool { + if !pf.HasFilters() { + return true + } + + // Normalize path for matching + checkPath := fullpath + if !strings.HasSuffix(checkPath, "/") { + checkPath = checkPath + "/" + } + + // Find deepest matching prefix from each list + includeMatch := findDeepestMatch(checkPath, pf.includePrefixes) + excludeMatch := findDeepestMatch(checkPath, pf.excludePrefixes) + + // Determine result based on which match is deeper + if includeMatch != "" && excludeMatch != "" { + // Both matched - deeper prefix wins + return len(includeMatch) >= len(excludeMatch) + } + + if includeMatch != "" { + return true + } + + if excludeMatch != "" { + return false + } + + // No match found + if len(pf.includePrefixes) > 0 { + // If includes are specified, require explicit include + return false + } + + // Default: include if only excludes are specified + return true +} + +// findDeepestMatch finds the longest prefix that matches the path. +func findDeepestMatch(path string, prefixes []string) string { + var deepest string + for _, prefix := range prefixes { + if strings.HasPrefix(path, prefix) { + if len(prefix) > len(deepest) { + deepest = prefix + } + } + } + return deepest +} + +// GetIncludePrefixes returns the configured include prefixes. +func (pf *PathPrefixFilter) GetIncludePrefixes() []string { + return pf.includePrefixes +} + +// GetExcludePrefixes returns the configured exclude prefixes. +func (pf *PathPrefixFilter) GetExcludePrefixes() []string { + return pf.excludePrefixes +} diff --git a/weed/util/path_filter_test.go b/weed/util/path_filter_test.go new file mode 100644 index 000000000..7d4186f2d --- /dev/null +++ b/weed/util/path_filter_test.go @@ -0,0 +1,201 @@ +package util + +import ( + "testing" +) + +func TestPathPrefixFilter_Empty(t *testing.T) { + pf := NewPathPrefixFilter("", "", nil) + + if pf.HasFilters() { + t.Error("empty filter should have no filters") + } + + // Should include everything when no filters + tests := []string{"/", "/foo", "/foo/bar", "/buckets/test"} + for _, path := range tests { + if !pf.ShouldInclude(path) { + t.Errorf("empty filter should include %q", path) + } + } +} + +func TestPathPrefixFilter_ExcludeOnly(t *testing.T) { + pf := NewPathPrefixFilter("", "/buckets/legacy,/buckets/old", nil) + + tests := []struct { + path string + include bool + }{ + {"/buckets/active", true}, + {"/buckets/active/file.txt", true}, + {"/buckets/legacy", false}, + {"/buckets/legacy/file.txt", false}, + {"/buckets/legacy_new", true}, // boundary check: not a prefix match + {"/buckets/old", false}, + {"/buckets/old/data", false}, + {"/other", true}, + } + + for _, tc := range tests { + got := pf.ShouldInclude(tc.path) + if got != tc.include { + t.Errorf("ShouldInclude(%q) = %v, want %v", tc.path, got, tc.include) + } + } +} + +func TestPathPrefixFilter_IncludeOnly(t *testing.T) { + pf := NewPathPrefixFilter("/buckets/important,/data", "", nil) + + tests := []struct { + path string + include bool + }{ + {"/buckets/important", true}, + {"/buckets/important/file.txt", true}, + {"/data", true}, + {"/data/file.txt", true}, + {"/buckets/other", false}, // not in include list + {"/other", false}, + } + + for _, tc := range tests { + got := pf.ShouldInclude(tc.path) + if got != tc.include { + t.Errorf("ShouldInclude(%q) = %v, want %v", tc.path, got, tc.include) + } + } +} + +func TestPathPrefixFilter_DeeperPrefixWins(t *testing.T) { + // Exclude /buckets/keep but include /buckets/keep/important + pf := NewPathPrefixFilter("/buckets/keep/important", "/buckets/keep", nil) + + tests := []struct { + path string + include bool + }{ + {"/buckets/keep", false}, + {"/buckets/keep/other", false}, + {"/buckets/keep/important", true}, // deeper include wins + {"/buckets/keep/important/file.txt", true}, // deeper include wins + {"/buckets/other", false}, // not matched, include required + } + + for _, tc := range tests { + got := pf.ShouldInclude(tc.path) + if got != tc.include { + t.Errorf("ShouldInclude(%q) = %v, want %v", tc.path, got, tc.include) + } + } +} + +func TestPathPrefixFilter_DeeperExcludeWins(t *testing.T) { + // Include /buckets but exclude /buckets/legacy + pf := NewPathPrefixFilter("/buckets", "/buckets/legacy", nil) + + tests := []struct { + path string + include bool + }{ + {"/buckets", true}, + {"/buckets/active", true}, + {"/buckets/legacy", false}, // deeper exclude wins + {"/buckets/legacy/file.txt", false}, // deeper exclude wins + {"/other", false}, // not in include list + } + + for _, tc := range tests { + got := pf.ShouldInclude(tc.path) + if got != tc.include { + t.Errorf("ShouldInclude(%q) = %v, want %v", tc.path, got, tc.include) + } + } +} + +func TestPathPrefixFilter_MultipleOverlappingPrefixes(t *testing.T) { + // Complex scenario with multiple overlapping prefixes + pf := NewPathPrefixFilter( + "/a,/a/b/c/d", // includes + "/a/b,/a/b/c/d/e", // excludes + nil, + ) + + tests := []struct { + path string + include bool + }{ + {"/a", true}, // direct include match + {"/a/x", true}, // under include /a + {"/a/b", false}, // deeper exclude /a/b beats /a + {"/a/b/x", false}, // under exclude /a/b + {"/a/b/c", false}, // under exclude /a/b + {"/a/b/c/d", true}, // deeper include /a/b/c/d beats /a/b + {"/a/b/c/d/x", true}, // under include /a/b/c/d + {"/a/b/c/d/e", false}, // deeper exclude /a/b/c/d/e beats /a/b/c/d + {"/a/b/c/d/e/f", false}, // under exclude /a/b/c/d/e + } + + for _, tc := range tests { + got := pf.ShouldInclude(tc.path) + if got != tc.include { + t.Errorf("ShouldInclude(%q) = %v, want %v", tc.path, got, tc.include) + } + } +} + +func TestPathPrefixFilter_InvalidPrefixes(t *testing.T) { + var warnings []string + warn := func(format string, args ...interface{}) { + warnings = append(warnings, format) + } + + pf := NewPathPrefixFilter("invalid,/valid", "also_invalid", warn) + + if len(warnings) != 2 { + t.Errorf("expected 2 warnings, got %d", len(warnings)) + } + + // Only valid prefix should be stored + if len(pf.includePrefixes) != 1 { + t.Errorf("expected 1 include prefix, got %d", len(pf.includePrefixes)) + } + if len(pf.excludePrefixes) != 0 { + t.Errorf("expected 0 exclude prefixes, got %d", len(pf.excludePrefixes)) + } +} + +func TestPathPrefixFilter_TrailingSlashNormalization(t *testing.T) { + pf := NewPathPrefixFilter("/path/to/dir", "/exclude/this/", nil) + + // Both should be normalized with trailing slash + if pf.includePrefixes[0] != "/path/to/dir/" { + t.Errorf("include prefix not normalized: %q", pf.includePrefixes[0]) + } + if pf.excludePrefixes[0] != "/exclude/this/" { + t.Errorf("exclude prefix not normalized: %q", pf.excludePrefixes[0]) + } +} + +func TestPathPrefixFilter_BoundaryMatching(t *testing.T) { + pf := NewPathPrefixFilter("", "/buckets/legacy1", nil) + + tests := []struct { + path string + include bool + }{ + {"/buckets/legacy1", false}, + {"/buckets/legacy1/file", false}, + {"/buckets/legacy1_backup", true}, // not a prefix match due to boundary + {"/buckets/legacy10", true}, // not a prefix match due to boundary + {"/buckets/legacy", true}, + } + + for _, tc := range tests { + got := pf.ShouldInclude(tc.path) + if got != tc.include { + t.Errorf("ShouldInclude(%q) = %v, want %v", tc.path, got, tc.include) + } + } +} From b034cf188e5cef98789eb2935d382cda58f80422 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 30 Dec 2025 14:54:37 -0800 Subject: [PATCH 60/66] Fix: trim prefix slash in ListObjectVersionsHandler (#7919) * Fix: trim prefix slash in ListObjectVersionsHandler * Add test for ListObjectVersions prefix handling Test validates that prefix normalization works correctly with and without leading slashes, ensuring the fix for /Veeam/Archive/ style prefixes. * Simplify prefix test to validate normalization logic The test now validates that the prefix normalization (TrimPrefix) works correctly and that normalized prefixes match paths as expected. This is a focused unit test that validates the core fix without requiring complex mocking of the filer client. * Enhance prefix test with full matchesPrefixFilter logic Added test cases for directory traversal including: - Directory matching with trailing slash - canDescend logic for recursive directory search - Full simulation of matchesPrefixFilter behavior This provides more comprehensive coverage of the prefix normalization fix and ensures it works correctly for both files and directories. --- ...api_object_handlers_list_versioned_test.go | 84 +++++++++++++++++++ weed/s3api/s3api_object_versioning.go | 2 +- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/weed/s3api/s3api_object_handlers_list_versioned_test.go b/weed/s3api/s3api_object_handlers_list_versioned_test.go index 8252dc4a9..fdadcfe57 100644 --- a/weed/s3api/s3api_object_handlers_list_versioned_test.go +++ b/weed/s3api/s3api_object_handlers_list_versioned_test.go @@ -431,3 +431,87 @@ func (c *customTestFilerClient) ListEntries(ctx context.Context, in *filer_pb.Li (*c.traversedDirs)[in.Directory] = true return c.testFilerClient.ListEntries(ctx, in, opts...) } + +// TestListObjectVersions_PrefixWithLeadingSlash tests that prefixes with leading slashes work correctly +// This validates the fix for the bug where "/Veeam/Archive/" would fail to match relative paths +func TestListObjectVersions_PrefixWithLeadingSlash(t *testing.T) { + tests := []struct { + name string + inputPrefix string + expectedNormalized string + entryPath string + isDirectory bool + shouldMatch bool + }{ + { + name: "Prefix without leading slash matches file", + inputPrefix: "Veeam/Archive/", + expectedNormalized: "Veeam/Archive/", + entryPath: "Veeam/Archive/file.txt", + isDirectory: false, + shouldMatch: true, + }, + { + name: "Prefix with leading slash (bug fix test) - normalized and matches file", + inputPrefix: "/Veeam/Archive/", + expectedNormalized: "Veeam/Archive/", + entryPath: "Veeam/Archive/file.txt", + isDirectory: false, + shouldMatch: true, + }, + { + name: "Normalized prefix matches subdirectory file", + inputPrefix: "/Veeam/", + expectedNormalized: "Veeam/", + entryPath: "Veeam/Backup/file.txt", + isDirectory: false, + shouldMatch: true, + }, + { + name: "Normalized prefix does not match different path", + inputPrefix: "/Veeam/Archive/", + expectedNormalized: "Veeam/Archive/", + entryPath: "Veeam/Backup/file.txt", + isDirectory: false, + shouldMatch: false, + }, + { + name: "Prefix with leading slash allows descending into directory", + inputPrefix: "/Veeam/Archive/", + expectedNormalized: "Veeam/Archive/", + entryPath: "Veeam", + isDirectory: true, + shouldMatch: true, // canDescend is true + }, + { + name: "Prefix with leading slash matches directory with trailing slash", + inputPrefix: "/Veeam/", + expectedNormalized: "Veeam/", + entryPath: "Veeam", + isDirectory: true, + shouldMatch: true, // isMatch becomes true + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // This is the normalization logic from ListObjectVersionsHandler (the fix) + normalizedPrefix := strings.TrimPrefix(tt.inputPrefix, "/") + + // Verify normalization worked correctly + assert.Equal(t, tt.expectedNormalized, normalizedPrefix, + "Prefix normalization should strip leading slash") + + // This simulates the full matchesPrefixFilter logic used in findVersionsRecursively + isMatch := strings.HasPrefix(tt.entryPath, normalizedPrefix) + if !isMatch && tt.isDirectory { + isMatch = strings.HasPrefix(tt.entryPath+"/", normalizedPrefix) + } + canDescend := tt.isDirectory && strings.HasPrefix(normalizedPrefix, tt.entryPath) + matches := isMatch || canDescend + + assert.Equal(t, tt.shouldMatch, matches, + "Normalized prefix should correctly match/not match the path based on full filter logic") + }) + } +} diff --git a/weed/s3api/s3api_object_versioning.go b/weed/s3api/s3api_object_versioning.go index 3fadc46cb..6221eac1b 100644 --- a/weed/s3api/s3api_object_versioning.go +++ b/weed/s3api/s3api_object_versioning.go @@ -985,7 +985,7 @@ func (s3a *S3ApiServer) ListObjectVersionsHandler(w http.ResponseWriter, r *http // Parse query parameters query := r.URL.Query() originalPrefix := query.Get("prefix") // Keep original prefix for response - prefix := originalPrefix // Use for internal processing + prefix := strings.TrimPrefix(originalPrefix, "/") // Note: prefix is used for filtering relative to bucket root, so no leading slash needed keyMarker := query.Get("key-marker") From e3db95e0c1dacf3494c995efa65a7ffa9e7cbffc Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 30 Dec 2025 16:34:05 -0800 Subject: [PATCH 61/66] Fix: Route unauthenticated specific STS requests to STS handler correctly (#7920) * Fix STS Access Denied for AssumeRoleWithWebIdentity (Issue #7917) * Fix logging regression: ensure IAM status is logged even if STS is enabled * Address PR feedback: fix duplicate log, clarify comments, add comprehensive routing tests * Add edge case test: authenticated STS action routes to IAM (auth precedence) --- weed/s3api/s3api_server.go | 28 +++- weed/s3api/s3api_server_routing_test.go | 195 ++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 weed/s3api/s3api_server_routing_test.go diff --git a/weed/s3api/s3api_server.go b/weed/s3api/s3api_server.go index 5917b5195..530a8af4b 100644 --- a/weed/s3api/s3api_server.go +++ b/weed/s3api/s3api_server.go @@ -618,9 +618,8 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) { // STS API endpoint for AssumeRoleWithWebIdentity // POST /?Action=AssumeRoleWithWebIdentity&WebIdentityToken=... - // This endpoint is unauthenticated - the JWT token in the request is the authentication - // IMPORTANT: Register this BEFORE the general IAM route to prevent interception if s3a.stsHandlers != nil { + // 1. Explicit query param match (highest priority) apiRouter.Methods(http.MethodPost).Path("/").Queries("Action", "AssumeRoleWithWebIdentity"). HandlerFunc(track(s3a.stsHandlers.HandleSTSRequest, "STS")) glog.V(0).Infof("STS API enabled on S3 port (AssumeRoleWithWebIdentity)") @@ -628,15 +627,30 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) { // Embedded IAM API endpoint // POST / (without specific query parameters) - // This must be before ListBuckets since IAM uses POST and ListBuckets uses GET - // Uses AuthIam for granular permission checking: - // - Self-service operations (own access keys) don't require admin - // - Operations on other users require admin privileges + // Uses AuthIam for granular permission checking if s3a.embeddedIam != nil { - apiRouter.Methods(http.MethodPost).Path("/").HandlerFunc(track(s3a.embeddedIam.AuthIam(s3a.cb.Limit(s3a.embeddedIam.DoActions, ACTION_WRITE)), "IAM")) + // 2. Authenticated IAM requests + // Only match if the request appears to be authenticated (AWS Signature) + // This prevents unauthenticated STS requests (like AssumeRoleWithWebIdentity in body) + // from being captured by the IAM handler which would reject them. + iamMatcher := func(r *http.Request, rm *mux.RouteMatch) bool { + return getRequestAuthType(r) != authTypeAnonymous + } + + apiRouter.Methods(http.MethodPost).Path("/").MatcherFunc(iamMatcher). + HandlerFunc(track(s3a.embeddedIam.AuthIam(s3a.cb.Limit(s3a.embeddedIam.DoActions, ACTION_WRITE)), "IAM")) glog.V(0).Infof("Embedded IAM API enabled on S3 port") } + // 3. Fallback STS handler (lowest priority) + // Catches unauthenticated POST / requests that didn't match specific query params. + // This primarily handles AssumeRoleWithWebIdentity where parameters are in the POST body. + if s3a.stsHandlers != nil { + glog.V(1).Infof("Registering fallback STS handler for unauthenticated POST requests") + apiRouter.Methods(http.MethodPost).Path("/"). + HandlerFunc(track(s3a.stsHandlers.HandleSTSRequest, "STS-Fallback")) + } + // ListBuckets apiRouter.Methods(http.MethodGet).Path("/").HandlerFunc(track(s3a.iam.Auth(s3a.ListBucketsHandler, ACTION_LIST), "LIST")) diff --git a/weed/s3api/s3api_server_routing_test.go b/weed/s3api/s3api_server_routing_test.go new file mode 100644 index 000000000..5aed24d39 --- /dev/null +++ b/weed/s3api/s3api_server_routing_test.go @@ -0,0 +1,195 @@ +package s3api + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gorilla/mux" + "github.com/seaweedfs/seaweedfs/weed/credential" + "github.com/seaweedfs/seaweedfs/weed/util" + "github.com/stretchr/testify/assert" +) + +// setupRoutingTestServer creates a minimal S3ApiServer for routing tests +func setupRoutingTestServer(t *testing.T) *S3ApiServer { + opt := &S3ApiServerOption{EnableIam: true} + iam := NewIdentityAccessManagementWithStore(opt, "memory") + iam.isAuthEnabled = true + + if iam.credentialManager == nil { + cm, err := credential.NewCredentialManager("memory", util.GetViper(), "") + if err != nil { + t.Fatalf("Failed to create credential manager: %v", err) + } + iam.credentialManager = cm + } + + server := &S3ApiServer{ + option: opt, + iam: iam, + credentialManager: iam.credentialManager, + embeddedIam: NewEmbeddedIamApi(iam.credentialManager, iam), + stsHandlers: &STSHandlers{}, + } + + return server +} + +// TestRouting_STSWithQueryParams verifies that AssumeRoleWithWebIdentity with query params routes to STS +func TestRouting_STSWithQueryParams(t *testing.T) { + router := mux.NewRouter() + s3a := setupRoutingTestServer(t) + s3a.registerRouter(router) + + // Create request with Action in query params (no auth header) + req, _ := http.NewRequest("POST", "/?Action=AssumeRoleWithWebIdentity&WebIdentityToken=test-token&RoleArn=arn:aws:iam::123:role/test&RoleSessionName=test-session", nil) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + // Should route to STS handler -> 503 (service not initialized) or 400 (validation error) + assert.Contains(t, []int{http.StatusBadRequest, http.StatusServiceUnavailable}, rr.Code, "Should route to STS handler") +} + +// TestRouting_STSWithBodyParams verifies that AssumeRoleWithWebIdentity with body params routes to STS fallback +func TestRouting_STSWithBodyParams(t *testing.T) { + router := mux.NewRouter() + s3a := setupRoutingTestServer(t) + s3a.registerRouter(router) + + // Create request with Action in POST body (no auth header) + data := url.Values{} + data.Set("Action", "AssumeRoleWithWebIdentity") + data.Set("WebIdentityToken", "test-token") + data.Set("RoleArn", "arn:aws:iam::123:role/test") + data.Set("RoleSessionName", "test-session") + + req, _ := http.NewRequest("POST", "/", strings.NewReader(data.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + // Should route to STS fallback handler -> 503 (service not initialized in test) + assert.Equal(t, http.StatusServiceUnavailable, rr.Code, "Should route to STS fallback handler (503 because STS not initialized)") +} + +// TestRouting_AuthenticatedIAM verifies that authenticated IAM requests route to IAM handler +func TestRouting_AuthenticatedIAM(t *testing.T) { + router := mux.NewRouter() + s3a := setupRoutingTestServer(t) + s3a.registerRouter(router) + + // Create IAM request with Authorization header + data := url.Values{} + data.Set("Action", "CreateUser") + data.Set("UserName", "testuser") + + req, _ := http.NewRequest("POST", "/", strings.NewReader(data.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIA.../...") + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + // Should route to IAM handler -> 400/403 (invalid signature) + // NOT 503 (which would indicate STS handler) + assert.NotEqual(t, http.StatusServiceUnavailable, rr.Code, "Should NOT route to STS handler") + assert.Contains(t, []int{http.StatusBadRequest, http.StatusForbidden}, rr.Code, "Should route to IAM handler (400/403 due to invalid signature)") +} + +// TestRouting_IAMMatcherLogic verifies the iamMatcher correctly distinguishes auth types +func TestRouting_IAMMatcherLogic(t *testing.T) { + tests := []struct { + name string + authHeader string + queryParams string + expectsIAM bool + description string + }{ + { + name: "No auth - anonymous", + authHeader: "", + queryParams: "", + expectsIAM: false, + description: "Request with no auth should NOT match IAM", + }, + { + name: "AWS4 signature", + authHeader: "AWS4-HMAC-SHA256 Credential=AKIA.../...", + queryParams: "", + expectsIAM: true, + description: "Request with AWS4 signature should match IAM", + }, + { + name: "AWS2 signature", + authHeader: "AWS AKIA...:signature", + queryParams: "", + expectsIAM: true, + description: "Request with AWS2 signature should match IAM", + }, + { + name: "Presigned V4", + authHeader: "", + queryParams: "?X-Amz-Credential=AKIA...", + expectsIAM: true, + description: "Request with presigned V4 params should match IAM", + }, + { + name: "Presigned V2", + authHeader: "", + queryParams: "?AWSAccessKeyId=AKIA...", + expectsIAM: true, + description: "Request with presigned V2 params should match IAM", + }, + { + name: "AWS4 signature with STS action in body", + authHeader: "AWS4-HMAC-SHA256 Credential=AKIA.../...", + queryParams: "", + expectsIAM: true, + description: "Authenticated STS action should still route to IAM (auth takes precedence)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := mux.NewRouter() + s3a := setupRoutingTestServer(t) + s3a.registerRouter(router) + + data := url.Values{} + // For the authenticated STS action test, set the STS action + // For other tests, don't set Action to avoid STS validation errors + if tt.name == "AWS4 signature with STS action in body" { + data.Set("Action", "AssumeRoleWithWebIdentity") + data.Set("WebIdentityToken", "test-token") + data.Set("RoleArn", "arn:aws:iam::123:role/test") + data.Set("RoleSessionName", "test-session") + } + + req, _ := http.NewRequest("POST", "/"+tt.queryParams, strings.NewReader(data.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if tt.authHeader != "" { + req.Header.Set("Authorization", tt.authHeader) + } + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + if tt.expectsIAM { + // Should route to IAM (400/403 for invalid sig) + // NOT 400 from STS (which would be missing Action parameter) + // We distinguish by checking it's NOT a generic 400 with empty body + assert.NotEqual(t, http.StatusServiceUnavailable, rr.Code, tt.description) + } else { + // Should route to STS fallback + // Can be 503 (service not initialized) or 400 (missing/invalid Action parameter) + assert.Contains(t, []int{http.StatusBadRequest, http.StatusServiceUnavailable}, rr.Code, tt.description) + } + }) + } +} From 7bd9f6b5d838c78a82acd14fbc272f2e87c667d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Dec 2025 16:35:41 -0800 Subject: [PATCH 62/66] chore(deps): bump modernc.org/sqlite from 1.39.0 to 1.42.2 (#7904) Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.39.0 to 1.42.2. - [Commits](https://gitlab.com/cznic/sqlite/compare/v1.39.0...v1.42.2) --- updated-dependencies: - dependency-name: modernc.org/sqlite dependency-version: 1.42.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 58edae83a..36dd2f3a5 100644 --- a/go.mod +++ b/go.mod @@ -113,7 +113,7 @@ require ( modernc.org/b v1.0.0 // indirect modernc.org/mathutil v1.7.1 modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.39.0 + modernc.org/sqlite v1.42.2 modernc.org/strutil v1.2.1 ) @@ -454,7 +454,7 @@ require ( gopkg.in/validator.v2 v2.0.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.66.3 // indirect + modernc.org/libc v1.66.10 // indirect moul.io/http2curl/v2 v2.3.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect storj.io/common v0.0.0-20250808122759-804533d519c1 // indirect diff --git a/go.sum b/go.sum index 9232a08f0..6c35b8775 100644 --- a/go.sum +++ b/go.sum @@ -2678,19 +2678,19 @@ modernc.org/b v1.0.0/go.mod h1:uZWcZfRj1BpYzfN9JTerzlNUnnPsV9O2ZA8JsRcubNg= modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= -modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= -modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc= modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw= modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ= modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws= modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo= -modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= -modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= -modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= -modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= @@ -2703,8 +2703,8 @@ modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU= modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA= modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0= modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s= -modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= -modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/mathutil v1.1.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= @@ -2723,8 +2723,8 @@ modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4= -modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= -modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74= +modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= modernc.org/strutil v1.1.0/go.mod h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs= modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= From 4391cff2e822ea3a4b20dda459b0a20b67519acf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Dec 2025 16:35:49 -0800 Subject: [PATCH 63/66] chore(deps): bump google.golang.org/api from 0.247.0 to 0.258.0 (#7905) Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.247.0 to 0.258.0. - [Release notes](https://github.com/googleapis/google-api-go-client/releases) - [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md) - [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.247.0...v0.258.0) --- updated-dependencies: - dependency-name: google.golang.org/api dependency-version: 0.258.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 14 +++++++------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 36dd2f3a5..e3babedd3 100644 --- a/go.mod +++ b/go.mod @@ -100,15 +100,15 @@ require ( golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 golang.org/x/image v0.34.0 golang.org/x/net v0.48.0 - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.39.0 golang.org/x/text v0.32.0 // indirect golang.org/x/tools v0.39.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - google.golang.org/api v0.247.0 + google.golang.org/api v0.258.0 google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 // indirect google.golang.org/grpc v1.77.0 - google.golang.org/protobuf v1.36.10 + google.golang.org/protobuf v1.36.11 gopkg.in/inf.v0 v0.9.1 // indirect modernc.org/b v1.0.0 // indirect modernc.org/mathutil v1.7.1 @@ -227,7 +227,7 @@ require ( require ( cel.dev/expr v0.24.0 // indirect - cloud.google.com/go/auth v0.16.5 // indirect + cloud.google.com/go/auth v0.17.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect @@ -328,7 +328,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/schema v1.4.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect @@ -447,9 +447,9 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/term v0.38.0 // indirect - golang.org/x/time v0.12.0 // indirect + golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/validator.v2 v2.0.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 6c35b8775..17b4abb54 100644 --- a/go.sum +++ b/go.sum @@ -86,8 +86,8 @@ cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVo cloud.google.com/go/assuredworkloads v1.8.0/go.mod h1:AsX2cqyNCOvEQC8RMPnoc0yEarXQk6WEKkxYfL6kGIo= cloud.google.com/go/assuredworkloads v1.9.0/go.mod h1:kFuI1P78bplYtT77Tb1hi0FMxM0vVpRC7VVoJC3ZoT0= cloud.google.com/go/assuredworkloads v1.10.0/go.mod h1:kwdUQuXcedVdsIaKgKTp9t0UJkE5+PAVNhdQm4ZVq2E= -cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI= -cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= +cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= +cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0= @@ -1148,8 +1148,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= -github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= @@ -2088,8 +2088,8 @@ golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec= golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -2269,8 +2269,8 @@ golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -2422,8 +2422,8 @@ google.golang.org/api v0.106.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/ google.golang.org/api v0.107.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= google.golang.org/api v0.108.0/go.mod h1:2Ts0XTHNVWxypznxWOYUeI4g3WdP9Pk2Qk58+a/O9MY= google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI= -google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc= -google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM= +google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc= +google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -2561,8 +2561,8 @@ google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79 h1:Nt6z9UHqSlIdIGJ google.golang.org/genproto v0.0.0-20250715232539-7130f93afb79/go.mod h1:kTmlBHMPqR5uCZPBvwa2B18mvubkjyY3CRLI0c6fj0s= google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4= google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba h1:UKgtfRM7Yh93Sya0Fo8ZzhDP4qBckrrxEr2oF5UIVb8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -2626,8 +2626,8 @@ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From ea4a2422f4e31ad98561497a0dd429ebae39abc0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Dec 2025 16:35:57 -0800 Subject: [PATCH 64/66] chore(deps): bump github.com/schollz/progressbar/v3 from 3.18.0 to 3.19.0 (#7906) chore(deps): bump github.com/schollz/progressbar/v3 Bumps [github.com/schollz/progressbar/v3](https://github.com/schollz/progressbar) from 3.18.0 to 3.19.0. - [Release notes](https://github.com/schollz/progressbar/releases) - [Commits](https://github.com/schollz/progressbar/compare/v3.18.0...v3.19.0) --- updated-dependencies: - dependency-name: github.com/schollz/progressbar/v3 dependency-version: 3.19.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e3babedd3..ec8df29f6 100644 --- a/go.mod +++ b/go.mod @@ -153,7 +153,7 @@ require ( github.com/rclone/rclone v1.71.2 github.com/rdleal/intervalst v1.5.0 github.com/redis/go-redis/v9 v9.17.2 - github.com/schollz/progressbar/v3 v3.18.0 + github.com/schollz/progressbar/v3 v3.19.0 github.com/shirou/gopsutil/v4 v4.25.11 github.com/tarantool/go-tarantool/v2 v2.4.1 github.com/tikv/client-go/v2 v2.0.7 diff --git a/go.sum b/go.sum index 17b4abb54..1011d7939 100644 --- a/go.sum +++ b/go.sum @@ -1614,8 +1614,8 @@ github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM= -github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA= -github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= +github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= +github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/seaweedfs/cockroachdb-parser v0.0.0-20251021184156-909763b17138 h1:bX1vBF7GQjPeFQsCAZ8gCQGS/nJQnekL7gZ4Qg/pF4E= github.com/seaweedfs/cockroachdb-parser v0.0.0-20251021184156-909763b17138/go.mod h1:JSKCh6uCHBz91lQYFYHCyTrSVIPge4SUFVn28iwMNB0= github.com/seaweedfs/goexif v1.0.3 h1:ve/OjI7dxPW8X9YQsv3JuVMaxEyF9Rvfd04ouL+Bz30= From b1a9f344fe12e5b4be85d59ad31f42b8f3efc8e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Dec 2025 16:36:03 -0800 Subject: [PATCH 65/66] chore(deps): bump gocloud.dev/pubsub/natspubsub from 0.43.0 to 0.44.0 (#7907) Bumps [gocloud.dev/pubsub/natspubsub](https://github.com/google/go-cloud) from 0.43.0 to 0.44.0. - [Release notes](https://github.com/google/go-cloud/releases) - [Commits](https://github.com/google/go-cloud/compare/v0.43.0...v0.44.0) --- updated-dependencies: - dependency-name: gocloud.dev/pubsub/natspubsub dependency-version: 0.44.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 10 +++++----- go.sum | 27 ++++++++++----------------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index ec8df29f6..6457fcfbe 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/golang/snappy v1.0.0 github.com/google/btree v1.1.3 github.com/google/uuid v1.6.0 - github.com/google/wire v0.6.0 // indirect + github.com/google/wire v0.7.0 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gorilla/mux v1.8.1 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect @@ -93,8 +93,8 @@ require ( go.etcd.io/etcd/client/v3 v3.6.6 go.mongodb.org/mongo-driver v1.17.6 go.opencensus.io v0.24.0 // indirect - gocloud.dev v0.43.0 - gocloud.dev/pubsub/natspubsub v0.43.0 + gocloud.dev v0.44.0 + gocloud.dev/pubsub/natspubsub v0.44.0 gocloud.dev/pubsub/rabbitpubsub v0.43.0 golang.org/x/crypto v0.46.0 golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 @@ -171,7 +171,7 @@ require github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 // ind require ( cloud.google.com/go/longrunning v0.6.7 // indirect - cloud.google.com/go/pubsub/v2 v2.0.0 // indirect + cloud.google.com/go/pubsub/v2 v2.2.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect github.com/bazelbuild/rules_go v0.46.0 // indirect @@ -262,7 +262,7 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect - github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.3 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect diff --git a/go.sum b/go.sum index 1011d7939..9e0b535c8 100644 --- a/go.sum +++ b/go.sum @@ -385,8 +385,8 @@ cloud.google.com/go/pubsub v1.27.1/go.mod h1:hQN39ymbV9geqBnfQq6Xf63yNhUAhv9CZhz cloud.google.com/go/pubsub v1.28.0/go.mod h1:vuXFpwaVoIPQMGXqRyUQigu/AX1S3IWugR9xznmcXX8= cloud.google.com/go/pubsub v1.50.1 h1:fzbXpPyJnSGvWXF1jabhQeXyxdbCIkXTpjXHy7xviBM= cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk= -cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0= -cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= +cloud.google.com/go/pubsub/v2 v2.2.1 h1:3brZcshL3fIiD1qOxAE2QW9wxsfjioy014x4yC9XuYI= +cloud.google.com/go/pubsub/v2 v2.2.1/go.mod h1:O5f0KHG9zDheZAd3z5rlCRhxt2JQtB+t/IYLKK3Bpvw= cloud.google.com/go/pubsublite v1.5.0/go.mod h1:xapqNQ1CuLfGi23Yda/9l4bBCKz/wC3KIJ5gKcxveZg= cloud.google.com/go/pubsublite v1.6.0/go.mod h1:1eFCS0U11xlOuMFV/0iBqw3zP12kddMeCbj/F3FSj9k= cloud.google.com/go/recaptchaenterprise v1.3.1/go.mod h1:OdD+q+y4XGeAlxRaMn1Y7/GveP6zmq76byL6tjPE7d4= @@ -675,8 +675,8 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRt github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.4 h1:0SzCLoPRSK3qSydsaFQWugP+lOBCTPwfcBOm6222+UA= -github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.18.4/go.mod h1:JAet9FsBHjfdI+TnMBX4ModNNaQHAd3dc/Bk+cNsxeM= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.3 h1:4GNV1lhyELGjMz5ILMRxDvxvOaeo3Ux9Z69S1EgVMMQ= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.3/go.mod h1:br7KA6edAAqDGUYJ+zVVPAyMrPhnN+zdt17yTUT6FPw= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= @@ -1134,15 +1134,14 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI= -github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= +github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8= github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg= @@ -1888,10 +1887,10 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -gocloud.dev v0.43.0 h1:aW3eq4RMyehbJ54PMsh4hsp7iX8cO/98ZRzJJOzN/5M= -gocloud.dev v0.43.0/go.mod h1:eD8rkg7LhKUHrzkEdLTZ+Ty/vgPHPCd+yMQdfelQVu4= -gocloud.dev/pubsub/natspubsub v0.43.0 h1:k35tFoaorvD9Fa26zVEEzyXiMOEyXNHc0pBOmRYvQI0= -gocloud.dev/pubsub/natspubsub v0.43.0/go.mod h1:xJn8TO8pGYieDn6AsRFsYfhQW8cnC+xGmG9APGNxkpQ= +gocloud.dev v0.44.0 h1:iVyMAqFl2r6xUy7M4mfqwlN+21UpJoEtgHEcfiLMUXs= +gocloud.dev v0.44.0/go.mod h1:ZmjROXGdC/eKZLF1N+RujDlFRx3D+4Av2thREKDMVxY= +gocloud.dev/pubsub/natspubsub v0.44.0 h1:1Us76ckkdgtiE1p1rJZ+38b9TQP051bmjAiQlFQzYrM= +gocloud.dev/pubsub/natspubsub v0.44.0/go.mod h1:PvVAGIhL14PWGwWIXX/zAK42ixr2/PKP4Q4yMiAUraQ= gocloud.dev/pubsub/rabbitpubsub v0.43.0 h1:6nNZFSlJ1dk2GujL8PFltfLz3vC6IbrpjGS4FTduo1s= gocloud.dev/pubsub/rabbitpubsub v0.43.0/go.mod h1:sEaueAGat+OASRoB3QDkghCtibKttgg7X6zsPTm1pl0= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= @@ -1913,7 +1912,6 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= @@ -1984,7 +1982,6 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= @@ -2054,7 +2051,6 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= @@ -2214,7 +2210,6 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -2233,7 +2228,6 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= @@ -2341,7 +2335,6 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= From 0995214b3717b65d9e4879442d9635c6d88abd5b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Dec 2025 16:36:12 -0800 Subject: [PATCH 66/66] chore(deps): bump github.com/parquet-go/parquet-go from 0.25.1 to 0.26.3 (#7908) Bumps [github.com/parquet-go/parquet-go](https://github.com/parquet-go/parquet-go) from 0.25.1 to 0.26.3. - [Release notes](https://github.com/parquet-go/parquet-go/releases) - [Changelog](https://github.com/parquet-go/parquet-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/parquet-go/parquet-go/compare/v0.25.1...v0.26.3) --- updated-dependencies: - dependency-name: github.com/parquet-go/parquet-go dependency-version: 0.26.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 8 ++++---- go.sum | 8 ++++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 6457fcfbe..30cbc98a5 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/seaweedfs/seaweedfs -go 1.24.0 - -toolchain go1.24.1 +go 1.24.9 require ( cloud.google.com/go v0.121.6 // indirect @@ -147,7 +145,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.32 github.com/minio/crc64nvme v1.1.1 github.com/orcaman/concurrent-map/v2 v2.0.1 - github.com/parquet-go/parquet-go v0.25.1 + github.com/parquet-go/parquet-go v0.26.3 github.com/pkg/sftp v1.13.10 github.com/rabbitmq/amqp091-go v1.10.0 github.com/rclone/rclone v1.71.2 @@ -202,6 +200,8 @@ require ( github.com/kr/text v0.2.0 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/openzipkin/zipkin-go v0.4.3 // indirect + github.com/parquet-go/bitpack v1.0.0 // indirect + github.com/parquet-go/jsonlite v1.0.0 // indirect github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect github.com/pierrre/geohash v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect diff --git a/go.sum b/go.sum index 9e0b535c8..63b407cfa 100644 --- a/go.sum +++ b/go.sum @@ -1470,8 +1470,12 @@ github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q github.com/ory/dockertest/v3 v3.6.0/go.mod h1:4ZOpj8qBUmh8fcBSVzkH2bws2s91JdGvHUqan4GHEuQ= github.com/panjf2000/ants/v2 v2.11.3 h1:AfI0ngBoXJmYOpDh9m516vjqoUu2sLrIVgppI9TZVpg= github.com/panjf2000/ants/v2 v2.11.3/go.mod h1:8u92CYMUc6gyvTIw8Ru7Mt7+/ESnJahz5EVtqfrilek= -github.com/parquet-go/parquet-go v0.25.1 h1:l7jJwNM0xrk0cnIIptWMtnSnuxRkwq53S+Po3KG8Xgo= -github.com/parquet-go/parquet-go v0.25.1/go.mod h1:AXBuotO1XiBtcqJb/FKFyjBG4aqa3aQAAWF3ZPzCanY= +github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA= +github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs= +github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU= +github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0= +github.com/parquet-go/parquet-go v0.26.3 h1:kJY+xmjcR7BH77tyHqasJpIl3kch/6EIO3TW4tFj69M= +github.com/parquet-go/parquet-go v0.26.3/go.mod h1:h9GcSt41Knf5qXI1tp1TfR8bDBUtvdUMzSKe26aZcHk= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=