package s3tables import ( "encoding/json" "errors" "fmt" "net/http" "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 } if _, err := h.authorizeMaintenanceBucket(r, filerClient, "PutTableBucketMaintenanceConfiguration", bucketName); 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); 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 } if _, err := h.authorizeMaintenanceTable(r, filerClient, "PutTableMaintenanceConfiguration", bucketName, namespaceName, tableName); 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); 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. func (h *S3TablesHandler) putMaintenanceConfiguration( r *http.Request, filerClient FilerClient, resourcePath, maintenanceType string, value *MaintenanceConfigurationValue, ) error { return filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { return h.mutateEntryExtended(r.Context(), client, resourcePath, func(extended map[string][]byte) error { if len(extended[ExtendedKeyMetadata]) == 0 { return fmt.Errorf("%w: %s", filer_pb.ErrNotFound, resourcePath) } 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. func (h *S3TablesHandler) authorizeMaintenanceBucket(r *http.Request, filerClient FilerClient, operation, bucketName string) (string, 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.readBucketPolicy(r, client, bucketPath) return err }) if err != nil { return "", err } bucketARN := h.generateTableBucketARN(bucketMetadata.OwnerAccountID, bucketName) principal := h.getAccountID(r) if !CheckPermissionWithContext(operation, principal, bucketMetadata.OwnerAccountID, bucketPolicy, bucketARN, &PolicyContext{ TableBucketName: bucketName, IdentityActions: getIdentityActions(r), DefaultAllow: h.defaultAllowFor(r), }) { return "", NewAuthError(operation, principal, "not authorized to "+operation) } return bucketARN, nil } // authorizeMaintenanceTable checks the caller may perform operation on the // table, returning the table ARN. func (h *S3TablesHandler) authorizeMaintenanceTable(r *http.Request, filerClient FilerClient, operation, bucketName, namespaceName, tableName string) (string, error) { tablePath := GetTablePath(bucketName, namespaceName, tableName) bucketPath := GetTableBucketPath(bucketName) var metadata tableMetadataInternal var bucketPolicy string err := filerClient.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { data, err := h.getExtendedAttribute(r.Context(), client, tablePath, ExtendedKeyMetadata) if err != nil { return err } if err := json.Unmarshal(data, &metadata); err != nil { return fmt.Errorf("failed to unmarshal table metadata: %w", err) } bucketPolicy, err = h.readBucketPolicy(r, client, bucketPath) return err }) if err != nil { return "", err } tableARN := h.generateTableARN(metadata.OwnerAccountID, bucketName, namespaceName+"/"+tableName) principal := h.getAccountID(r) if !CheckPermissionWithContext(operation, principal, metadata.OwnerAccountID, bucketPolicy, tableARN, &PolicyContext{ TableBucketName: bucketName, Namespace: namespaceName, TableName: tableName, IdentityActions: getIdentityActions(r), DefaultAllow: h.defaultAllowFor(r), }) { return "", NewAuthError(operation, principal, "not authorized to "+operation) } return tableARN, nil } // readBucketPolicy returns the bucket policy, or an empty string when the // bucket has none. func (h *S3TablesHandler) readBucketPolicy(r *http.Request, client filer_pb.SeaweedFilerClient, bucketPath string) (string, error) { data, err := h.getExtendedAttribute(r.Context(), client, bucketPath, ExtendedKeyPolicy) if err != nil { if errors.Is(err, ErrAttributeNotFound) { return "", nil } return "", fmt.Errorf("failed to read bucket 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 }