Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
804 lines
26 KiB
Go
804 lines
26 KiB
Go
package dash
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3lifecycle"
|
|
)
|
|
|
|
// MaxBucketLifecycleRules mirrors AWS S3's limit of 1000 lifecycle rules per bucket.
|
|
const MaxBucketLifecycleRules = 1000
|
|
|
|
// MaxBucketLifecycleRuleIDLength mirrors the S3 API's limit on a lifecycle rule's <ID>.
|
|
const MaxBucketLifecycleRuleIDLength = 255
|
|
|
|
// MaxOwnerNameLength is the maximum allowed length for bucket owner identity names.
|
|
// This is a reasonable limit to prevent abuse; AWS IAM user names are limited to 64 chars,
|
|
// but we use 256 to allow for more complex identity formats (e.g., email addresses).
|
|
const MaxOwnerNameLength = 256
|
|
|
|
// S3 Bucket management data structures for templates
|
|
type S3BucketsData struct {
|
|
Username string `json:"username"`
|
|
Buckets []S3Bucket `json:"buckets"`
|
|
TotalBuckets int `json:"total_buckets"`
|
|
TotalSize int64 `json:"total_size"`
|
|
LastUpdated time.Time `json:"last_updated"`
|
|
|
|
// Pagination
|
|
CurrentPage int `json:"current_page"`
|
|
TotalPages int `json:"total_pages"`
|
|
PageSize int `json:"page_size"`
|
|
|
|
// Sorting
|
|
SortBy string `json:"sort_by"`
|
|
SortOrder string `json:"sort_order"`
|
|
}
|
|
|
|
type CreateBucketRequest struct {
|
|
Name string `json:"name"` // validated manually in CreateBucket
|
|
Region string `json:"region"`
|
|
QuotaSize int64 `json:"quota_size"` // Quota size in bytes
|
|
QuotaUnit string `json:"quota_unit"` // Unit: MB, GB, TB
|
|
QuotaEnabled bool `json:"quota_enabled"` // Whether quota is enabled
|
|
VersioningEnabled bool `json:"versioning_enabled"` // Whether versioning is enabled
|
|
ObjectLockEnabled bool `json:"object_lock_enabled"` // Whether object lock is enabled
|
|
ObjectLockMode string `json:"object_lock_mode"` // Object lock mode: "GOVERNANCE" or "COMPLIANCE"
|
|
SetDefaultRetention bool `json:"set_default_retention"` // Whether to set default retention
|
|
ObjectLockDuration int32 `json:"object_lock_duration"` // Default retention duration in days
|
|
Owner string `json:"owner"` // Bucket owner identity (for S3 IAM authentication)
|
|
}
|
|
|
|
// S3 Bucket Management Handlers
|
|
|
|
// ShowS3Buckets displays the Object Store buckets management page
|
|
func (s *AdminServer) ShowS3Buckets(w http.ResponseWriter, r *http.Request) {
|
|
username := UsernameFromContext(r.Context())
|
|
|
|
data, err := s.GetS3BucketsData(1, 100, "name", "asc")
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "Failed to get Object Store buckets: "+err.Error())
|
|
return
|
|
}
|
|
|
|
data.Username = username
|
|
writeJSON(w, http.StatusOK, data)
|
|
}
|
|
|
|
// ShowBucketDetails displays detailed information about a specific bucket
|
|
func (s *AdminServer) ShowBucketDetails(w http.ResponseWriter, r *http.Request) {
|
|
bucketName := mux.Vars(r)["bucket"]
|
|
if bucketName == "" {
|
|
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
|
|
return
|
|
}
|
|
|
|
details, err := s.GetBucketDetails(bucketName)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "Failed to get bucket details: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, details)
|
|
}
|
|
|
|
// ShowBucketLifecycle returns the lifecycle configuration for a specific bucket
|
|
func (s *AdminServer) ShowBucketLifecycle(w http.ResponseWriter, r *http.Request) {
|
|
bucketName := mux.Vars(r)["bucket"]
|
|
if bucketName == "" {
|
|
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
|
|
return
|
|
}
|
|
|
|
lifecycle, err := s.GetBucketLifecycle(bucketName)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "Failed to get bucket lifecycle: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, lifecycle)
|
|
}
|
|
|
|
// UpdateBucketLifecycle replaces the entire lifecycle configuration for a bucket.
|
|
func (s *AdminServer) UpdateBucketLifecycle(w http.ResponseWriter, r *http.Request) {
|
|
if !requireSessionCSRFToken(w, r) {
|
|
return
|
|
}
|
|
|
|
bucketName := mux.Vars(r)["bucket"]
|
|
if bucketName == "" {
|
|
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Rules []BucketLifecycleRule `json:"rules"`
|
|
}
|
|
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if err := validateBucketLifecycleRules(req.Rules); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
if err := s.SetBucketLifecycle(bucketName, req.Rules); err != nil {
|
|
writeJSONError(w, bucketLifecycleErrorStatus(err), "Failed to update bucket lifecycle: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"message": "Bucket lifecycle updated successfully",
|
|
"bucket": bucketName,
|
|
})
|
|
}
|
|
|
|
// DeleteBucketLifecycle clears the entire lifecycle configuration for a bucket.
|
|
func (s *AdminServer) DeleteBucketLifecycle(w http.ResponseWriter, r *http.Request) {
|
|
if !requireSessionCSRFToken(w, r) {
|
|
return
|
|
}
|
|
|
|
bucketName := mux.Vars(r)["bucket"]
|
|
if bucketName == "" {
|
|
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
|
|
return
|
|
}
|
|
|
|
if err := s.SetBucketLifecycle(bucketName, nil); err != nil {
|
|
writeJSONError(w, bucketLifecycleErrorStatus(err), "Failed to delete bucket lifecycle: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"message": "Bucket lifecycle deleted successfully",
|
|
"bucket": bucketName,
|
|
})
|
|
}
|
|
|
|
// bucketLifecycleErrorStatus keeps a request for a bucket that does not exist
|
|
// out of the 5xx bucket, where a client would retry it.
|
|
func bucketLifecycleErrorStatus(err error) int {
|
|
if errors.Is(err, ErrBucketNotFound) {
|
|
return http.StatusNotFound
|
|
}
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
// validateBucketLifecycleRules rejects a rule set before any write is
|
|
// attempted, so a malformed PUT can't half-apply. Mirrors the constraints
|
|
// AWS enforces on PutBucketLifecycleConfiguration plus the local rule cap.
|
|
func validateBucketLifecycleRules(rules []BucketLifecycleRule) error {
|
|
if len(rules) > MaxBucketLifecycleRules {
|
|
return fmt.Errorf("a bucket may have at most %d lifecycle rules, got %d", MaxBucketLifecycleRules, len(rules))
|
|
}
|
|
|
|
seenIDs := make(map[string]bool, len(rules))
|
|
for i, rule := range rules {
|
|
label := fmt.Sprintf("rule %d", i+1)
|
|
if rule.ID != "" {
|
|
label = fmt.Sprintf("rule %q", rule.ID)
|
|
if len(rule.ID) > MaxBucketLifecycleRuleIDLength {
|
|
return fmt.Errorf("%s: ID must be %d characters or less", label, MaxBucketLifecycleRuleIDLength)
|
|
}
|
|
if seenIDs[rule.ID] {
|
|
return fmt.Errorf("duplicate rule ID %q", rule.ID)
|
|
}
|
|
seenIDs[rule.ID] = true
|
|
}
|
|
|
|
switch rule.Status {
|
|
case s3lifecycle.StatusEnabled, s3lifecycle.StatusDisabled:
|
|
default:
|
|
return fmt.Errorf("%s: status must be %q or %q, got %q", label, s3lifecycle.StatusEnabled, s3lifecycle.StatusDisabled, rule.Status)
|
|
}
|
|
|
|
if rule.ExpirationDays > 0 && rule.ExpirationDate != "" {
|
|
return fmt.Errorf("%s: expiration_days and expiration_date are mutually exclusive", label)
|
|
}
|
|
if rule.ExpirationDays < 0 {
|
|
return fmt.Errorf("%s: expiration_days must be positive", label)
|
|
}
|
|
if rule.ExpirationDate != "" {
|
|
if _, err := time.Parse(time.DateOnly, rule.ExpirationDate); err != nil {
|
|
return fmt.Errorf("%s: invalid expiration_date %q, expected YYYY-MM-DD", label, rule.ExpirationDate)
|
|
}
|
|
}
|
|
if rule.ExpiredObjectDeleteMarker && (rule.ExpirationDays > 0 || rule.ExpirationDate != "") {
|
|
return fmt.Errorf("%s: expired_object_delete_marker cannot be combined with expiration_days or expiration_date", label)
|
|
}
|
|
|
|
if rule.NoncurrentVersionExpirationDays < 0 {
|
|
return fmt.Errorf("%s: noncurrent_version_expiration_days must be positive", label)
|
|
}
|
|
if rule.NewerNoncurrentVersions < 0 {
|
|
return fmt.Errorf("%s: newer_noncurrent_versions must be positive", label)
|
|
}
|
|
if rule.AbortMultipartDays < 0 {
|
|
return fmt.Errorf("%s: abort_multipart_days must be positive", label)
|
|
}
|
|
|
|
if rule.SizeGreaterThan < 0 || rule.SizeLessThan < 0 {
|
|
return fmt.Errorf("%s: size_greater_than and size_less_than must be positive", label)
|
|
}
|
|
if rule.SizeGreaterThan > 0 && rule.SizeLessThan > 0 && rule.SizeGreaterThan >= rule.SizeLessThan {
|
|
return fmt.Errorf("%s: size_greater_than must be less than size_less_than", label)
|
|
}
|
|
|
|
for k, v := range rule.Tags {
|
|
if strings.TrimSpace(k) == "" {
|
|
return fmt.Errorf("%s: tag keys must not be empty", label)
|
|
}
|
|
if strings.TrimSpace(v) == "" {
|
|
return fmt.Errorf("%s: tag value for key %q must not be empty", label, k)
|
|
}
|
|
}
|
|
|
|
hasAction := rule.ExpirationDays > 0 || rule.ExpirationDate != "" || rule.ExpiredObjectDeleteMarker ||
|
|
rule.NoncurrentVersionExpirationDays > 0 || rule.NewerNoncurrentVersions > 0 || rule.AbortMultipartDays > 0
|
|
if !hasAction {
|
|
return fmt.Errorf("%s: must specify at least one action (expiration, noncurrent version expiration, or abort incomplete multipart upload)", label)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ShowBucketPolicy returns the policy document for a specific bucket, or
|
|
// {"bucket": ..., "policy": null} if the bucket has none.
|
|
func (s *AdminServer) ShowBucketPolicy(w http.ResponseWriter, r *http.Request) {
|
|
bucketName := mux.Vars(r)["bucket"]
|
|
if bucketName == "" {
|
|
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
|
|
return
|
|
}
|
|
|
|
policy, raw, err := s.GetBucketPolicy(bucketName)
|
|
if err != nil {
|
|
writeJSONError(w, bucketPolicyErrorStatus(err), "Failed to get bucket policy: "+err.Error())
|
|
return
|
|
}
|
|
|
|
resp := map[string]interface{}{
|
|
"bucket": bucketName,
|
|
"policy": policy,
|
|
}
|
|
if policy == nil && len(raw) > 0 {
|
|
// Stored bytes the decoder rejects: hand them to the JSON tab so
|
|
// the operator can fix or delete the document.
|
|
resp["policy_text"] = string(raw)
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// UpdateBucketPolicy replaces the bucket policy for a bucket.
|
|
func (s *AdminServer) UpdateBucketPolicy(w http.ResponseWriter, r *http.Request) {
|
|
if !requireSessionCSRFToken(w, r) {
|
|
return
|
|
}
|
|
|
|
bucketName := mux.Vars(r)["bucket"]
|
|
if bucketName == "" {
|
|
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Policy *policy_engine.PolicyDocument `json:"policy"`
|
|
}
|
|
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
if req.Policy == nil {
|
|
writeJSONError(w, http.StatusBadRequest, "policy is required; use DELETE to clear a bucket policy")
|
|
return
|
|
}
|
|
|
|
if err := s.SetBucketPolicy(bucketName, req.Policy); err != nil {
|
|
writeJSONError(w, bucketPolicyErrorStatus(err), "Failed to update bucket policy: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"message": "Bucket policy updated successfully",
|
|
"bucket": bucketName,
|
|
})
|
|
}
|
|
|
|
// RemoveBucketPolicy clears the bucket policy for a bucket. Named
|
|
// "Remove", not "Delete", because (*AdminServer).DeleteBucketPolicy is the
|
|
// data-layer method this handler calls.
|
|
func (s *AdminServer) RemoveBucketPolicy(w http.ResponseWriter, r *http.Request) {
|
|
if !requireSessionCSRFToken(w, r) {
|
|
return
|
|
}
|
|
|
|
bucketName := mux.Vars(r)["bucket"]
|
|
if bucketName == "" {
|
|
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
|
|
return
|
|
}
|
|
|
|
if err := s.DeleteBucketPolicy(bucketName); err != nil {
|
|
writeJSONError(w, bucketPolicyErrorStatus(err), "Failed to delete bucket policy: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"message": "Bucket policy deleted successfully",
|
|
"bucket": bucketName,
|
|
})
|
|
}
|
|
|
|
// bucketPolicyErrorStatus keeps a request for a bucket that does not exist,
|
|
// or an invalid policy document, out of the 5xx bucket where a client
|
|
// would retry it. Mirrors bucketLifecycleErrorStatus.
|
|
func bucketPolicyErrorStatus(err error) int {
|
|
if errors.Is(err, ErrBucketNotFound) {
|
|
return http.StatusNotFound
|
|
}
|
|
if errors.Is(err, ErrInvalidBucketPolicy) {
|
|
return http.StatusBadRequest
|
|
}
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
// CreateBucket creates a new S3 bucket
|
|
func (s *AdminServer) CreateBucket(w http.ResponseWriter, r *http.Request) {
|
|
var req CreateBucketRequest
|
|
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Name) == "" {
|
|
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
|
|
return
|
|
}
|
|
|
|
// Validate bucket name (basic validation)
|
|
if len(req.Name) < 3 || len(req.Name) > 63 {
|
|
writeJSONError(w, http.StatusBadRequest, "Bucket name must be between 3 and 63 characters")
|
|
return
|
|
}
|
|
|
|
// Validate object lock settings
|
|
if req.ObjectLockEnabled {
|
|
// Object lock requires versioning to be enabled
|
|
req.VersioningEnabled = true
|
|
|
|
// Validate object lock mode
|
|
if req.ObjectLockMode != "GOVERNANCE" && req.ObjectLockMode != "COMPLIANCE" {
|
|
writeJSONError(w, http.StatusBadRequest, "Object lock mode must be either GOVERNANCE or COMPLIANCE")
|
|
return
|
|
}
|
|
|
|
// Validate retention duration if default retention is enabled
|
|
if req.SetDefaultRetention {
|
|
if req.ObjectLockDuration <= 0 {
|
|
writeJSONError(w, http.StatusBadRequest, "Object lock duration must be greater than 0 days when default retention is enabled")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
normalizedUnit, err := normalizeQuotaUnit(req.QuotaUnit)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
req.QuotaUnit = normalizedUnit
|
|
quotaBytes := convertQuotaToBytes(req.QuotaSize, normalizedUnit)
|
|
|
|
// Validate quota: if enabled, size must be greater than 0
|
|
if req.QuotaEnabled && quotaBytes <= 0 {
|
|
writeJSONError(w, http.StatusBadRequest, "Quota size must be greater than 0 when quota is enabled")
|
|
return
|
|
}
|
|
|
|
// Sanitize owner: trim whitespace and enforce max length
|
|
owner := strings.TrimSpace(req.Owner)
|
|
if len(owner) > MaxOwnerNameLength {
|
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength))
|
|
return
|
|
}
|
|
|
|
err = s.CreateS3BucketWithObjectLock(req.Name, quotaBytes, req.QuotaEnabled, req.VersioningEnabled, req.ObjectLockEnabled, req.ObjectLockMode, req.SetDefaultRetention, req.ObjectLockDuration, owner)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "Failed to create bucket: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
|
"message": "Bucket created successfully",
|
|
"bucket": req.Name,
|
|
"quota_size": req.QuotaSize,
|
|
"quota_unit": req.QuotaUnit,
|
|
"quota_enabled": req.QuotaEnabled,
|
|
"versioning_enabled": req.VersioningEnabled,
|
|
"object_lock_enabled": req.ObjectLockEnabled,
|
|
"object_lock_mode": req.ObjectLockMode,
|
|
"object_lock_duration": req.ObjectLockDuration,
|
|
"owner": owner,
|
|
})
|
|
}
|
|
|
|
// UpdateBucketQuota updates the quota settings for a bucket
|
|
func (s *AdminServer) UpdateBucketQuota(w http.ResponseWriter, r *http.Request) {
|
|
bucketName := mux.Vars(r)["bucket"]
|
|
if bucketName == "" {
|
|
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
QuotaSize int64 `json:"quota_size"`
|
|
QuotaUnit string `json:"quota_unit"`
|
|
QuotaEnabled bool `json:"quota_enabled"`
|
|
}
|
|
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
|
|
if req.QuotaEnabled && req.QuotaSize <= 0 {
|
|
writeJSONError(w, http.StatusBadRequest, "quota_size must be > 0 when quota_enabled is true")
|
|
return
|
|
}
|
|
|
|
normalizedUnit, err := normalizeQuotaUnit(req.QuotaUnit)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
req.QuotaUnit = normalizedUnit
|
|
// Convert quota to bytes
|
|
quotaBytes := convertQuotaToBytes(req.QuotaSize, normalizedUnit)
|
|
|
|
err = s.SetBucketQuota(bucketName, quotaBytes, req.QuotaEnabled)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "Failed to update bucket quota: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"message": "Bucket quota updated successfully",
|
|
"bucket": bucketName,
|
|
"quota_size": req.QuotaSize,
|
|
"quota_unit": req.QuotaUnit,
|
|
"quota_enabled": req.QuotaEnabled,
|
|
})
|
|
}
|
|
|
|
// DeleteBucket deletes an S3 bucket
|
|
func (s *AdminServer) DeleteBucket(w http.ResponseWriter, r *http.Request) {
|
|
bucketName := mux.Vars(r)["bucket"]
|
|
if bucketName == "" {
|
|
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
|
|
return
|
|
}
|
|
|
|
err := s.DeleteS3Bucket(bucketName)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "Failed to delete bucket: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"message": "Bucket deleted successfully",
|
|
"bucket": bucketName,
|
|
})
|
|
}
|
|
|
|
// UpdateBucketOwner updates the owner of an S3 bucket
|
|
func (s *AdminServer) UpdateBucketOwner(w http.ResponseWriter, r *http.Request) {
|
|
bucketName := mux.Vars(r)["bucket"]
|
|
if bucketName == "" {
|
|
writeJSONError(w, http.StatusBadRequest, "Bucket name is required")
|
|
return
|
|
}
|
|
|
|
// Use pointer to detect if owner field was explicitly provided
|
|
var req struct {
|
|
Owner *string `json:"owner"`
|
|
}
|
|
if err := decodeJSONBody(newJSONMaxReader(w, r), &req); err != nil {
|
|
writeJSONError(w, http.StatusBadRequest, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Require owner field to be explicitly provided
|
|
if req.Owner == nil {
|
|
writeJSONError(w, http.StatusBadRequest, "Owner field is required (use empty string to clear owner)")
|
|
return
|
|
}
|
|
|
|
// Trim and validate owner
|
|
owner := strings.TrimSpace(*req.Owner)
|
|
if len(owner) > MaxOwnerNameLength {
|
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Owner name must be %d characters or less", MaxOwnerNameLength))
|
|
return
|
|
}
|
|
|
|
err := s.SetBucketOwner(bucketName, owner)
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "Failed to update bucket owner: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"message": "Bucket owner updated successfully",
|
|
"bucket": bucketName,
|
|
"owner": owner,
|
|
})
|
|
}
|
|
|
|
// SetBucketOwner sets the owner of a bucket
|
|
func (s *AdminServer) SetBucketOwner(bucketName string, owner string) error {
|
|
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
|
// Get the current bucket entry
|
|
lookupResp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
|
|
Directory: "/buckets",
|
|
Name: bucketName,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("lookup bucket %s: %w", bucketName, err)
|
|
}
|
|
|
|
bucketEntry := lookupResp.Entry
|
|
|
|
// Initialize Extended map if nil
|
|
if bucketEntry.Extended == nil {
|
|
bucketEntry.Extended = make(map[string][]byte)
|
|
}
|
|
|
|
// Set or remove the owner. The account id recorded by the S3 API goes with
|
|
// it: the S3 API derives it from the owning identity when it is absent, so
|
|
// leaving the old one behind would keep the previous owner of the objects.
|
|
delete(bucketEntry.Extended, s3_constants.ExtAmzOwnerKey)
|
|
if owner == "" {
|
|
delete(bucketEntry.Extended, s3_constants.AmzIdentityId)
|
|
} else {
|
|
bucketEntry.Extended[s3_constants.AmzIdentityId] = []byte(owner)
|
|
}
|
|
|
|
// Update the entry
|
|
_, err = client.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
|
|
Directory: "/buckets",
|
|
Entry: bucketEntry,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update bucket owner: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// ListBucketsAPI returns the list of buckets as JSON
|
|
func (s *AdminServer) ListBucketsAPI(w http.ResponseWriter, r *http.Request) {
|
|
buckets, err := s.GetS3Buckets()
|
|
if err != nil {
|
|
writeJSONError(w, http.StatusInternalServerError, "Failed to get buckets: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"buckets": buckets,
|
|
"total": len(buckets),
|
|
})
|
|
}
|
|
|
|
// Helper function to convert quota size and unit to bytes
|
|
func convertQuotaToBytes(size int64, unit string) int64 {
|
|
if size <= 0 {
|
|
return 0
|
|
}
|
|
|
|
switch unit {
|
|
case "TB":
|
|
return size * 1024 * 1024 * 1024 * 1024
|
|
case "GB":
|
|
return size * 1024 * 1024 * 1024
|
|
case "MB":
|
|
return size * 1024 * 1024
|
|
case "KB":
|
|
return size * 1024
|
|
case "B":
|
|
return size
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func normalizeQuotaUnit(unit string) (string, error) {
|
|
normalized := strings.ToUpper(strings.TrimSpace(unit))
|
|
if normalized == "" {
|
|
return "MB", nil
|
|
}
|
|
switch normalized {
|
|
case "B", "KB", "MB", "GB", "TB":
|
|
return normalized, nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported quota unit: %s", unit)
|
|
}
|
|
}
|
|
|
|
// SetBucketQuota sets the quota for a bucket
|
|
func (s *AdminServer) SetBucketQuota(bucketName string, quotaBytes int64, quotaEnabled bool) error {
|
|
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
|
// Get the current bucket entry
|
|
lookupResp, err := client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
|
|
Directory: "/buckets",
|
|
Name: bucketName,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("bucket not found: %w", err)
|
|
}
|
|
|
|
bucketEntry := lookupResp.Entry
|
|
|
|
// Determine quota value (negative if disabled)
|
|
var quota int64
|
|
if quotaEnabled && quotaBytes > 0 {
|
|
quota = quotaBytes
|
|
} else if !quotaEnabled && quotaBytes > 0 {
|
|
quota = -quotaBytes
|
|
} else {
|
|
quota = 0
|
|
}
|
|
|
|
// Update the quota
|
|
bucketEntry.Quota = quota
|
|
|
|
// Update the entry
|
|
_, err = client.UpdateEntry(context.Background(), &filer_pb.UpdateEntryRequest{
|
|
Directory: "/buckets",
|
|
Entry: bucketEntry,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update bucket quota: %w", err)
|
|
}
|
|
|
|
if quota <= 0 {
|
|
// with no active quota, quota enforcement can no longer clear a
|
|
// read-only flag it turned on, so lift it here
|
|
if _, err := filer.ClearBucketReadOnly(context.Background(), client, "/buckets", bucketName); err != nil {
|
|
return fmt.Errorf("failed to clear bucket read-only flag: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// CreateS3BucketWithQuota creates a new S3 bucket with quota settings
|
|
func (s *AdminServer) CreateS3BucketWithQuota(bucketName string, quotaBytes int64, quotaEnabled bool) error {
|
|
return s.CreateS3BucketWithObjectLock(bucketName, quotaBytes, quotaEnabled, false, false, "", false, 0, "")
|
|
}
|
|
|
|
// CreateS3BucketWithObjectLock creates a new S3 bucket with quota, versioning, object lock settings, and owner
|
|
func (s *AdminServer) CreateS3BucketWithObjectLock(bucketName string, quotaBytes int64, quotaEnabled, versioningEnabled, objectLockEnabled bool, objectLockMode string, setDefaultRetention bool, objectLockDuration int32, owner string) error {
|
|
return s.WithFilerClient(func(client filer_pb.SeaweedFilerClient) error {
|
|
// First ensure /buckets directory exists
|
|
_, err := client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
|
Directory: "/",
|
|
Entry: &filer_pb.Entry{
|
|
Name: "buckets",
|
|
IsDirectory: true,
|
|
Attributes: &filer_pb.FuseAttributes{
|
|
FileMode: uint32(0755 | os.ModeDir), // Directory mode
|
|
Uid: uint32(1000),
|
|
Gid: uint32(1000),
|
|
Crtime: time.Now().Unix(),
|
|
Mtime: time.Now().Unix(),
|
|
TtlSec: 0,
|
|
},
|
|
},
|
|
})
|
|
// Ignore error if directory already exists
|
|
if err != nil && !strings.Contains(err.Error(), "already exists") && !strings.Contains(err.Error(), "existing entry") {
|
|
return fmt.Errorf("failed to create /buckets directory: %w", err)
|
|
}
|
|
|
|
// Check if bucket already exists
|
|
_, err = client.LookupDirectoryEntry(context.Background(), &filer_pb.LookupDirectoryEntryRequest{
|
|
Directory: "/buckets",
|
|
Name: bucketName,
|
|
})
|
|
if err == nil {
|
|
return fmt.Errorf("bucket %s already exists", bucketName)
|
|
}
|
|
|
|
// Determine quota value (negative if disabled)
|
|
var quota int64
|
|
if quotaEnabled && quotaBytes > 0 {
|
|
quota = quotaBytes
|
|
} else if !quotaEnabled && quotaBytes > 0 {
|
|
quota = -quotaBytes
|
|
} else {
|
|
quota = 0
|
|
}
|
|
|
|
// Prepare bucket attributes with versioning and object lock metadata
|
|
attributes := &filer_pb.FuseAttributes{
|
|
FileMode: uint32(0755 | os.ModeDir), // Directory mode
|
|
Uid: filer_pb.OS_UID,
|
|
Gid: filer_pb.OS_GID,
|
|
Crtime: time.Now().Unix(),
|
|
Mtime: time.Now().Unix(),
|
|
TtlSec: 0,
|
|
}
|
|
|
|
// Create extended attributes map for versioning and owner
|
|
extended := make(map[string][]byte)
|
|
|
|
// Set bucket owner if specified
|
|
if owner != "" {
|
|
extended[s3_constants.AmzIdentityId] = []byte(owner)
|
|
}
|
|
|
|
// Create bucket entry
|
|
bucketEntry := &filer_pb.Entry{
|
|
Name: bucketName,
|
|
IsDirectory: true,
|
|
Attributes: attributes,
|
|
Extended: extended,
|
|
Quota: quota,
|
|
}
|
|
|
|
// Handle versioning using shared utilities
|
|
if err := s3api.StoreVersioningInExtended(bucketEntry, versioningEnabled); err != nil {
|
|
return fmt.Errorf("failed to store versioning configuration: %w", err)
|
|
}
|
|
|
|
// Handle Object Lock configuration using shared utilities
|
|
if objectLockEnabled {
|
|
var duration int32 = 0
|
|
var mode string = ""
|
|
|
|
if setDefaultRetention {
|
|
// Validate Object Lock parameters only when setting default retention
|
|
if err := s3api.ValidateObjectLockParameters(objectLockEnabled, objectLockMode, objectLockDuration); err != nil {
|
|
return fmt.Errorf("invalid Object Lock parameters: %w", err)
|
|
}
|
|
duration = objectLockDuration
|
|
mode = objectLockMode
|
|
}
|
|
|
|
// Create Object Lock configuration using shared utility
|
|
objectLockConfig := s3api.CreateObjectLockConfigurationFromParams(objectLockEnabled, mode, duration)
|
|
|
|
// Store Object Lock configuration in extended attributes using shared utility
|
|
if err := s3api.StoreObjectLockConfigurationInExtended(bucketEntry, objectLockConfig); err != nil {
|
|
return fmt.Errorf("failed to store Object Lock configuration: %w", err)
|
|
}
|
|
}
|
|
|
|
// Create bucket directory under /buckets
|
|
_, err = client.CreateEntry(context.Background(), &filer_pb.CreateEntryRequest{
|
|
Directory: "/buckets",
|
|
Entry: bucketEntry,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create bucket directory: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|