Files
seaweedfs/weed/s3api/s3api_sosapi.go
T
Chris LuandGitHub a2ff9cca27 master: let VolumeList ask for the volumes it wants (#10674)
* master: let VolumeList ask for the volumes it wants

The request carried nothing, so every caller was answered with the whole
cluster. A dashboard opening one volume's page, or a capacity probe adding up
one bucket, was served all 800k of them and threw away the rest -- and the
master built every one of those messages first.

The topology, its disks and their counters are still reported in full: a caller
reading free space or replica placement needs the cluster whichever volumes it
asked about. Only what is listed under a disk is selected, ec shards included.

An empty collection and a zero volume id take everything, the way volume.list
already reads its own -collectionPattern and -volumeId, so a caller that
forgets to narrow is answered too much rather than answered wrongly. That
leaves the default collection unnameable, since it is the one the empty string
names, so it gets a field of its own.

An older client sends none of it and is answered exactly as before.

* admin: ask the master for the volume the page is showing

A volume's detail page was pulling every volume in the cluster to find one and
its replicas, and discarding the rest.

* admin: ask the master for the ec volume the page is showing

Same as the volume detail page: one volume's shards were found by pulling every
ec shard in the cluster.

* s3: ask the master for the bucket's own collection

The SOSAPI capacity probe summed one collection's volumes out of a listing of
every volume in the cluster. Cluster capacity still comes out the same: it is
read from the disk counters, which a filtered listing reports in full.

* topology: read the disk usage counters atomically

They are written with atomic.AddInt64 from heartbeats but were read plainly by
the two listings and by FreeSpace, and the map they sit in was iterated without
the lock its neighbour takes. Under -race a listing concurrent with a heartbeat
trips on both.
2026-08-09 21:59:42 -07:00

336 lines
11 KiB
Go

// Package s3api implements the S3 API for SeaweedFS.
// This file implements the Smart Object Storage API (SOSAPI) which enables
// enterprise backup software to automatically discover storage system
// capabilities and capacity information.
package s3api
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
"github.com/seaweedfs/seaweedfs/weed/util/version"
)
// SOSAPI constants
const (
// sosAPISystemFolder is the well-known folder path for SOSAPI system files.
// This UUID-based path is part of the SOSAPI specification.
sosAPISystemFolder = ".system-d26a9498-cb7c-4a87-a44a-8ae204f5ba6c"
// sosAPISystemXML is the path to the system capabilities XML file.
sosAPISystemXML = sosAPISystemFolder + "/system.xml"
// sosAPICapacityXML is the path to the capacity information XML file.
sosAPICapacityXML = sosAPISystemFolder + "/capacity.xml"
// sosAPIProtocolVersion is the SOSAPI protocol version supported.
sosAPIProtocolVersion = `"1.0"`
// sosAPIDefaultBlockSizeKB is the recommended block size in KB.
// 4096 KB (4MB) is optimal for object storage workloads.
sosAPIDefaultBlockSizeKB = 4096
)
// SystemInfo represents the system.xml response structure for SOSAPI.
// It describes the storage system's capabilities and recommendations.
type SystemInfo struct {
XMLName xml.Name `xml:"SystemInfo"`
ProtocolVersion string `xml:"ProtocolVersion"`
ModelName string `xml:"ModelName"`
ProtocolCapabilities struct {
CapacityInfo bool `xml:"CapacityInfo"`
UploadSessions bool `xml:"UploadSessions"`
IAMSTS bool `xml:"IAMSTS"`
} `xml:"ProtocolCapabilities"`
APIEndpoints *APIEndpoints `xml:"APIEndpoints,omitempty"`
SystemRecommendations *SystemRecommendations `xml:"SystemRecommendations,omitempty"`
}
// APIEndpoints contains optional IAM and STS endpoint information.
type APIEndpoints struct {
IAMEndpoint string `xml:"IAMEndpoint,omitempty"`
STSEndpoint string `xml:"STSEndpoint,omitempty"`
}
// SystemRecommendations contains storage system performance recommendations.
type SystemRecommendations struct {
S3ConcurrentTaskLimit int `xml:"S3ConcurrentTaskLimit,omitempty"`
S3MultiObjectDeleteLimit int `xml:"S3MultiObjectDeleteLimit,omitempty"`
StorageCurrentTaskLimit int `xml:"StorageCurrentTaskLimit,omitempty"`
KBBlockSize int `xml:"KbBlockSize"`
}
// CapacityInfo represents the capacity.xml response structure for SOSAPI.
// It provides real-time storage capacity information.
type CapacityInfo struct {
XMLName xml.Name `xml:"CapacityInfo"`
Capacity int64 `xml:"Capacity"`
Available int64 `xml:"Available"`
Used int64 `xml:"Used"`
}
// isSOSAPIObject checks if the given object path is a SOSAPI virtual object.
// These objects don't physically exist but are generated on-demand.
func isSOSAPIObject(object string) bool {
switch object {
case sosAPISystemXML, sosAPICapacityXML:
return true
default:
return false
}
}
// generateSystemXML creates the system.xml response containing storage system
// capabilities and recommendations.
func generateSystemXML() ([]byte, error) {
si := SystemInfo{
ProtocolVersion: sosAPIProtocolVersion,
ModelName: "\"SeaweedFS " + version.VERSION_NUMBER + "\"",
}
// Enable capacity reporting capability
si.ProtocolCapabilities.CapacityInfo = true
si.ProtocolCapabilities.UploadSessions = false
si.ProtocolCapabilities.IAMSTS = false
// Set recommended block size for optimal performance
si.SystemRecommendations = &SystemRecommendations{
KBBlockSize: sosAPIDefaultBlockSizeKB,
}
return xml.Marshal(&si)
}
// generateCapacityXML creates the capacity.xml response containing real-time
// storage capacity information.
func (s3a *S3ApiServer) generateCapacityXML(ctx context.Context, bucket string) ([]byte, error) {
total, available, used, err := s3a.getCapacityInfo(ctx, bucket)
if err != nil {
glog.Warningf("SOSAPI: failed to get capacity info for bucket %s: %v, using defaults", bucket, err)
// Return zero capacity on error
total, available, used = 0, 0, 0
}
ci := CapacityInfo{
Capacity: total,
Available: available,
Used: used,
}
return xml.Marshal(&ci)
}
// getCapacityInfo retrieves capacity information for the specific bucket.
// It checks bucket quota first, then falls back to cluster topology information.
// Returns capacity, available, and used bytes.
func (s3a *S3ApiServer) getCapacityInfo(ctx context.Context, bucket string) (capacity, available, used int64, err error) {
// 1. Check if bucket has a quota
// We use s3a.getEntry which is a helper in s3api_bucket_handlers.go
var quota int64
// getEntry communicates with filer, so errors here might mean filer connectivity issues or bucket not found
// If bucket not found, we probably shouldn't be here (checked in handler), but safe to ignore
if entry, getErr := s3a.getBucketEntry(bucket); getErr == nil && entry != nil {
quota = entry.Quota
}
// 2. Get cluster topology from master
if len(s3a.option.Masters) == 0 {
return 0, 0, 0, fmt.Errorf("no master servers configured")
}
masterMap := make(map[string]pb.ServerAddress)
for _, master := range s3a.option.Masters {
masterMap[string(master)] = master
}
// Cluster capacity below is read from disk counters, which a filtered
// listing still reports in full.
collectionName := s3a.getCollectionName(bucket)
// Connect to any available master and get volume list (topology)
err = pb.WithOneOfGrpcMasterClients(false, masterMap, s3a.option.GrpcDialOption, func(client master_pb.SeaweedClient) error {
resp, vErr := client.VolumeList(ctx, &master_pb.VolumeListRequest{Collection: collectionName})
if vErr != nil {
return vErr
}
if resp.TopologyInfo == nil {
return nil
}
// Calculate used size for the bucket by summing up volumes in the collection
used = collectBucketUsageFromTopology(resp.TopologyInfo, collectionName)
// Calculate cluster capacity if no quota
if quota > 0 {
capacity = quota
available = quota - used
if available < 0 {
available = 0
}
} else {
// No quota - use cluster capacity
clusterTotal, clusterAvailable := calculateClusterCapacity(resp.TopologyInfo, resp.VolumeSizeLimitMb)
capacity = clusterTotal
available = clusterAvailable
}
return nil
})
return capacity, available, used, err
}
// collectBucketUsageFromTopology sums up the size of all volumes belonging to the specified collection.
func collectBucketUsageFromTopology(t *master_pb.TopologyInfo, collectionName string) (used int64) {
seenVolumes := make(map[uint32]bool)
for _, dc := range t.DataCenterInfos {
for _, r := range dc.RackInfos {
for _, dn := range r.DataNodeInfos {
for _, disk := range dn.DiskInfos {
for _, vi := range disk.VolumeInfos {
if vi.Collection == collectionName {
if !seenVolumes[vi.Id] {
used += int64(vi.Size)
seenVolumes[vi.Id] = true
}
}
}
}
}
}
}
return
}
// calculateClusterCapacity sums up the total and available capacity of the entire cluster.
func calculateClusterCapacity(t *master_pb.TopologyInfo, volumeSizeLimitMb uint64) (total, available int64) {
volumeSize := int64(volumeSizeLimitMb) * 1024 * 1024
for _, dc := range t.DataCenterInfos {
for _, r := range dc.RackInfos {
for _, dn := range r.DataNodeInfos {
for _, disk := range dn.DiskInfos {
total += int64(disk.MaxVolumeCount) * volumeSize
available += int64(disk.FreeVolumeCount) * volumeSize
}
}
}
}
return
}
// handleSOSAPIGetObject handles GET requests for SOSAPI virtual objects.
// Returns true if the request was handled, false if it should proceed normally.
func (s3a *S3ApiServer) handleSOSAPIGetObject(w http.ResponseWriter, r *http.Request, bucket, object string) bool {
if !isSOSAPIObject(object) {
return false
}
xmlData, err := s3a.generateSOSAPIContent(r.Context(), bucket, object)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
} else {
glog.Errorf("SOSAPI: failed to generate %s: %v", object, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
}
return true
}
// Calculate ETag from content
hash := md5.Sum(xmlData)
etag := hex.EncodeToString(hash[:])
// Set ETag header manually as ServeContent doesn't calculate it automatically
w.Header().Set("ETag", "\""+etag+"\"")
w.Header().Set("Content-Type", "application/xml")
// Use http.ServeContent to handle Content-Length, Range, and Last-Modified
http.ServeContent(w, r, object, time.Now().UTC(), bytes.NewReader(xmlData))
return true
}
// handleSOSAPIHeadObject handles HEAD requests for SOSAPI virtual objects.
// Returns true if the request was handled, false if it should proceed normally.
func (s3a *S3ApiServer) handleSOSAPIHeadObject(w http.ResponseWriter, r *http.Request, bucket, object string) bool {
if !isSOSAPIObject(object) {
return false
}
xmlData, err := s3a.generateSOSAPIContent(r.Context(), bucket, object)
if err != nil {
if errors.Is(err, filer_pb.ErrNotFound) {
s3err.WriteErrorResponse(w, r, s3err.ErrNoSuchBucket)
} else {
glog.Errorf("SOSAPI: failed to generate %s for HEAD: %v", object, err)
s3err.WriteErrorResponse(w, r, s3err.ErrInternalError)
}
return true
}
// Calculate ETag from content
hash := md5.Sum(xmlData)
etag := hex.EncodeToString(hash[:])
// Set response headers (no body for HEAD)
w.Header().Set("Content-Type", "application/xml")
w.Header().Set("ETag", "\""+etag+"\"")
w.Header().Set("Content-Length", strconv.Itoa(len(xmlData)))
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
w.WriteHeader(http.StatusOK)
return true
}
// generateSOSAPIContent generates the XML content for SOSAPI virtual objects.
// Returns the complete XML with declaration prepended.
func (s3a *S3ApiServer) generateSOSAPIContent(ctx context.Context, bucket, object string) ([]byte, error) {
// Verify bucket exists
if _, errCode := s3a.getBucketConfig(bucket); errCode != s3err.ErrNone {
if errCode == s3err.ErrNoSuchBucket {
return nil, filer_pb.ErrNotFound
}
return nil, fmt.Errorf("bucket config error: %v", errCode)
}
var xmlData []byte
var err error
switch object {
case sosAPISystemXML:
xmlData, err = generateSystemXML()
if err != nil {
return nil, err
}
glog.V(4).Infof("SOSAPI: generated system.xml for bucket %s", bucket)
case sosAPICapacityXML:
xmlData, err = s3a.generateCapacityXML(ctx, bucket)
if err != nil {
return nil, err
}
glog.V(4).Infof("SOSAPI: generated capacity.xml for bucket %s", bucket)
default:
return nil, fmt.Errorf("unknown SOSAPI object: %s", object)
}
// Prepend XML declaration
xmlData = append([]byte(xml.Header), xmlData...)
return xmlData, nil
}