Files
seaweedfs/weed/s3api/s3tables/handler_maintenance.go
T
Chris LuandClaude Opus 5.5 66a6598fdd fix(s3tables,s3vectors): bind table and vector bucket writes to the bucket they were authorized against (#12329)
* s3tables: bind table bucket policy writes to the authorized bucket

PutTableBucketPolicy compared only the policy bytes, and
DeleteTableBucketPolicy removed the policy unconditionally. A bucket deleted
and created again under the same name between authorization and the write
took a policy change nobody authorized for it. Both writes now require the
bucket's CreatedAt and the policy they were authorized against, answer 409
otherwise and 404 for a bucket that is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: bind bucket tag writes to the authorized bucket

TagResource and UntagResource checked the creation time only for a table
ARN. On a bucket ARN the merged tags landed on a bucket deleted and created
again since authorization. extractResourceOwnerAndBucket now returns the
resource's CreatedAt for both kinds, and the write requires it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3tables: bind bucket maintenance writes to the authorized bucket

PutTableBucketMaintenanceConfiguration wrote onto whatever bucket held the
name at write time. authorizeMaintenanceBucket now returns the bucket's
CreatedAt like authorizeMaintenanceTable, and putMaintenanceConfiguration
takes the check and creation time from both callers instead of an optional
closure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3vectors: bind vector bucket policy writes to the authorized bucket

Put/DeleteVectorBucketPolicy authorized inside the compare-and-swap with
the owner from an earlier bucket read, so a bucket deleted and created
again by another account in between had its policy rewritten by the old
owner. The mutation now requires the entry's createdAt to match the
record it authorized against.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3vectors: bind DeleteVectorBucket to the authorized bucket

DeleteVectorBucket authorized one bucket record, then ran the trusted
catalog DeleteTableBucket on whatever bucket held the name by then, so an
empty bucket deleted and created again by another account in between was
deleted by the old owner. The catalog delete now accepts the authorized
bucket's createdAt through an in-process context value and treats any
other bucket as gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* s3vectors: bind CreateIndex and DeleteIndex to the authorized bucket

Both authorized the bucket, then ran trusted CreateNamespace, CreateTable
or DeleteTable on whatever bucket held the name by then, so an index could
be created in, or deleted from, a bucket another account created again in
between. Those catalog operations now honor the bucket binding
DeleteTableBucket already takes, and the vector handlers pass the
authorized bucket's createdAt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 03:00:30 -08:00

560 lines
21 KiB
Go

package s3tables
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
)
// Maintenance types are scoped: AWS configures unreferenced file removal on the
// table bucket and compaction and snapshot management on the table.
var (
bucketMaintenanceTypes = map[string]bool{
MaintenanceTypeIcebergUnreferencedFileRemoval: true,
}
tableMaintenanceTypes = map[string]bool{
MaintenanceTypeIcebergCompaction: true,
MaintenanceTypeIcebergSnapshotManagement: true,
}
)
// handlePutTableBucketMaintenanceConfiguration sets a maintenance configuration on a table bucket
func (h *S3TablesHandler) handlePutTableBucketMaintenanceConfiguration(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req PutTableBucketMaintenanceConfigurationRequest
if err := h.readRequestBody(r, &req); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
if req.TableBucketARN == "" {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "tableBucketARN is required")
return fmt.Errorf("tableBucketARN is required")
}
bucketName, err := parseBucketNameFromARN(req.TableBucketARN)
if err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
if err := validateMaintenanceValue(req.Type, bucketMaintenanceTypes, req.Value); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
_, createdAt, err := h.authorizeMaintenanceBucket(r, filerClient, "PutTableBucketMaintenanceConfiguration", bucketName)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
if err := h.putMaintenanceConfiguration(r, filerClient, GetTableBucketPath(bucketName), req.Type, req.Value, requireTableBucketCreatedAt, createdAt); err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
h.writeJSON(w, http.StatusOK, nil)
return nil
}
// handleGetTableBucketMaintenanceConfiguration gets the maintenance configuration of a table bucket
func (h *S3TablesHandler) handleGetTableBucketMaintenanceConfiguration(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req GetTableBucketMaintenanceConfigurationRequest
if err := h.readRequestBody(r, &req); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
if req.TableBucketARN == "" {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, "tableBucketARN is required")
return fmt.Errorf("tableBucketARN is required")
}
bucketName, err := parseBucketNameFromARN(req.TableBucketARN)
if err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
bucketARN, _, err := h.authorizeMaintenanceBucket(r, filerClient, "GetTableBucketMaintenanceConfiguration", bucketName)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
config, err := h.readMaintenanceConfiguration(r, filerClient, GetTableBucketPath(bucketName))
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
h.writeJSON(w, http.StatusOK, &GetTableBucketMaintenanceConfigurationResponse{
TableBucketARN: bucketARN,
Configuration: config,
})
return nil
}
// handlePutTableMaintenanceConfiguration sets a maintenance configuration on a table
func (h *S3TablesHandler) handlePutTableMaintenanceConfiguration(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req PutTableMaintenanceConfigurationRequest
if err := h.readRequestBody(r, &req); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
bucketName, namespaceName, tableName, err := h.parseTableTarget(req.TableBucketARN, req.Namespace, req.Name)
if err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
if err := validateMaintenanceValue(req.Type, tableMaintenanceTypes, req.Value); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
_, createdAt, err := h.authorizeMaintenanceTable(r, filerClient, "PutTableMaintenanceConfiguration", bucketName, namespaceName, tableName)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
tablePath := GetTablePath(bucketName, namespaceName, tableName)
if err := h.putMaintenanceConfiguration(r, filerClient, tablePath, req.Type, req.Value, requireTableCreatedAt, createdAt); err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
h.writeJSON(w, http.StatusOK, nil)
return nil
}
// handleGetTableMaintenanceConfiguration gets the maintenance configuration of a table
func (h *S3TablesHandler) handleGetTableMaintenanceConfiguration(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req GetTableMaintenanceConfigurationRequest
if err := h.readRequestBody(r, &req); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
bucketName, namespaceName, tableName, err := h.parseTableTarget(req.TableBucketARN, req.Namespace, req.Name)
if err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
tableARN, _, err := h.authorizeMaintenanceTable(r, filerClient, "GetTableMaintenanceConfiguration", bucketName, namespaceName, tableName)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
tablePath := GetTablePath(bucketName, namespaceName, tableName)
config, err := h.readMaintenanceConfiguration(r, filerClient, tablePath)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
h.writeJSON(w, http.StatusOK, &GetTableMaintenanceConfigurationResponse{
TableARN: tableARN,
Namespace: []string{namespaceName},
Name: tableName,
Configuration: config,
})
return nil
}
// handleGetTableMaintenanceJobStatus reports the outcome of the last maintenance run on a table
func (h *S3TablesHandler) handleGetTableMaintenanceJobStatus(w http.ResponseWriter, r *http.Request, filerClient FilerClient) error {
var req GetTableMaintenanceJobStatusRequest
if err := h.readRequestBody(r, &req); err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
bucketName, namespaceName, tableName, err := h.parseTableTarget(req.TableBucketARN, req.Namespace, req.Name)
if err != nil {
h.writeError(w, http.StatusBadRequest, ErrCodeInvalidRequest, err.Error())
return err
}
tableARN, _, err := h.authorizeMaintenanceTable(r, filerClient, "GetTableMaintenanceJobStatus", bucketName, namespaceName, tableName)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
tablePath := GetTablePath(bucketName, namespaceName, tableName)
// Unreferenced file removal is configured on the bucket, so a bucket-level
// disable has to show up here as Disabled rather than a stale table status.
bucketConfig, err := h.readMaintenanceConfiguration(r, filerClient, GetTableBucketPath(bucketName))
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchBucket, fmt.Sprintf("table bucket %s not found", bucketName))
return err
}
tableConfig, err := h.readMaintenanceConfiguration(r, filerClient, tablePath)
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
config := MergeMaintenanceConfiguration(bucketConfig, tableConfig)
recorded := MaintenanceJobStatus{}
err = filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyMaintenanceStatus)
if err != nil {
if errors.Is(err, ErrAttributeNotFound) {
return nil
}
return err
}
return json.Unmarshal(data, &recorded)
})
if err != nil {
h.writeMaintenanceError(w, err, ErrCodeNoSuchTable, fmt.Sprintf("table %s not found", tableName))
return err
}
h.writeJSON(w, http.StatusOK, &GetTableMaintenanceJobStatusResponse{
TableARN: tableARN,
Status: buildJobStatusResponse(recorded, config),
})
return nil
}
// buildJobStatusResponse reports every job type: what the worker recorded, or
// Disabled when the configuration switched the type off, or Not_Yet_Run.
func buildJobStatusResponse(recorded MaintenanceJobStatus, config MaintenanceConfiguration) MaintenanceJobStatus {
jobTypes := []string{
MaintenanceTypeIcebergCompaction,
MaintenanceTypeIcebergSnapshotManagement,
MaintenanceTypeIcebergUnreferencedFileRemoval,
}
status := make(MaintenanceJobStatus, len(jobTypes))
for _, jobType := range jobTypes {
if value, ok := config[jobType]; ok && value != nil && value.Status == MaintenanceStatusDisabled {
status[jobType] = &MaintenanceJobStatusValue{Status: MaintenanceJobStatusDisabled}
continue
}
if value, ok := recorded[jobType]; ok && value != nil {
status[jobType] = value
continue
}
status[jobType] = &MaintenanceJobStatusValue{Status: MaintenanceJobStatusNotYetRun}
}
return status
}
// putMaintenanceConfiguration merges one maintenance type into the stored
// configuration, leaving the other types alone. The write checks the catalog
// identity in the same conditional mutation, so it cannot land on a name a
// concurrent rename or delete has already soft-deleted. sameRecord refuses a
// record other than the one created at createdAt, which the caller was
// authorized against.
func (h *S3TablesHandler) putMaintenanceConfiguration(
r *http.Request,
filerClient FilerClient,
resourcePath, maintenanceType string,
value *MaintenanceConfigurationValue,
sameRecord func(extended map[string][]byte, path string, createdAt time.Time) error,
createdAt time.Time,
) error {
return filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
return h.mutateCatalogRecord(r.Context(), client, resourcePath, func(extended map[string][]byte) error {
if len(extended[ExtendedKeyMetadata]) == 0 {
return fmt.Errorf("%w: %s", filer_pb.ErrNotFound, resourcePath)
}
if err := sameRecord(extended, resourcePath, createdAt); err != nil {
return err
}
config := MaintenanceConfiguration{}
if current := extended[ExtendedKeyMaintenance]; len(current) > 0 {
if err := json.Unmarshal(current, &config); err != nil {
return fmt.Errorf("failed to unmarshal maintenance configuration: %w", err)
}
}
config[maintenanceType] = value
data, err := json.Marshal(config)
if err != nil {
return err
}
extended[ExtendedKeyMaintenance] = data
return nil
})
})
}
// readMaintenanceConfiguration returns the stored configuration, or an empty
// one when the resource has never been configured.
func (h *S3TablesHandler) readMaintenanceConfiguration(r *http.Request, filerClient FilerClient, resourcePath string) (MaintenanceConfiguration, error) {
config := MaintenanceConfiguration{}
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, resourcePath, ExtendedKeyMaintenance)
if err != nil {
if errors.Is(err, ErrAttributeNotFound) {
return nil
}
return err
}
return json.Unmarshal(data, &config)
})
if err != nil {
return nil, err
}
return config, nil
}
// parseTableTarget validates the bucket/namespace/table triple every
// table-scoped maintenance request carries.
func (h *S3TablesHandler) parseTableTarget(tableBucketARN string, namespace []string, name string) (bucketName, namespaceName, tableName string, err error) {
if tableBucketARN == "" || len(namespace) == 0 || name == "" {
return "", "", "", fmt.Errorf("tableBucketARN, namespace, and name are required")
}
if namespaceName, err = validateNamespace(namespace); err != nil {
return "", "", "", err
}
if bucketName, err = parseBucketNameFromARN(tableBucketARN); err != nil {
return "", "", "", err
}
if tableName, err = validateTableName(name); err != nil {
return "", "", "", err
}
return bucketName, namespaceName, tableName, nil
}
// authorizeMaintenanceBucket checks the caller may perform operation on the
// table bucket, returning the bucket ARN and the creation time of the bucket
// record the check read.
func (h *S3TablesHandler) authorizeMaintenanceBucket(r *http.Request, filerClient FilerClient, operation, bucketName string) (string, time.Time, error) {
bucketPath := GetTableBucketPath(bucketName)
var bucketMetadata tableBucketMetadata
var bucketPolicy string
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyMetadata)
if err != nil {
return err
}
if err := json.Unmarshal(data, &bucketMetadata); err != nil {
return fmt.Errorf("failed to unmarshal bucket metadata: %w", err)
}
bucketPolicy, err = h.readPolicy(r, client, bucketPath)
return err
})
if err != nil {
return "", time.Time{}, err
}
bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName)
principal := h.getAccountID(r)
if !CheckPermissionWithContext(operation, principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
IsLocalAccountID: h.localAccountIDs,
TableBucketName: bucketName,
IdentityActions: getIdentityActions(r),
DefaultAllow: h.defaultAllowFor(r),
}) {
return "", time.Time{}, NewAuthError(operation, principal, "not authorized to "+operation)
}
return bucketARN, bucketMetadata.CreatedAt, nil
}
// authorizeMaintenanceTable checks the caller may perform operation on the
// table, returning the table ARN and the creation time of the table record the
// check read.
func (h *S3TablesHandler) authorizeMaintenanceTable(r *http.Request, filerClient FilerClient, operation, bucketName, namespaceName, tableName string) (string, time.Time, error) {
tablePath := GetTablePath(bucketName, namespaceName, tableName)
bucketPath := GetTableBucketPath(bucketName)
var metadata tableMetadataInternal
var bucketPolicy, tablePolicy string
var bucketTags, tableTags map[string]string
err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error {
data, err := h.readTableMetadataAttr(r.Context(), client, tablePath)
if err != nil {
return err
}
if err := json.Unmarshal(data, &metadata); err != nil {
return fmt.Errorf("failed to unmarshal table metadata: %w", err)
}
if bucketPolicy, err = h.readPolicy(r, client, bucketPath); err != nil {
return err
}
if bucketTags, err = h.readTags(r.Context(), client, bucketPath); err != nil {
return err
}
if tablePolicy, err = h.readPolicy(r, client, tablePath); err != nil {
return err
}
tableTags, err = h.readTags(r.Context(), client, tablePath)
return err
})
if err != nil {
return "", time.Time{}, err
}
tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName)
principal := h.getAccountID(r)
ctx := &PolicyContext{
TrustedIssuers: h.trustedIssuers(r),
IsLocalAccountID: h.localAccountIDs,
TableBucketName: bucketName,
Namespace: namespaceName,
TableName: tableName,
TableBucketTags: bucketTags,
ResourceTags: tableTags,
IdentityActions: getIdentityActions(r),
DefaultAllow: h.defaultAllowFor(r),
}
if deniedByPolicy(operation, principal, tablePolicy, tableARN, ctx) ||
!CheckPermissionWithContext(operation, principal, metadata.OwnerAccountID, bucketPolicy, tableARN, ctx) {
return "", time.Time{}, NewAuthError(operation, principal, "not authorized to "+operation)
}
return tableARN, metadata.CreatedAt, nil
}
// readPolicy returns the policy stored on path, or an empty string when the
// resource has none.
func (h *S3TablesHandler) readPolicy(r *http.Request, client filer_pb.SeaweedFilerClient, path string) (string, error) {
data, err := h.getExtendedAttribute(r.Context(), client, path, ExtendedKeyPolicy)
if err != nil {
if errors.Is(err, ErrAttributeNotFound) {
return "", nil
}
return "", fmt.Errorf("failed to read policy: %w", err)
}
return string(data), nil
}
// writeMaintenanceError maps the shared failure modes of the maintenance
// handlers onto responses.
func (h *S3TablesHandler) writeMaintenanceError(w http.ResponseWriter, err error, notFoundCode, notFoundMessage string) {
switch {
case errors.Is(err, filer_pb.ErrNotFound):
h.writeError(w, http.StatusNotFound, notFoundCode, notFoundMessage)
case isAuthError(err):
h.writeError(w, http.StatusForbidden, ErrCodeAccessDenied, err.Error())
case errors.Is(err, ErrConcurrentUpdate):
h.writeError(w, http.StatusConflict, ErrCodeConflict, "maintenance configuration changed concurrently, retry the request")
default:
h.writeError(w, http.StatusInternalServerError, ErrCodeInternalError, err.Error())
}
}
// validateMaintenanceValue rejects a type that does not belong to the scope and
// settings that do not name that same type.
func validateMaintenanceValue(maintenanceType string, allowed map[string]bool, value *MaintenanceConfigurationValue) error {
if maintenanceType == "" {
return fmt.Errorf("type is required")
}
if !allowed[maintenanceType] {
return fmt.Errorf("unsupported maintenance type %q", maintenanceType)
}
if value == nil {
return fmt.Errorf("value is required")
}
switch value.Status {
case MaintenanceStatusEnabled, MaintenanceStatusDisabled:
case "":
return fmt.Errorf("value.status is required")
default:
return fmt.Errorf("invalid value.status %q, expected %q or %q", value.Status, MaintenanceStatusEnabled, MaintenanceStatusDisabled)
}
if value.Settings != nil && !settingsMatchType(maintenanceType, value.Settings) {
return fmt.Errorf("value.settings must only contain %s", maintenanceType)
}
return validateMaintenanceSettings(value.Settings)
}
// AWS bounds every maintenance setting to a positive 32-bit value. Accepting
// anything else would store a number the worker then ignores or saturates, so
// the configuration read back would not be the one that runs.
const (
maintenanceSettingMin int64 = 1
maintenanceSettingMax int64 = 2147483647
)
func validateMaintenanceSettings(settings *MaintenanceSettings) error {
if settings == nil {
return nil
}
if c := settings.IcebergCompaction; c != nil {
if err := validateCompactionStrategy(c.Strategy); err != nil {
return err
}
if err := validateMaintenanceSetting("targetFileSizeMB", c.TargetFileSizeMB); err != nil {
return err
}
}
if m := settings.IcebergSnapshotManagement; m != nil {
if err := validateMaintenanceSetting("minSnapshotsToKeep", m.MinSnapshotsToKeep); err != nil {
return err
}
if err := validateMaintenanceSetting("maxSnapshotAgeHours", m.MaxSnapshotAgeHours); err != nil {
return err
}
}
if u := settings.IcebergUnreferencedFileRemoval; u != nil {
if err := validateMaintenanceSetting("unreferencedDays", u.UnreferencedDays); err != nil {
return err
}
if err := validateMaintenanceSetting("nonCurrentDays", u.NonCurrentDays); err != nil {
return err
}
}
return nil
}
func validateMaintenanceSetting(name string, value *int64) error {
if value == nil {
return nil
}
if *value < maintenanceSettingMin || *value > maintenanceSettingMax {
return fmt.Errorf("%s must be between %d and %d, got %d", name, maintenanceSettingMin, maintenanceSettingMax, *value)
}
return nil
}
// validateCompactionStrategy rejects a strategy the maintenance worker cannot
// carry out, rather than accepting it and quietly compacting some other way.
func validateCompactionStrategy(strategy string) error {
switch strategy {
case "", CompactionStrategyAuto, CompactionStrategyBinpack, CompactionStrategySort:
return nil
case CompactionStrategyZOrder:
return fmt.Errorf("compaction strategy %q is not supported", strategy)
default:
return fmt.Errorf("invalid compaction strategy %q, expected one of %q, %q, %q",
strategy, CompactionStrategyAuto, CompactionStrategyBinpack, CompactionStrategySort)
}
}
func settingsMatchType(maintenanceType string, settings *MaintenanceSettings) bool {
switch maintenanceType {
case MaintenanceTypeIcebergCompaction:
return settings.IcebergSnapshotManagement == nil && settings.IcebergUnreferencedFileRemoval == nil
case MaintenanceTypeIcebergSnapshotManagement:
return settings.IcebergCompaction == nil && settings.IcebergUnreferencedFileRemoval == nil
case MaintenanceTypeIcebergUnreferencedFileRemoval:
return settings.IcebergCompaction == nil && settings.IcebergSnapshotManagement == nil
}
return false
}