Add immutable placement-target selection to Bucket
Buckets could not choose which RGW placement target (and thus durability profile) backs them, so all data landed on the cluster default. The estate's radosgw exposes two targets - default-placement (3x replicated) and ec (4+1 erasure-coded) - and archival workloads want ec. - Validate spec.placementTarget: a DNS-ish pattern, 63-char cap, and a CEL self==oldSelf immutability rule (RGW fixes placement at bucket creation and cannot move a bucket between targets); make spec.zonegroup immutable too. - Thread the target into the S3 CreateBucket LocationConstraint via the existing helper; an empty zonegroup yields ":<target>", selecting the local zonegroup so callers need not name the zonegroup api-name. - Read the live placement_rule and zonegroup back from the Admin Ops bucket stats and surface them: status.placementTarget plus a Placement print column. - Guard the controller: if a live bucket's placement differs from spec, set an Error phase with a PlacementImmutable reason instead of deleting/recreating. - Cover locationConstraint construction, placement readback (httptest), and the placementConflict guard with tests; document targets and immutability in the README and add config/samples/06-bucket-ec.yaml. Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
This commit is contained in:
@@ -18,6 +18,11 @@ type BucketInfo struct {
|
||||
Bid string
|
||||
ID string
|
||||
Owner string
|
||||
// PlacementRule is the placement target RGW stores the bucket on (e.g.
|
||||
// "default-placement" or "ec"), read from the Admin Ops bucket stats.
|
||||
PlacementRule string
|
||||
// Zonegroup is the RGW zonegroup id the bucket belongs to.
|
||||
Zonegroup string
|
||||
}
|
||||
|
||||
// Name returns the bucket name regardless of the field radosgw used.
|
||||
@@ -50,7 +55,13 @@ func (c *Client) GetBucket(ctx context.Context, name string) (*BucketInfo, error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &BucketInfo{Bucket: b.Bucket, ID: b.ID, Owner: b.Owner}, nil
|
||||
return &BucketInfo{
|
||||
Bucket: b.Bucket,
|
||||
ID: b.ID,
|
||||
Owner: b.Owner,
|
||||
PlacementRule: b.PlacementRule,
|
||||
Zonegroup: b.Zonegroup,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateBucket provisions a bucket owned by spec.OwnerUID. The Admin Ops API
|
||||
@@ -211,8 +222,14 @@ func (c *Client) setObjectLockDefault(ctx context.Context, owner func(*s3.Option
|
||||
return err
|
||||
}
|
||||
|
||||
// locationConstraint renders the RGW LocationConstraint from a zonegroup and
|
||||
// placement target ("<zonegroup>:<placement>"), or "" for default placement.
|
||||
// locationConstraint renders the RGW S3 CreateBucket LocationConstraint from a
|
||||
// zonegroup api-name and a placement target. RGW's S3 create-bucket handler
|
||||
// splits the value on the first ":" — the part before is the zonegroup api-name,
|
||||
// the part after is the placement target id. An empty zonegroup (the common
|
||||
// case) yields ":<placement>", which selects the local/master zonegroup with the
|
||||
// given placement, so callers need not know the zonegroup's api-name to pick a
|
||||
// placement target. Both empty yields "" (no constraint: user/zonegroup
|
||||
// default). Placement empty with a zonegroup set yields just the zonegroup.
|
||||
func locationConstraint(zonegroup, placement string) string {
|
||||
loc := zonegroup
|
||||
if placement != "" {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package ceph
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocationConstraint(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
zonegroup string
|
||||
placement string
|
||||
want string
|
||||
}{
|
||||
{"both empty -> no constraint", "", "", ""},
|
||||
{"placement only -> local zonegroup", "", "ec", ":ec"},
|
||||
{"placement only default target", "", "default-placement", ":default-placement"},
|
||||
{"zonegroup and placement", "default", "ec", "default:ec"},
|
||||
{"zonegroup only", "default", "", "default"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := locationConstraint(tc.zonegroup, tc.placement); got != tc.want {
|
||||
t.Errorf("locationConstraint(%q,%q)=%q want %q", tc.zonegroup, tc.placement, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetBucketPlacement verifies GetBucket surfaces the placement target and
|
||||
// zonegroup from the Admin Ops bucket-stats response, so the controller can
|
||||
// detect placement drift.
|
||||
func TestGetBucketPlacement(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{
|
||||
"bucket": "raw-archive",
|
||||
"id": "eae688bc-ee35-445d-9188-111b73c8b4a0.12345.1",
|
||||
"owner": "logarchiver",
|
||||
"zonegroup": "eae688bc-ee35-445d-9188-111b73c8b4a0",
|
||||
"placement_rule": "ec"
|
||||
}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := NewClient(Config{Endpoint: srv.URL, AccessKey: "a", SecretKey: "s"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
info, err := c.GetBucket(context.Background(), "raw-archive")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBucket: %v", err)
|
||||
}
|
||||
if info.PlacementRule != "ec" {
|
||||
t.Errorf("PlacementRule=%q want %q", info.PlacementRule, "ec")
|
||||
}
|
||||
if info.Zonegroup != "eae688bc-ee35-445d-9188-111b73c8b4a0" {
|
||||
t.Errorf("Zonegroup=%q unexpected", info.Zonegroup)
|
||||
}
|
||||
if info.Owner != "logarchiver" {
|
||||
t.Errorf("Owner=%q want logarchiver", info.Owner)
|
||||
}
|
||||
if info.Name() != "raw-archive" {
|
||||
t.Errorf("Name()=%q want raw-archive", info.Name())
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,18 @@ func (r *BucketReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
|
||||
}
|
||||
bucketID := info.InstanceID()
|
||||
|
||||
// Placement is fixed at creation: RGW cannot move an existing bucket between
|
||||
// placement targets. If the live bucket sits on a different target than the
|
||||
// spec asks for (a changed spec, or an adopted bucket that predates the
|
||||
// request), surface a clear error instead of ever deleting/recreating it. An
|
||||
// empty PlacementTarget imposes no constraint.
|
||||
if pc := placementConflict(b.Spec.PlacementTarget, info.PlacementRule); pc {
|
||||
b.Status.PlacementTarget = info.PlacementRule
|
||||
return r.fail(ctx, &b, "PlacementImmutable", fmt.Errorf(
|
||||
"bucket %q is on placement target %q but spec requests %q; RGW cannot move a bucket between placement targets",
|
||||
bucketName, info.PlacementRule, b.Spec.PlacementTarget))
|
||||
}
|
||||
|
||||
// Versioning (forced on when object lock is enabled).
|
||||
if b.Spec.Versioning || (b.Spec.ObjectLock != nil && b.Spec.ObjectLock.Enabled) {
|
||||
if err := r.Ceph.SetBucketVersioning(ctx, bucketName, bucketID, ownerUID, true); err != nil {
|
||||
@@ -167,6 +179,7 @@ func (r *BucketReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
|
||||
b.Status.BucketName = bucketName
|
||||
b.Status.BucketID = bucketID
|
||||
b.Status.Owner = ownerUID
|
||||
b.Status.PlacementTarget = info.PlacementRule
|
||||
b.Status.PolicyPrincipals = int32(principals)
|
||||
b.Status.ObservedGeneration = b.Generation
|
||||
setReady(&b.Status.Conditions, b.Generation, true, "Provisioned", "bucket provisioned")
|
||||
@@ -250,6 +263,17 @@ func managePolicy(b *v1alpha1.Bucket) bool {
|
||||
return b.Spec.ManagePolicy == nil || *b.Spec.ManagePolicy
|
||||
}
|
||||
|
||||
// placementConflict reports whether a bucket's live placement target violates
|
||||
// the spec. An empty spec placement imposes no constraint (the bucket may sit on
|
||||
// whatever default it was created with). Otherwise the live placement must match
|
||||
// exactly, since RGW cannot move a bucket between placement targets.
|
||||
func placementConflict(specPlacement, livePlacement string) bool {
|
||||
if specPlacement == "" {
|
||||
return false
|
||||
}
|
||||
return specPlacement != livePlacement
|
||||
}
|
||||
|
||||
func (r *BucketReconciler) pending(ctx context.Context, b *v1alpha1.Bucket, reason, msg string) (ctrl.Result, error) {
|
||||
b.Status.Phase = "Pending"
|
||||
b.Status.ObservedGeneration = b.Generation
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package controller
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPlacementConflict(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
specPlacement string
|
||||
livePlacement string
|
||||
want bool
|
||||
}{
|
||||
{"unset spec never conflicts", "", "default-placement", false},
|
||||
{"unset spec unset live", "", "", false},
|
||||
{"matching ec", "ec", "ec", false},
|
||||
{"matching default", "default-placement", "default-placement", false},
|
||||
{"ec requested but default live", "ec", "default-placement", true},
|
||||
{"default requested but ec live", "default-placement", "ec", true},
|
||||
{"spec set live empty", "ec", "", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := placementConflict(tc.specPlacement, tc.livePlacement); got != tc.want {
|
||||
t.Errorf("placementConflict(%q,%q)=%v want %v", tc.specPlacement, tc.livePlacement, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user