mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-16 20:26:45 +00:00
* 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.
336 lines
11 KiB
Go
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
|
|
}
|