mirror of
https://github.com/pgsty/minio.git
synced 2026-09-12 21:44:04 +03:00
62cf066ff5
An independent adversarial review of the bucket metadata convergence work found three defects it had introduced. GetBucketInfo overwrote the physical creation probe with cached metadata, which a bucket that never held a configuration legitimately lacks. The new creation-time requirement then failed every policy, tag, SSE, quota, versioning and Object Lock write on such a bucket, with no operator recovery path, and initial synchronization skipped it silently. Return the physical result unchanged when metadata is not requested, as ListBuckets already does, recover the time during initial synchronization, and pass it to MakeBucketHook so peers adopt the same bucket generation. Replication status compared parsed policies statement by statement while heal compares the canonical key. An upgraded peer that stored an equivalent statement order was therefore reported as mismatched forever, and heal never had anything to write. Compare the key heal compares; per-site presence counting is unchanged. Heal diagnostics shared one log key across four conditions, so a real peer RPC failure could be deduplicated away by an earlier message, and they were logged at error level for the normal transient of a peer that does not have the bucket yet. Give each reason its own key at warning level, report only a field state that exists and still cannot be ordered, and diagnose nothing when no site holds a state to propagate. The recovery test now runs against the real ObjectLayer; the stub it replaced returned the expected time and hid the defect. The policy status test uses a statement order the canonical encoder reorders, and adoption coverage is extended past a real field time. Signed-off-by: Feng Ruohang <rh@vonng.com>
331 lines
9.8 KiB
Go
331 lines
9.8 KiB
Go
// Copyright (c) 2015-2026 MinIO, Inc.
|
|
//
|
|
// This file is part of MinIO Object Storage stack
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/minio/minio-go/v7/pkg/tags"
|
|
bucketsse "github.com/minio/minio/internal/bucket/encryption"
|
|
objectlock "github.com/minio/minio/internal/bucket/object/lock"
|
|
"github.com/minio/minio/internal/bucket/versioning"
|
|
"github.com/pgsty/silo-pkg/v3/policy"
|
|
)
|
|
|
|
// Only these fields share the site-replication source-time ordering contract.
|
|
// Bulk apply/import process Object Lock before Versioning, whose effective
|
|
// document depends on it. Periodic heal retains its existing type order.
|
|
var replicatedBucketConfigs = [...]string{
|
|
objectLockConfig, bucketVersioningConfig, bucketPolicyConfig,
|
|
bucketTaggingConfig, bucketSSEConfig, bucketQuotaConfigFile,
|
|
}
|
|
|
|
func replicatedBucketConfig(meta *BucketMetadata, file string) (*[]byte, *time.Time) {
|
|
switch file {
|
|
case bucketPolicyConfig:
|
|
return &meta.PolicyConfigJSON, &meta.PolicyConfigUpdatedAt
|
|
case bucketTaggingConfig:
|
|
return &meta.TaggingConfigXML, &meta.TaggingConfigUpdatedAt
|
|
case bucketSSEConfig:
|
|
return &meta.EncryptionConfigXML, &meta.EncryptionConfigUpdatedAt
|
|
case bucketQuotaConfigFile:
|
|
return &meta.QuotaConfigJSON, &meta.QuotaConfigUpdatedAt
|
|
case bucketVersioningConfig:
|
|
return &meta.VersioningConfigXML, &meta.VersioningConfigUpdatedAt
|
|
case objectLockConfig:
|
|
return &meta.ObjectLockConfigXML, &meta.ObjectLockConfigUpdatedAt
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// Callers that only need to know whether a file is under the contract must not
|
|
// probe replicatedBucketConfig with a throwaway BucketMetadata.
|
|
func isReplicatedBucketConfig(file string) bool {
|
|
for _, replicated := range replicatedBucketConfigs {
|
|
if replicated == file {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func bucketConfigUpdateOnly(file string) bool {
|
|
return file == bucketVersioningConfig || file == objectLockConfig
|
|
}
|
|
|
|
// Reuse the persistence rule before comparison, so an accepted Versioning
|
|
// event and the document Save actually writes have the same comparison key.
|
|
func effectiveBucketVersioning(data []byte, lockEnabled bool) []byte {
|
|
if lockEnabled {
|
|
config, err := versioning.ParseConfig(bytes.NewReader(data))
|
|
if err != nil || !config.Enabled() || config.PrefixesExcluded() {
|
|
return enabledBucketVersioningConfig
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
|
|
// A parsed policy still contains map-backed sets with nondeterministic Marshal
|
|
// order. Sort every set array recursively, including statements and conditions.
|
|
// RawMessage keeps integer values intact; decoding through float64 would not.
|
|
func canonicalBucketPolicyJSON(data json.RawMessage) (json.RawMessage, error) {
|
|
data = bytes.TrimSpace(data)
|
|
if len(data) == 0 {
|
|
return nil, nil
|
|
}
|
|
switch data[0] {
|
|
case '{':
|
|
var obj map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &obj); err != nil {
|
|
return nil, err
|
|
}
|
|
for key, value := range obj {
|
|
var err error
|
|
obj[key], err = canonicalBucketPolicyJSON(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return json.Marshal(obj)
|
|
case '[':
|
|
var arr []json.RawMessage
|
|
if err := json.Unmarshal(data, &arr); err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range arr {
|
|
var err error
|
|
arr[i], err = canonicalBucketPolicyJSON(arr[i])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
sort.Slice(arr, func(i, j int) bool { return bytes.Compare(arr[i], arr[j]) < 0 })
|
|
return json.Marshal(arr)
|
|
default:
|
|
var compact bytes.Buffer
|
|
if err := json.Compact(&compact, data); err != nil {
|
|
return nil, err
|
|
}
|
|
return compact.Bytes(), nil
|
|
}
|
|
}
|
|
|
|
// Encode the validated policy fields explicitly: BPStatement's required
|
|
// Action/Resource tags otherwise try to marshal empty sets for the supported
|
|
// NotAction/NotResource alternatives. This stays within the existing schema.
|
|
func canonicalBucketPolicy(cfg *policy.BucketPolicy) ([]byte, error) {
|
|
if cfg.IsEmpty() {
|
|
return nil, nil
|
|
}
|
|
doc := map[string]any{"Version": cfg.Version}
|
|
if cfg.ID != "" {
|
|
doc["ID"] = cfg.ID
|
|
}
|
|
statements := make([]map[string]any, 0, len(cfg.Statements))
|
|
for _, st := range cfg.Statements {
|
|
statement := map[string]any{"Effect": st.Effect, "Principal": st.Principal}
|
|
if st.SID != "" {
|
|
statement["Sid"] = st.SID
|
|
}
|
|
if len(st.Actions) != 0 {
|
|
statement["Action"] = st.Actions
|
|
}
|
|
if len(st.NotActions) != 0 {
|
|
statement["NotAction"] = st.NotActions
|
|
}
|
|
if len(st.Resources) != 0 {
|
|
statement["Resource"] = st.Resources
|
|
}
|
|
if len(st.NotResources) != 0 {
|
|
statement["NotResource"] = st.NotResources
|
|
}
|
|
if len(st.Conditions) != 0 {
|
|
statement["Condition"] = st.Conditions
|
|
}
|
|
statements = append(statements, statement)
|
|
}
|
|
doc["Statement"] = statements
|
|
data, err := json.Marshal(doc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return canonicalBucketPolicyJSON(data)
|
|
}
|
|
|
|
// Validate with the same parsers as Save. Policy's established empty-policy
|
|
// semantics are deletion; a parsed zero quota is still a live document.
|
|
func bucketConfigPayload(bucket, file string, data []byte, lockEnabled bool) ([]byte, []byte, error) {
|
|
if len(data) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
var err error
|
|
key := data
|
|
switch file {
|
|
case bucketPolicyConfig:
|
|
var cfg *policy.BucketPolicy
|
|
cfg, err = policy.ParseBucketPolicyConfig(bytes.NewReader(data), bucket)
|
|
if err == nil {
|
|
if cfg.IsEmpty() {
|
|
return nil, nil, nil
|
|
}
|
|
key, err = canonicalBucketPolicy(cfg)
|
|
}
|
|
case bucketQuotaConfigFile:
|
|
cfg, parseErr := parseBucketQuota(bucket, data)
|
|
err = parseErr
|
|
if err == nil {
|
|
key, err = json.Marshal(cfg)
|
|
}
|
|
case bucketTaggingConfig:
|
|
_, err = tags.ParseBucketXML(bytes.NewReader(data))
|
|
case bucketSSEConfig:
|
|
_, err = bucketsse.ParseBucketSSEConfig(bytes.NewReader(data))
|
|
case objectLockConfig:
|
|
_, err = objectlock.ParseObjectLockConfig(bytes.NewReader(data))
|
|
case bucketVersioningConfig:
|
|
data = effectiveBucketVersioning(data, lockEnabled)
|
|
key = data
|
|
_, err = versioning.ParseConfig(bytes.NewReader(data))
|
|
}
|
|
return data, key, err
|
|
}
|
|
|
|
type bucketConfigState struct {
|
|
data, key []byte
|
|
at time.Time
|
|
real, valid bool
|
|
}
|
|
|
|
func newBucketConfigState(bucket, file string, data []byte, at, created time.Time, lockEnabled bool) (bucketConfigState, error) {
|
|
data, key, err := bucketConfigPayload(bucket, file, data, lockEnabled)
|
|
if err != nil {
|
|
return bucketConfigState{}, err
|
|
}
|
|
if at.IsZero() {
|
|
at = created
|
|
}
|
|
valid := !created.IsZero() && !at.Before(created)
|
|
modified := valid && at.After(created)
|
|
if bucketConfigUpdateOnly(file) && len(data) == 0 {
|
|
modified = false
|
|
}
|
|
return bucketConfigState{data: data, key: key, at: at, real: modified, valid: valid}, nil
|
|
}
|
|
|
|
func (s bucketConfigState) candidate() bool {
|
|
return s.valid && (s.real || len(s.data) != 0)
|
|
}
|
|
|
|
func compareBucketConfigStates(a, b bucketConfigState) int {
|
|
if a.valid != b.valid {
|
|
if a.valid {
|
|
return 1
|
|
}
|
|
return -1
|
|
}
|
|
if a.real != b.real {
|
|
if a.real {
|
|
return 1
|
|
}
|
|
return -1
|
|
}
|
|
if a.real {
|
|
if n := a.at.Compare(b.at); n != 0 {
|
|
return n
|
|
}
|
|
// At equal source time a real deletion wins, preventing resurrection.
|
|
if (len(a.data) == 0) != (len(b.data) == 0) {
|
|
if len(a.data) == 0 {
|
|
return 1
|
|
}
|
|
return -1
|
|
}
|
|
}
|
|
return bytes.Compare(a.key, b.key)
|
|
}
|
|
|
|
func localBucketConfigUpdatedAt(meta BucketMetadata, file string, now time.Time) time.Time {
|
|
_, at := replicatedBucketConfig(&meta, file)
|
|
for _, lower := range []time.Time{meta.Created, *at} {
|
|
if !now.After(lower) {
|
|
now = lower.Add(time.Nanosecond)
|
|
}
|
|
}
|
|
return now.UTC()
|
|
}
|
|
|
|
func ensureBucketMetadataCreated(ctx context.Context, obj ObjectLayer, meta *BucketMetadata) error {
|
|
if !meta.Created.IsZero() {
|
|
return nil
|
|
}
|
|
info, err := obj.GetBucketInfo(ctx, meta.Name, BucketOptions{NoMetadata: true})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Created.IsZero() {
|
|
return errors.New("bucket metadata creation time is unknown")
|
|
}
|
|
meta.Created = info.Created.UTC()
|
|
return nil
|
|
}
|
|
|
|
// applyBucketConfig runs under metadata.lock, on freshly loaded metadata. It
|
|
// changes only the selected field; the caller persists once after all checks.
|
|
func applyBucketConfig(meta *BucketMetadata, file string, data []byte, at time.Time) (bool, error) {
|
|
if bucketConfigUpdateOnly(file) && len(data) == 0 {
|
|
return false, nil
|
|
}
|
|
current, currentAt := replicatedBucketConfig(meta, file)
|
|
lockEnabled := len(meta.ObjectLockConfigXML) != 0
|
|
incoming, err := newBucketConfigState(meta.Name, file, data, at, meta.Created, lockEnabled)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !incoming.candidate() {
|
|
return false, nil
|
|
}
|
|
local, err := newBucketConfigState(meta.Name, file, *current, *currentAt, meta.Created, lockEnabled)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if compareBucketConfigStates(incoming, local) <= 0 {
|
|
return false, nil
|
|
}
|
|
*current, *currentAt = bytes.Clone(incoming.data), incoming.at.UTC()
|
|
return true, nil
|
|
}
|
|
|
|
func rebaseBucketConfigDefaults(meta *BucketMetadata, oldCreated time.Time) {
|
|
if meta.Created.Equal(oldCreated) {
|
|
return
|
|
}
|
|
// These six fields alone use Created to distinguish a baseline from a
|
|
// tombstone. Preserve actual source times when adopting an existing bucket.
|
|
for _, file := range replicatedBucketConfigs {
|
|
_, at := replicatedBucketConfig(meta, file)
|
|
if at.IsZero() || at.Equal(oldCreated) {
|
|
*at = meta.Created
|
|
}
|
|
}
|
|
}
|