fix: make bucket CORS replication converge

Define a deterministic CORS replication register with durable tombstones, strict source timestamps, equal-time conflict ordering, full-state status, and heal convergence.

Serialize local and peer CORS transitions with a distributed namespace lock, validate canonical transport payloads, preserve initial-sync deletes, and fail closed on metadata errors.

Add adversarial, concurrent, restart, status, heal, signed admin-dispatch, protocol, and middleware coverage together with the reviewed site-replication design record.

Refs #75

Signed-off-by: Feng Ruohang <rh@vonng.com>
This commit is contained in:
Feng Ruohang
2026-08-29 01:33:24 +08:00
parent e4e3007da6
commit 724f8703d8
9 changed files with 2206 additions and 124 deletions
+31 -15
View File
@@ -18,6 +18,7 @@
package cmd
import (
"errors"
"net"
"net/http"
"strconv"
@@ -657,6 +658,8 @@ func applyBucketCors(w http.ResponseWriter, r *http.Request, cfg *bktcors.Config
if origin == "" {
return false // not a CORS request
}
h := w.Header()
h.Add("Vary", "Origin")
isPreflight := r.Method == http.MethodOptions &&
r.Header.Get("Access-Control-Request-Method") != ""
@@ -664,45 +667,52 @@ func applyBucketCors(w http.ResponseWriter, r *http.Request, cfg *bktcors.Config
if isPreflight {
method := r.Header.Get("Access-Control-Request-Method")
reqHeaders := splitAndTrim(r.Header.Get("Access-Control-Request-Headers"))
rule, allowedHeaders, ok := cfg.MatchPreflight(origin, method, reqHeaders)
// A preflight response depends on all three request headers that
// determine the outcome, including when the request is rejected.
h.Add("Vary", "Access-Control-Request-Method")
h.Add("Vary", "Access-Control-Request-Headers")
rule, allowedOrigin, allowedHeaders, ok := cfg.MatchPreflight(origin, method, reqHeaders)
if !ok {
writeResponse(w, http.StatusForbidden, nil, mimeNone)
return true
}
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
setBucketCorsOriginHeaders(h, allowedOrigin, origin)
h.Set("Access-Control-Allow-Methods", method)
if len(allowedHeaders) > 0 {
h.Set("Access-Control-Allow-Headers", strings.Join(allowedHeaders, ", "))
}
if len(rule.ExposeHeaders) > 0 {
h.Set("Access-Control-Expose-Headers", strings.Join(rule.ExposeHeaders, ", "))
}
if rule.MaxAgeSeconds > 0 {
h.Set("Access-Control-Max-Age", strconv.Itoa(rule.MaxAgeSeconds))
}
h.Set("Access-Control-Allow-Credentials", "true")
// A preflight response depends on all three request headers that
// determine the outcome, so cache variation must key on each of them.
h.Add("Vary", "Origin")
h.Add("Vary", "Access-Control-Request-Method")
h.Add("Vary", "Access-Control-Request-Headers")
writeResponse(w, http.StatusOK, nil, mimeNone)
return true
}
// Actual request: attach headers if the origin+method match.
rule, ok := cfg.MatchRule(origin, r.Method)
rule, allowedOrigin, ok := cfg.MatchRule(origin, r.Method)
if !ok {
return false // no matching rule → no CORS headers, continue normally
}
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Set("Access-Control-Allow-Credentials", "true")
setBucketCorsOriginHeaders(h, allowedOrigin, origin)
if len(rule.ExposeHeaders) > 0 {
h.Set("Access-Control-Expose-Headers", strings.Join(rule.ExposeHeaders, ", "))
}
h.Add("Vary", "Origin")
return false
}
func setBucketCorsOriginHeaders(h http.Header, allowedOrigin, requestOrigin string) {
if allowedOrigin == "*" {
h.Set("Access-Control-Allow-Origin", "*")
h.Del("Access-Control-Allow-Credentials")
return
}
h.Set("Access-Control-Allow-Origin", requestOrigin)
h.Set("Access-Control-Allow-Credentials", "true")
}
// splitAndTrim splits a comma-separated header list into trimmed, non-empty values.
func splitAndTrim(s string) []string {
if s == "" {
@@ -766,13 +776,19 @@ func corsHandler(handler http.Handler) http.Handler {
globalCors := cors.New(opts).Handler(handler)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if bucket, _ := request2BucketObjectName(r); bucket != "" && globalBucketMetadataSys != nil {
if cfg, _, err := globalBucketMetadataSys.GetCorsConfig(bucket); err == nil && cfg != nil {
cfg, _, err := globalBucketMetadataSys.GetCorsConfig(bucket)
if err == nil && cfg != nil {
if applyBucketCors(w, r, cfg) {
return
}
handler.ServeHTTP(w, r)
return
}
if err != nil && !errors.Is(err, errConfigNotFound) && r.Header.Get("Origin") != "" {
internalLogOnceIf(r.Context(), err, "bucket-cors-metadata")
handler.ServeHTTP(w, r)
return
}
}
globalCors.ServeHTTP(w, r)
})
+3 -3
View File
@@ -77,7 +77,7 @@ func (api objectAPIHandlers) PutBucketCorsHandler(w http.ResponseWriter, r *http
return
}
corsBytes, err := io.ReadAll(io.LimitReader(r.Body, r.ContentLength))
corsBytes, err := io.ReadAll(r.Body)
if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
@@ -97,7 +97,7 @@ func (api objectAPIHandlers) PutBucketCorsHandler(w http.ResponseWriter, r *http
return
}
updatedAt, err := globalBucketMetadataSys.Update(ctx, bucket, bucketCorsConfig, corsBytes)
updatedAt, err := updateLocalBucketCORSMetadata(ctx, objAPI, bucket, corsBytes)
if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
@@ -181,7 +181,7 @@ func (api objectAPIHandlers) DeleteBucketCorsHandler(w http.ResponseWriter, r *h
return
}
updatedAt, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketCorsConfig)
updatedAt, err := updateLocalBucketCORSMetadata(ctx, objAPI, bucket, nil)
if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
+32
View File
@@ -98,6 +98,38 @@ func testBucketCorsHandlers(obj ObjectLayer, instanceType, bucketName string, ap
t.Fatalf("PUT malformed cors: expected 400, got %d", rec.Code)
}
// Missing Content-MD5 is rejected before the body is parsed.
req, err = newTestRequest(http.MethodPut, getBucketCorsURL("", bucketName),
int64(len(testCORSDoc)), bytes.NewReader([]byte(testCORSDoc)))
if err != nil {
t.Fatal(err)
}
req.Header.Del("Content-Md5")
if err = signRequestV4(req, creds.AccessKey, creds.SecretKey); err != nil {
t.Fatal(err)
}
rec = httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || !bytes.Contains(rec.Body.Bytes(), []byte("<Code>MissingContentMD5</Code>")) {
t.Fatalf("PUT cors without Content-MD5: expected MissingContentMD5, got %d: %s", rec.Code, rec.Body.String())
}
// A signed but incorrect Content-MD5 is rejected while reading the body.
req, err = newTestRequest(http.MethodPut, getBucketCorsURL("", bucketName),
int64(len(testCORSDoc)), bytes.NewReader([]byte(testCORSDoc)))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Md5", getMD5HashBase64([]byte("different body")))
if err = signRequestV4(req, creds.AccessKey, creds.SecretKey); err != nil {
t.Fatal(err)
}
rec = httptest.NewRecorder()
apiRouter.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || !bytes.Contains(rec.Body.Bytes(), []byte("<Code>BadDigest</Code>")) {
t.Fatalf("PUT cors with bad Content-MD5: expected BadDigest, got %d: %s", rec.Code, rec.Body.String())
}
// Re-PUT the config so the store→GetCorsConfig→enforce seam below has
// something to enforce (the earlier DELETE removed it).
req, err = newTestSignedRequestV4(http.MethodPut, getBucketCorsURL("", bucketName),
+163 -1
View File
@@ -20,8 +20,10 @@ package cmd
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/minio/minio/internal/auth"
"github.com/minio/minio/internal/bucket/cors"
)
@@ -49,6 +51,28 @@ func TestPerBucketCorsPreflight(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("preflight status = %d", rec.Code)
}
if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "ETag" {
t.Fatalf("expose-headers = %q", got)
}
requireCorsOriginVary(t, rec.Header())
}
func TestPerBucketCorsActualRequestNoMatchVariesByOrigin(t *testing.T) {
cfg := &cors.Config{CORSRules: []cors.Rule{{
AllowedOrigins: []string{"https://allowed.example.com"},
AllowedMethods: []string{"GET"},
}}}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/mybucket/obj", nil)
req.Header.Set("Origin", "https://denied.example.com")
if handled := applyBucketCors(rec, req, cfg); handled {
t.Fatal("actual request must continue when CORS does not match")
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("allow-origin = %q", got)
}
requireCorsOriginVary(t, rec.Header())
}
func TestPerBucketCorsPreflightNoMatch(t *testing.T) {
@@ -68,6 +92,34 @@ func TestPerBucketCorsPreflightNoMatch(t *testing.T) {
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 for disallowed origin, got %d", rec.Code)
}
requireCorsVary(t, rec.Header())
}
func TestPerBucketCorsPreflightWildcardOrigin(t *testing.T) {
cfg := &cors.Config{CORSRules: []cors.Rule{{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET"},
AllowedHeaders: []string{"*"},
ExposeHeaders: []string{"ETag"},
}}}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodOptions, "/mybucket/obj", nil)
req.Header.Set("Origin", "https://app.example.com")
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
if handled := applyBucketCors(rec, req, cfg); !handled {
t.Fatal("expected preflight to be handled")
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("allow-origin = %q", got)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" {
t.Fatalf("allow-credentials = %q", got)
}
if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "ETag" {
t.Fatalf("expose-headers = %q", got)
}
requireCorsVary(t, rec.Header())
}
func TestPerBucketCorsActualRequest(t *testing.T) {
@@ -84,10 +136,120 @@ func TestPerBucketCorsActualRequest(t *testing.T) {
if handled {
t.Fatal("actual (non-preflight) request must not be terminated by CORS")
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://any.com" {
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("allow-origin = %q", got)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" {
t.Fatalf("allow-credentials = %q", got)
}
if got := rec.Header().Get("Access-Control-Expose-Headers"); got != "ETag" {
t.Fatalf("expose-headers = %q", got)
}
}
func TestPerBucketCorsOriginPatternResponse(t *testing.T) {
cfg := &cors.Config{CORSRules: []cors.Rule{{
AllowedOrigins: []string{"https://app.example.com", "https://*", "*"},
AllowedMethods: []string{"GET"},
}}}
tests := []struct {
origin string
wantOrigin string
wantCredentials string
}{
{"https://app.example.com", "https://app.example.com", "true"},
{"https://other.example.com", "https://other.example.com", "true"},
{"http://other.example.com", "*", ""},
}
for _, tt := range tests {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/mybucket/obj", nil)
req.Header.Set("Origin", tt.origin)
if handled := applyBucketCors(rec, req, cfg); handled {
t.Fatal("actual request must not be terminated by CORS")
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != tt.wantOrigin {
t.Fatalf("origin %q: allow-origin = %q, want %q", tt.origin, got, tt.wantOrigin)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != tt.wantCredentials {
t.Fatalf("origin %q: allow-credentials = %q, want %q", tt.origin, got, tt.wantCredentials)
}
}
}
func TestBucketCorsMetadataErrorFailsClosed(t *testing.T) {
oldObjectAPI := newObjectLayerFn()
setObjectLayer(nil)
defer setObjectLayer(oldObjectAPI)
wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
for _, method := range []string{http.MethodGet, http.MethodOptions} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(method, getGetObjectURL("", "cors-metadata-error", "object"), nil)
req.Header.Set("Origin", "https://app.example.com")
if method == http.MethodOptions {
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
}
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("%s status = %d, want %d", method, rec.Code, http.StatusNoContent)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("%s metadata error fell back to global allow-origin %q", method, got)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" {
t.Fatalf("%s metadata error fell back to global credentials %q", method, got)
}
}
}
func TestBucketCorsNoConfigUsesGlobalFallback(t *testing.T) {
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
objAPITest: testBucketCorsNoConfigUsesGlobalFallback,
endpoints: []string{"GetBucketCors"},
})
}
func testBucketCorsNoConfigUsesGlobalFallback(_ ObjectLayer, _ string, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
wrapped := corsHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, getGetObjectURL("", bucket, "object"), nil)
req.Header.Set("Origin", "https://app.example.com")
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" {
t.Fatalf("allow-origin = %q", got)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("allow-credentials = %q", got)
}
}
func requireCorsVary(t *testing.T, header http.Header) {
t.Helper()
values := strings.Join(header.Values("Vary"), ",")
for _, want := range []string{"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"} {
if !strings.Contains(values, want) {
t.Fatalf("Vary = %q, missing %q", values, want)
}
}
}
func requireCorsOriginVary(t *testing.T, header http.Header) {
t.Helper()
if values := strings.Join(header.Values("Vary"), ","); !strings.Contains(values, "Origin") {
t.Fatalf("Vary = %q, missing Origin", values)
}
}
File diff suppressed because it is too large Load Diff
+272 -88
View File
@@ -43,6 +43,7 @@ import (
"github.com/minio/minio-go/v7/pkg/replication"
"github.com/minio/minio-go/v7/pkg/set"
"github.com/minio/minio/internal/auth"
"github.com/minio/minio/internal/bucket/cors"
"github.com/minio/minio/internal/bucket/lifecycle"
sreplication "github.com/minio/minio/internal/bucket/replication"
"github.com/minio/minio/internal/logger"
@@ -1577,12 +1578,33 @@ func (c *SiteReplicationSys) PeerBucketMetadataUpdateHandler(ctx context.Context
return wrapSRErr(errInvalidArgument)
}
var corsConfigData []byte
if item.Cors != nil {
var err error
corsConfigData, err = decodeCORSReplicationPayload(item.Cors)
if err != nil {
return wrapSRErr(err)
}
if err = validateCORSReplicationPayload(corsConfigData); err != nil {
return wrapSRErr(err)
}
var unlock func()
ctx, unlock, err = lockBucketCORSMetadata(ctx, objectAPI, item.Bucket)
if err != nil {
return wrapSRErr(err)
}
defer unlock()
}
meta, err := readBucketMetadata(ctx, objectAPI, item.Bucket)
if err != nil {
return wrapSRErr(err)
}
if meta.Created.After(item.UpdatedAt) {
if item.Cors != nil {
replLogOnceIf(ctx, fmt.Errorf("ignoring CORS event for bucket %s from %v before bucket creation at %v", item.Bucket, item.UpdatedAt, meta.Created), "cors-event-before-bucket-creation-"+item.Bucket)
}
return nil
}
@@ -1633,12 +1655,12 @@ func (c *SiteReplicationSys) PeerBucketMetadataUpdateHandler(ctx context.Context
}
if item.Cors != nil {
configData, err := base64.StdEncoding.DecodeString(*item.Cors)
if err != nil {
return wrapSRErr(err)
localState := newCORSReplicationState(meta.CorsConfigXML, meta.CorsConfigUpdatedAt)
incoming := newCORSReplicationState(corsConfigData, item.UpdatedAt)
if compareCORSReplicationStates(localState, incoming) < 0 {
meta.CorsConfigXML = bytes.Clone(corsConfigData)
meta.CorsConfigUpdatedAt = item.UpdatedAt
}
meta.CorsConfigXML = configData
meta.CorsConfigUpdatedAt = item.UpdatedAt
}
return globalBucketMetadataSys.save(ctx, meta)
@@ -1758,30 +1780,222 @@ func (c *SiteReplicationSys) PeerBucketSSEConfigHandler(ctx context.Context, buc
return nil
}
// PeerBucketCorsConfigHandler - copies/deletes CORS config to local cluster.
func (c *SiteReplicationSys) PeerBucketCorsConfigHandler(ctx context.Context, bucket string, corsConfig *string, updatedAt time.Time) error {
// skip overwrite if local update is newer than peer update.
if !updatedAt.IsZero() {
if _, updateTm, err := globalBucketMetadataSys.GetCorsConfig(bucket); err == nil && updateTm.After(updatedAt) {
return nil
}
}
type corsReplicationStateKind uint8
if corsConfig != nil {
configData, err := base64.StdEncoding.DecodeString(*corsConfig)
if err != nil {
return wrapSRErr(err)
}
_, err = globalBucketMetadataSys.Update(ctx, bucket, bucketCorsConfig, configData)
if err != nil {
return wrapSRErr(err)
}
const (
corsReplicationBaseline corsReplicationStateKind = iota
corsReplicationLive
corsReplicationTombstone
)
type corsReplicationState struct {
kind corsReplicationStateKind
payload []byte
updatedAt time.Time
}
func newCORSReplicationState(payload []byte, updatedAt time.Time) corsReplicationState {
state := corsReplicationState{updatedAt: updatedAt.UTC()}
switch {
case len(payload) > 0:
state.kind = corsReplicationLive
state.payload = bytes.Clone(payload)
case updatedAt.IsZero():
state.kind = corsReplicationBaseline
default:
state.kind = corsReplicationTombstone
}
return state
}
func compareCORSReplicationStates(a, b corsReplicationState) int {
switch {
case a.updatedAt.Before(b.updatedAt):
return -1
case a.updatedAt.After(b.updatedAt):
return 1
case a.kind < b.kind:
return -1
case a.kind > b.kind:
return 1
case a.kind == corsReplicationLive:
return bytes.Compare(a.payload, b.payload)
default:
return 0
}
}
func equalCORSReplicationStates(a, b corsReplicationState) bool {
return compareCORSReplicationStates(a, b) == 0
}
func decodeCORSReplicationPayload(encoded *string) ([]byte, error) {
if encoded == nil {
return nil, nil
}
payload, err := base64.StdEncoding.Strict().DecodeString(*encoded)
if err != nil {
return nil, fmt.Errorf("invalid CORS replication payload: %w", err)
}
if len(payload) == 0 || base64.StdEncoding.EncodeToString(payload) != *encoded {
return nil, fmt.Errorf("invalid CORS replication payload: %w", errInvalidArgument)
}
return payload, nil
}
func validateCORSReplicationPayload(payload []byte) error {
if payload == nil {
return nil
}
// Delete cors config
_, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketCorsConfig)
config, err := cors.ParseBucketCorsConfig(bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("invalid CORS replication payload: %w", errInvalidArgument)
}
if err = config.Validate(); err != nil {
return fmt.Errorf("invalid CORS replication payload: %w: %v", errInvalidArgument, err)
}
return nil
}
func corsReplicationStateFromInfo(info madmin.SRBucketInfo) (corsReplicationState, error) {
payload, err := decodeCORSReplicationPayload(info.CorsConfig)
if err != nil {
return corsReplicationState{}, err
}
if err = validateCORSReplicationPayload(payload); err != nil {
return corsReplicationState{}, err
}
if info.CorsConfig != nil && info.CorsConfigUpdatedAt.IsZero() {
return corsReplicationState{}, fmt.Errorf("live CORS replication payload has no source timestamp: %w", errInvalidArgument)
}
return newCORSReplicationState(payload, info.CorsConfigUpdatedAt), nil
}
func areCORSReplicationStatesEqual(sites []srBucketMetaInfo) bool {
if len(sites) == 0 {
return true
}
reference, err := corsReplicationStateFromInfo(sites[0].SRBucketInfo)
if err != nil {
return false
}
for _, site := range sites[1:] {
state, err := corsReplicationStateFromInfo(site.SRBucketInfo)
if err != nil || !equalCORSReplicationStates(reference, state) {
return false
}
}
return true
}
func (s corsReplicationState) encodedPayload() *string {
if s.kind != corsReplicationLive {
return nil
}
encoded := base64.StdEncoding.EncodeToString(s.payload)
return &encoded
}
func newBucketCORSReplicationEvent(bucket string, meta BucketMetadata) (madmin.SRBucketMeta, bool) {
if meta.CorsConfigUpdatedAt.IsZero() {
return madmin.SRBucketMeta{}, false
}
return madmin.SRBucketMeta{
Type: madmin.SRBucketMetaTypeCorsConfig,
Bucket: bucket,
Cors: newCORSReplicationState(meta.CorsConfigXML, meta.CorsConfigUpdatedAt).encodedPayload(),
UpdatedAt: meta.CorsConfigUpdatedAt,
}, true
}
func lockBucketCORSMetadata(ctx context.Context, objectAPI ObjectLayer, bucket string) (context.Context, func(), error) {
// The lock name is deliberately different from .metadata.bin. Saving the
// metadata locks that object internally, and namespace locks are not
// re-entrant.
lock := objectAPI.NewNSLock(minioMetaBucket, pathJoin(bucketMetaPrefix, bucket, "cors-config.lock"))
lkctx, err := lock.GetLock(ctx, globalOperationTimeout)
if err != nil {
return nil, nil, err
}
return lkctx.Context(), func() { lock.Unlock(lkctx) }, nil
}
func updateLocalBucketCORSMetadata(ctx context.Context, objectAPI ObjectLayer, bucket string, configData []byte) (time.Time, error) {
return applyBucketCORSMetadata(ctx, objectAPI, bucket, configData, time.Time{}, true)
}
func applyBucketCORSMetadata(ctx context.Context, objectAPI ObjectLayer, bucket string, configData []byte, sourceUpdatedAt time.Time, local bool) (time.Time, error) {
if bucket == "" || (configData != nil && len(configData) == 0) {
return time.Time{}, errInvalidArgument
}
if err := validateCORSReplicationPayload(configData); err != nil {
return time.Time{}, err
}
ctx, unlock, err := lockBucketCORSMetadata(ctx, objectAPI, bucket)
if err != nil {
return time.Time{}, err
}
defer unlock()
var meta BucketMetadata
if local {
meta, err = loadBucketMetadataParse(ctx, objectAPI, bucket, true)
} else {
meta, err = readBucketMetadata(ctx, objectAPI, bucket)
}
if err != nil {
return time.Time{}, err
}
localState := newCORSReplicationState(meta.CorsConfigXML, meta.CorsConfigUpdatedAt)
updatedAt := sourceUpdatedAt.UTC()
if local {
updatedAt = UTCNow()
floor := meta.Created
if localState.updatedAt.After(floor) {
floor = localState.updatedAt
}
if !updatedAt.After(floor) {
updatedAt = floor.Add(time.Nanosecond)
}
} else {
// CreatedAt is the bucket-lineage floor: an event from an older
// incarnation of the bucket must not change the current one.
if updatedAt.Before(meta.Created) {
replLogOnceIf(ctx, fmt.Errorf("ignoring CORS event for bucket %s from %v before bucket creation at %v", bucket, updatedAt, meta.Created), "cors-event-before-bucket-creation-"+bucket)
return localState.updatedAt, nil
}
incoming := newCORSReplicationState(configData, updatedAt)
if compareCORSReplicationStates(localState, incoming) >= 0 {
return localState.updatedAt, nil
}
}
meta.CorsConfigXML = bytes.Clone(configData)
meta.CorsConfigUpdatedAt = updatedAt
if err = globalBucketMetadataSys.save(ctx, meta); err != nil {
return time.Time{}, err
}
return updatedAt, nil
}
// PeerBucketCorsConfigHandler - copies/deletes CORS config to local cluster.
func (c *SiteReplicationSys) PeerBucketCorsConfigHandler(ctx context.Context, bucket string, corsConfig *string, updatedAt time.Time) error {
objectAPI := newObjectLayerFn()
if objectAPI == nil {
return errSRObjectLayerNotReady
}
if bucket == "" || updatedAt.IsZero() {
return wrapSRErr(errInvalidArgument)
}
configData, err := decodeCORSReplicationPayload(corsConfig)
if err != nil {
return wrapSRErr(err)
}
if _, err = applyBucketCORSMetadata(ctx, objectAPI, bucket, configData, updatedAt, false); err != nil {
return wrapSRErr(err)
}
return nil
@@ -1989,15 +2203,8 @@ func (c *SiteReplicationSys) syncToAllPeers(ctx context.Context, addOpts madmin.
}
// Replicate existing bucket CORS settings
corsConfigData, tm := meta.CorsConfigXML, meta.CorsConfigUpdatedAt
if len(corsConfigData) > 0 {
corsConfigStr := base64.StdEncoding.EncodeToString(corsConfigData)
err = c.BucketMetaHook(ctx, madmin.SRBucketMeta{
Type: madmin.SRBucketMetaTypeCorsConfig,
Bucket: bucket,
Cors: &corsConfigStr,
UpdatedAt: tm,
})
if corsEvent, ok := newBucketCORSReplicationEvent(bucket, meta); ok {
err = c.BucketMetaHook(ctx, corsEvent)
if err != nil {
return errSRBucketMetaError(err)
}
@@ -3198,7 +3405,6 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
replCfgs := make([]*sreplication.Config, numSites)
quotaCfgs := make([]*madmin.BucketQuota, numSites)
sseCfgSet := set.NewStringSet()
corsCfgSet := set.NewStringSet()
versionCfgSet := set.NewStringSet()
var tagCount, olockCfgCount, sseCfgCount, corsCfgCount, versionCfgCount int
for i, s := range slc {
@@ -3272,13 +3478,8 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
}
}
if s.CorsConfig != nil {
configData, err := base64.StdEncoding.DecodeString(*s.CorsConfig)
if err != nil {
continue
}
corsCfgCount++
if !corsCfgSet.Contains(string(configData)) {
corsCfgSet.Add(string(configData))
if _, err := decodeCORSReplicationPayload(s.CorsConfig); err == nil {
corsCfgCount++
}
}
ss, ok := info.StatsSummary[s.DeploymentID]
@@ -3299,7 +3500,7 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
if sseCfgCount > 0 {
ss.TotalSSEConfigCount++
}
if corsCfgCount > 0 {
if s.CorsConfig != nil {
ss.TotalCorsConfigCount++
}
if versionCfgCount > 0 {
@@ -3313,7 +3514,7 @@ func (c *SiteReplicationSys) siteReplicationStatus(ctx context.Context, objAPI O
tagMismatch := !isReplicated(tagCount, numSites, tagSet)
olockCfgMismatch := !isReplicated(olockCfgCount, numSites, olockConfigSet)
sseCfgMismatch := !isReplicated(sseCfgCount, numSites, sseCfgSet)
corsCfgMismatch := !isReplicated(corsCfgCount, numSites, corsCfgSet)
corsCfgMismatch := !areCORSReplicationStatesEqual(slc)
versionCfgMismatch := !isReplicated(versionCfgCount, numSites, versionCfgSet)
policyMismatch := !isBktPolicyReplicated(numSites, policies)
replCfgMismatch := !isBktReplCfgReplicated(numSites, replCfgs)
@@ -3783,10 +3984,10 @@ func (c *SiteReplicationSys) SiteReplicationMetaInfo(ctx context.Context, objAPI
bms.SSEConfigUpdatedAt = meta.EncryptionConfigUpdatedAt
}
bms.CorsConfigUpdatedAt = meta.CorsConfigUpdatedAt
if len(meta.CorsConfigXML) > 0 {
corsConfigStr := base64.StdEncoding.EncodeToString(meta.CorsConfigXML)
bms.CorsConfig = &corsConfigStr
bms.CorsConfigUpdatedAt = meta.CorsConfigUpdatedAt
}
if len(meta.ReplicationConfigXML) > 0 {
@@ -4997,62 +5198,45 @@ func (c *SiteReplicationSys) healSSEMetadata(ctx context.Context, objAPI ObjectL
return nil
}
func latestCORSConfig(bs map[string]srBucketStatsSummary) (latestID string, latest corsReplicationState, ok bool) {
for dID, status := range bs {
state, err := corsReplicationStateFromInfo(status.meta.SRBucketInfo)
if err != nil || state.kind == corsReplicationBaseline {
continue
}
cmp := compareCORSReplicationStates(latest, state)
if !ok || cmp < 0 || (cmp == 0 && dID > latestID) {
latestID = dID
latest = state
ok = true
}
}
return latestID, latest, ok
}
func (c *SiteReplicationSys) healCORSMetadata(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
c.RLock()
defer c.RUnlock()
if !c.enabled {
return nil
}
var (
latestID, latestPeerName string
lastUpdate time.Time
latestCorsConfig *string
)
bs := info.BucketStats[bucket]
for dID, ss := range bs {
if lastUpdate.IsZero() {
lastUpdate = ss.meta.CorsConfigUpdatedAt
latestID = dID
latestCorsConfig = ss.meta.CorsConfig
}
// avoid considering just created buckets as latest. Perhaps this site
// just joined cluster replication and yet to be sync'd
if ss.meta.CreatedAt.Equal(ss.meta.CorsConfigUpdatedAt) {
continue
}
if ss.meta.CorsConfigUpdatedAt.After(lastUpdate) {
lastUpdate = ss.meta.CorsConfigUpdatedAt
latestID = dID
latestCorsConfig = ss.meta.CorsConfig
}
latestID, latestState, ok := latestCORSConfig(bs)
if !ok {
return nil
}
latestPeerName = info.Sites[latestID].Name
var latestCorsConfigBytes []byte
var err error
if latestCorsConfig != nil {
latestCorsConfigBytes, err = base64.StdEncoding.DecodeString(*latestCorsConfig)
if err != nil {
return err
}
}
latestPeerName := info.Sites[latestID].Name
latestCorsConfig := latestState.encodedPayload()
for dID, bStatus := range bs {
if !bStatus.CorsCfgMismatch {
continue
}
if isBucketMetadataEqual(latestCorsConfig, bStatus.meta.CorsConfig) {
currentState, err := corsReplicationStateFromInfo(bStatus.meta.SRBucketInfo)
if err == nil && equalCORSReplicationStates(latestState, currentState) {
continue
}
if dID == globalDeploymentID() {
if latestCorsConfig == nil {
if _, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketCorsConfig); err != nil {
replLogIf(ctx, fmt.Errorf("Unable to heal CORS metadata from peer site %s : %w", latestPeerName, err))
}
continue
}
if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketCorsConfig, latestCorsConfigBytes); err != nil {
if err := c.PeerBucketCorsConfigHandler(ctx, bucket, latestCorsConfig, latestState.updatedAt); err != nil {
replLogIf(ctx, fmt.Errorf("Unable to heal CORS metadata from peer site %s : %w", latestPeerName, err))
}
continue
@@ -5067,7 +5251,7 @@ func (c *SiteReplicationSys) healCORSMetadata(ctx context.Context, objAPI Object
Type: madmin.SRBucketMetaTypeCorsConfig,
Bucket: bucket,
Cors: latestCorsConfig,
UpdatedAt: lastUpdate,
UpdatedAt: latestState.updatedAt,
})
if err != nil {
replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
@@ -5394,7 +5578,7 @@ func isBucketMetadataEqual(one, two *string) bool {
case one == nil || two == nil:
return false
default:
return strings.EqualFold(*one, *two)
return *one == *two
}
}