Add immutable placement-target selection to Bucket
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

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:
2026-07-29 00:16:31 +10:00
parent 52e183e6f3
commit c1b3ba1c34
9 changed files with 292 additions and 13 deletions
+24
View File
@@ -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)
}
})
}
}