Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a89e38be64 | |||
| ede6604ab1 | |||
| f60e69bb79 | |||
| 5f0b9f53ad | |||
| 7f1f57eb8d | |||
| d3b022d646 | |||
| 343d60cfcf | |||
| c1b3ba1c34 | |||
| 52e183e6f3 | |||
| e7760b79f4 | |||
| 9bbaa2b8ba | |||
| 54d3e38223 |
@@ -3,8 +3,8 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: docker-build-operator
|
- name: docker-build-operator
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
|
||||||
settings:
|
settings:
|
||||||
repo: git.unkin.net/unkin/cephrgw-operator
|
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/cephrgw-operator
|
||||||
dockerfile: Dockerfile.operator
|
dockerfile: Dockerfile.operator
|
||||||
dry_run: true
|
dry_run: true
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: docker-operator
|
- name: docker-operator
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: artifactapi.k8s.syd1.au.unkin.net/docker-internal/plugin-docker-buildx:latest
|
||||||
settings:
|
settings:
|
||||||
registry: git.unkin.net
|
registry: artifactapi.k8s.syd1.au.unkin.net
|
||||||
repo: git.unkin.net/unkin/cephrgw-operator
|
repo: artifactapi.k8s.syd1.au.unkin.net/docker-internal/cephrgw-operator
|
||||||
dockerfile: Dockerfile.operator
|
dockerfile: Dockerfile.operator
|
||||||
username: droneci
|
buildkit_config: |
|
||||||
password:
|
[registry."artifactapi.k8s.syd1.au.unkin.net"]
|
||||||
from_secret: DRONECI_PASSWORD
|
ca = ["/etc/docker/certs.d/artifactapi.k8s.syd1.au.unkin.net/ca.crt"]
|
||||||
tags:
|
tags:
|
||||||
- ${CI_COMMIT_TAG}
|
- ${CI_COMMIT_TAG}
|
||||||
- latest
|
- latest
|
||||||
|
|||||||
@@ -60,6 +60,54 @@ refine it (see `config/samples/04-access-fine-grained.yaml`):
|
|||||||
RGW honours S3 bucket policy on **Reef 18.2+ / Squid**; condition-key support is
|
RGW honours S3 bucket policy on **Reef 18.2+ / Squid**; condition-key support is
|
||||||
a subset of AWS, so validate exotic conditions against your cluster.
|
a subset of AWS, so validate exotic conditions against your cluster.
|
||||||
|
|
||||||
|
### Placement targets
|
||||||
|
|
||||||
|
`Bucket.spec.placementTarget` selects the RGW **placement target** that backs the
|
||||||
|
bucket — i.e. which pools, and therefore which durability profile, store its
|
||||||
|
data. The valid values are cluster configuration, not a fixed set baked into the
|
||||||
|
operator. On this estate radosgw exposes two:
|
||||||
|
|
||||||
|
- `default-placement` — 3× replicated (the cluster default).
|
||||||
|
- `ec` — 4+1 erasure-coded (cheaper capacity, for bulk/archival data).
|
||||||
|
|
||||||
|
Leaving `placementTarget` empty keeps the current behaviour: the owning user's
|
||||||
|
`default_placement` (falling back to the zonegroup default). When set, the
|
||||||
|
operator threads it into the S3 `CreateBucket` `LocationConstraint` as
|
||||||
|
`<zonegroup>:<placementTarget>`; with `spec.zonegroup` empty (the default) that
|
||||||
|
is `:<placementTarget>`, which selects the local/master zonegroup with the given
|
||||||
|
placement — so you do not need to know the zonegroup's api-name to pick a target.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: ceph.unkin.net/v1alpha1
|
||||||
|
kind: Bucket
|
||||||
|
metadata:
|
||||||
|
name: raw-archive
|
||||||
|
spec:
|
||||||
|
ownerRef: logarchiver
|
||||||
|
placementTarget: ec # 4+1 erasure-coded pool
|
||||||
|
```
|
||||||
|
|
||||||
|
Placement is **immutable**: RGW fixes it at bucket creation and cannot move an
|
||||||
|
existing bucket between targets. The CRD rejects changing `placementTarget` (and
|
||||||
|
`zonegroup`) on an existing `Bucket`, and if a bucket already lives on a
|
||||||
|
different target than the spec requests (e.g. an adopted bucket, or a value
|
||||||
|
sneaked in around the CRD guard) the controller sets an `Error` phase with a
|
||||||
|
`PlacementImmutable` reason rather than ever deleting and recreating it. The
|
||||||
|
placement RGW actually stores the bucket on is reported in
|
||||||
|
`status.placementTarget` (and the `Placement` print column), so drift is visible.
|
||||||
|
See `config/samples/06-bucket-ec.yaml`.
|
||||||
|
|
||||||
|
### Adopting existing buckets and users
|
||||||
|
|
||||||
|
The operator can take over buckets/users that already exist in radosgw and hand
|
||||||
|
them back without deleting them. In short: matching CRDs manage the resource in
|
||||||
|
place (no recreation, keys reused, `status.adopted: true`), the bucket policy is
|
||||||
|
**merged** so an existing hand-written policy is preserved (`spec.managePolicy:
|
||||||
|
false` opts out entirely), and `spec.retainOnDelete` on `ObjectStoreUser` /
|
||||||
|
`Bucket` / `BucketAccess` orphans the RGW object instead of deleting it. See
|
||||||
|
**[docs/adoption.md](docs/adoption.md)** and
|
||||||
|
`config/samples/05-adoption.yaml`.
|
||||||
|
|
||||||
The `Bucket` controller renders the policy as the **union of every ready
|
The `Bucket` controller renders the policy as the **union of every ready
|
||||||
`BucketAccess`** that targets it, so the result is convergent regardless of the
|
`BucketAccess`** that targets it, so the result is convergent regardless of the
|
||||||
order objects are created or deleted. It watches `BucketAccess` and
|
order objects are created or deleted. It watches `BucketAccess` and
|
||||||
@@ -129,7 +177,8 @@ kubectl apply -f hack/kind/manifests/ # edit the Secret first
|
|||||||
|
|
||||||
Woodpecker runs `pre-commit` (gofmt + vet), `test`, and a dry-run image `build`
|
Woodpecker runs `pre-commit` (gofmt + vet), `test`, and a dry-run image `build`
|
||||||
on pull requests; pushing a `v*` tag builds and pushes
|
on pull requests; pushing a `v*` tag builds and pushes
|
||||||
`git.unkin.net/unkin/cephrgw-operator` to the Gitea registry. Bump a release
|
`artifactapi.k8s.syd1.au.unkin.net/docker-internal/cephrgw-operator` to the
|
||||||
|
artifactapi local docker registry. Bump a release
|
||||||
with `make patch|minor|major`.
|
with `make patch|minor|major`.
|
||||||
|
|
||||||
## Notes & caveats
|
## Notes & caveats
|
||||||
|
|||||||
@@ -40,11 +40,29 @@ type BucketSpec struct {
|
|||||||
// with BucketAccess objects.
|
// with BucketAccess objects.
|
||||||
OwnerRef string `json:"ownerRef"`
|
OwnerRef string `json:"ownerRef"`
|
||||||
|
|
||||||
// Zonegroup optionally pins the bucket to a specific RGW zonegroup.
|
// Zonegroup optionally pins the bucket to a specific RGW zonegroup by its
|
||||||
|
// api-name. Empty (the default) uses the cluster's local/master zonegroup, so
|
||||||
|
// PlacementTarget selection works without naming the zonegroup. Immutable:
|
||||||
|
// RGW resolves the zonegroup at bucket creation and cannot move it afterwards.
|
||||||
|
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="zonegroup is immutable; RGW fixes it at bucket creation"
|
||||||
// +optional
|
// +optional
|
||||||
Zonegroup string `json:"zonegroup,omitempty"`
|
Zonegroup string `json:"zonegroup,omitempty"`
|
||||||
|
|
||||||
// PlacementTarget optionally selects a non-default placement target/pool.
|
// PlacementTarget optionally selects the RGW placement target that backs the
|
||||||
|
// bucket, choosing which pools (and thus replication/erasure profile) store
|
||||||
|
// its data. Empty (the default) uses the owning user's default_placement, or
|
||||||
|
// the zonegroup default. The valid values are cluster configuration, not a
|
||||||
|
// fixed set; on this estate the two configured targets are
|
||||||
|
// "default-placement" (3x replicated) and "ec" (4+1 erasure-coded).
|
||||||
|
//
|
||||||
|
// Immutable: RGW chooses the placement at bucket creation (from the S3
|
||||||
|
// LocationConstraint) and cannot move an existing bucket between placement
|
||||||
|
// targets. Set it on a fresh Bucket; changing it later is rejected, and if a
|
||||||
|
// pre-existing bucket is on a different placement the operator reports an
|
||||||
|
// error instead of recreating it.
|
||||||
|
// +kubebuilder:validation:MaxLength=63
|
||||||
|
// +kubebuilder:validation:Pattern=`^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$`
|
||||||
|
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="placementTarget is immutable; RGW cannot move a bucket between placement targets"
|
||||||
// +optional
|
// +optional
|
||||||
PlacementTarget string `json:"placementTarget,omitempty"`
|
PlacementTarget string `json:"placementTarget,omitempty"`
|
||||||
|
|
||||||
@@ -64,6 +82,16 @@ type BucketSpec struct {
|
|||||||
// +optional
|
// +optional
|
||||||
Tags map[string]string `json:"tags,omitempty"`
|
Tags map[string]string `json:"tags,omitempty"`
|
||||||
|
|
||||||
|
// ManagePolicy controls whether the operator manages the bucket's S3 policy
|
||||||
|
// from BucketAccess grants. When true (the default) the operator reconciles
|
||||||
|
// its own statements while preserving any statements it does not own, so it
|
||||||
|
// is safe to adopt a bucket that already has a policy. Set to false to leave
|
||||||
|
// the bucket policy entirely untouched (BucketAccess grants then have no
|
||||||
|
// effect on this bucket).
|
||||||
|
// +kubebuilder:default=true
|
||||||
|
// +optional
|
||||||
|
ManagePolicy *bool `json:"managePolicy,omitempty"`
|
||||||
|
|
||||||
// RetainOnDelete keeps the RGW bucket (and its objects) when the Bucket
|
// RetainOnDelete keeps the RGW bucket (and its objects) when the Bucket
|
||||||
// resource is deleted. By default the operator removes the empty bucket;
|
// resource is deleted. By default the operator removes the empty bucket;
|
||||||
// it never purges objects unless PurgeOnDelete is also set.
|
// it never purges objects unless PurgeOnDelete is also set.
|
||||||
@@ -90,10 +118,19 @@ type BucketStatus struct {
|
|||||||
// Owner is the RGW uid that owns the bucket.
|
// Owner is the RGW uid that owns the bucket.
|
||||||
// +optional
|
// +optional
|
||||||
Owner string `json:"owner,omitempty"`
|
Owner string `json:"owner,omitempty"`
|
||||||
|
// PlacementTarget is the placement target RGW actually stores the bucket on,
|
||||||
|
// read back from the live bucket. It makes placement drift (a bucket landing
|
||||||
|
// on a different target than spec requested) visible.
|
||||||
|
// +optional
|
||||||
|
PlacementTarget string `json:"placementTarget,omitempty"`
|
||||||
// PolicyPrincipals is the number of extra principals granted via
|
// PolicyPrincipals is the number of extra principals granted via
|
||||||
// BucketAccess and reflected in the bucket policy.
|
// BucketAccess and reflected in the bucket policy.
|
||||||
// +optional
|
// +optional
|
||||||
PolicyPrincipals int32 `json:"policyPrincipals,omitempty"`
|
PolicyPrincipals int32 `json:"policyPrincipals,omitempty"`
|
||||||
|
// Adopted reports that the RGW bucket already existed when the operator
|
||||||
|
// first reconciled this resource (it was taken over, not created).
|
||||||
|
// +optional
|
||||||
|
Adopted bool `json:"adopted,omitempty"`
|
||||||
// +optional
|
// +optional
|
||||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||||
// +optional
|
// +optional
|
||||||
@@ -107,7 +144,9 @@ type BucketStatus struct {
|
|||||||
// +kubebuilder:resource:shortName=bkt
|
// +kubebuilder:resource:shortName=bkt
|
||||||
// +kubebuilder:printcolumn:name="Bucket",type=string,JSONPath=`.status.bucketName`
|
// +kubebuilder:printcolumn:name="Bucket",type=string,JSONPath=`.status.bucketName`
|
||||||
// +kubebuilder:printcolumn:name="Owner",type=string,JSONPath=`.status.owner`
|
// +kubebuilder:printcolumn:name="Owner",type=string,JSONPath=`.status.owner`
|
||||||
|
// +kubebuilder:printcolumn:name="Placement",type=string,JSONPath=`.status.placementTarget`
|
||||||
// +kubebuilder:printcolumn:name="Grants",type=integer,JSONPath=`.status.policyPrincipals`
|
// +kubebuilder:printcolumn:name="Grants",type=integer,JSONPath=`.status.policyPrincipals`
|
||||||
|
// +kubebuilder:printcolumn:name="Adopted",type=boolean,JSONPath=`.status.adopted`
|
||||||
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
|
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
|
||||||
|
|
||||||
// Bucket is a Ceph RGW S3 bucket.
|
// Bucket is a Ceph RGW S3 bucket.
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ type BucketAccessSpec struct {
|
|||||||
// +optional
|
// +optional
|
||||||
SecretName string `json:"secretName,omitempty"`
|
SecretName string `json:"secretName,omitempty"`
|
||||||
|
|
||||||
|
// RetainOnDelete keeps the dedicated RGW user (created when UserRef is empty)
|
||||||
|
// instead of deleting it when this BucketAccess is removed. Ignored when
|
||||||
|
// UserRef is set (that user is never managed here). Defaults to false.
|
||||||
|
// +optional
|
||||||
|
RetainOnDelete bool `json:"retainOnDelete,omitempty"`
|
||||||
|
|
||||||
// Paths optionally scopes object-level access to these key prefixes within
|
// Paths optionally scopes object-level access to these key prefixes within
|
||||||
// the bucket; each becomes the resource "<bucket>/<prefix>*". Empty grants
|
// the bucket; each becomes the resource "<bucket>/<prefix>*". Empty grants
|
||||||
// the whole bucket. The bucket-level ListBucket action always applies to the
|
// the whole bucket. The bucket-level ListBucket action always applies to the
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ type ObjectStoreUserSpec struct {
|
|||||||
// +optional
|
// +optional
|
||||||
MaxBuckets *int32 `json:"maxBuckets,omitempty"`
|
MaxBuckets *int32 `json:"maxBuckets,omitempty"`
|
||||||
|
|
||||||
// Suspended, when true, suspends the user so its keys stop working.
|
// Suspended manages the user's suspended state: true suspends the user so
|
||||||
|
// its keys stop working, false resumes it. When unset the operator does not
|
||||||
|
// touch the suspended state (useful when adopting an existing user).
|
||||||
// +optional
|
// +optional
|
||||||
Suspended bool `json:"suspended,omitempty"`
|
Suspended *bool `json:"suspended,omitempty"`
|
||||||
|
|
||||||
// Quota optionally applies a user-level quota.
|
// Quota optionally applies a user-level quota.
|
||||||
// +optional
|
// +optional
|
||||||
@@ -40,6 +42,12 @@ type ObjectStoreUserSpec struct {
|
|||||||
// AWS_SECRET_ACCESS_KEY, BUCKET_HOST and the RGW uid.
|
// AWS_SECRET_ACCESS_KEY, BUCKET_HOST and the RGW uid.
|
||||||
// +optional
|
// +optional
|
||||||
SecretName string `json:"secretName,omitempty"`
|
SecretName string `json:"secretName,omitempty"`
|
||||||
|
|
||||||
|
// RetainOnDelete keeps the RGW user (and its keys) when the ObjectStoreUser
|
||||||
|
// resource is deleted, instead of removing it. Set this before adopting an
|
||||||
|
// existing user you may later want to hand back. Defaults to false.
|
||||||
|
// +optional
|
||||||
|
RetainOnDelete bool `json:"retainOnDelete,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ObjectStoreUserStatus reports observed user state.
|
// ObjectStoreUserStatus reports observed user state.
|
||||||
@@ -53,6 +61,10 @@ type ObjectStoreUserStatus struct {
|
|||||||
// SecretName is the Secret holding the user's credentials.
|
// SecretName is the Secret holding the user's credentials.
|
||||||
// +optional
|
// +optional
|
||||||
SecretName string `json:"secretName,omitempty"`
|
SecretName string `json:"secretName,omitempty"`
|
||||||
|
// Adopted reports that the RGW user already existed when the operator first
|
||||||
|
// reconciled this resource (it was taken over, not created).
|
||||||
|
// +optional
|
||||||
|
Adopted bool `json:"adopted,omitempty"`
|
||||||
// +optional
|
// +optional
|
||||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||||
// +optional
|
// +optional
|
||||||
@@ -66,6 +78,7 @@ type ObjectStoreUserStatus struct {
|
|||||||
// +kubebuilder:resource:shortName=osu
|
// +kubebuilder:resource:shortName=osu
|
||||||
// +kubebuilder:printcolumn:name="UID",type=string,JSONPath=`.status.uid`
|
// +kubebuilder:printcolumn:name="UID",type=string,JSONPath=`.status.uid`
|
||||||
// +kubebuilder:printcolumn:name="Secret",type=string,JSONPath=`.status.secretName`
|
// +kubebuilder:printcolumn:name="Secret",type=string,JSONPath=`.status.secretName`
|
||||||
|
// +kubebuilder:printcolumn:name="Adopted",type=boolean,JSONPath=`.status.adopted`
|
||||||
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
|
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
|
||||||
|
|
||||||
// ObjectStoreUser is a Ceph RGW S3 user whose keys are delivered into a Secret.
|
// ObjectStoreUser is a Ceph RGW S3 user whose keys are delivered into a Secret.
|
||||||
|
|||||||
@@ -226,6 +226,11 @@ func (in *BucketSpec) DeepCopyInto(out *BucketSpec) {
|
|||||||
(*out)[key] = val
|
(*out)[key] = val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if in.ManagePolicy != nil {
|
||||||
|
in, out := &in.ManagePolicy, &out.ManagePolicy
|
||||||
|
*out = new(bool)
|
||||||
|
**out = **in
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BucketSpec.
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BucketSpec.
|
||||||
@@ -352,6 +357,11 @@ func (in *ObjectStoreUserSpec) DeepCopyInto(out *ObjectStoreUserSpec) {
|
|||||||
*out = new(int32)
|
*out = new(int32)
|
||||||
**out = **in
|
**out = **in
|
||||||
}
|
}
|
||||||
|
if in.Suspended != nil {
|
||||||
|
in, out := &in.Suspended, &out.Suspended
|
||||||
|
*out = new(bool)
|
||||||
|
**out = **in
|
||||||
|
}
|
||||||
if in.Quota != nil {
|
if in.Quota != nil {
|
||||||
in, out := &in.Quota, &out.Quota
|
in, out := &in.Quota, &out.Quota
|
||||||
*out = new(Quota)
|
*out = new(Quota)
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Advisory only: warn (never exit) if the installed CRDs are missing or
|
||||||
|
// predate this operator's schema, which otherwise surfaces only as opaque
|
||||||
|
// strict-decode failures during reconcile.
|
||||||
|
crdCtx, crdCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
controller.CheckCRDVersions(crdCtx, mgr.GetConfig())
|
||||||
|
crdCancel()
|
||||||
|
|
||||||
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
||||||
logger.Error(err, "unable to set up health check")
|
logger.Error(err, "unable to set up health check")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -156,6 +156,12 @@ spec:
|
|||||||
- actions
|
- actions
|
||||||
type: object
|
type: object
|
||||||
type: array
|
type: array
|
||||||
|
retainOnDelete:
|
||||||
|
description: |-
|
||||||
|
RetainOnDelete keeps the dedicated RGW user (created when UserRef is empty)
|
||||||
|
instead of deleting it when this BucketAccess is removed. Ignored when
|
||||||
|
UserRef is set (that user is never managed here). Defaults to false.
|
||||||
|
type: boolean
|
||||||
secretName:
|
secretName:
|
||||||
description: |-
|
description: |-
|
||||||
SecretName is the Secret the operator writes credentials into for the
|
SecretName is the Secret the operator writes credentials into for the
|
||||||
|
|||||||
@@ -23,9 +23,15 @@ spec:
|
|||||||
- jsonPath: .status.owner
|
- jsonPath: .status.owner
|
||||||
name: Owner
|
name: Owner
|
||||||
type: string
|
type: string
|
||||||
|
- jsonPath: .status.placementTarget
|
||||||
|
name: Placement
|
||||||
|
type: string
|
||||||
- jsonPath: .status.policyPrincipals
|
- jsonPath: .status.policyPrincipals
|
||||||
name: Grants
|
name: Grants
|
||||||
type: integer
|
type: integer
|
||||||
|
- jsonPath: .status.adopted
|
||||||
|
name: Adopted
|
||||||
|
type: boolean
|
||||||
- jsonPath: .status.phase
|
- jsonPath: .status.phase
|
||||||
name: Phase
|
name: Phase
|
||||||
type: string
|
type: string
|
||||||
@@ -58,6 +64,16 @@ spec:
|
|||||||
description: BucketName is the S3 bucket name. Defaults to metadata.name.
|
description: BucketName is the S3 bucket name. Defaults to metadata.name.
|
||||||
Immutable.
|
Immutable.
|
||||||
type: string
|
type: string
|
||||||
|
managePolicy:
|
||||||
|
default: true
|
||||||
|
description: |-
|
||||||
|
ManagePolicy controls whether the operator manages the bucket's S3 policy
|
||||||
|
from BucketAccess grants. When true (the default) the operator reconciles
|
||||||
|
its own statements while preserving any statements it does not own, so it
|
||||||
|
is safe to adopt a bucket that already has a policy. Set to false to leave
|
||||||
|
the bucket policy entirely untouched (BucketAccess grants then have no
|
||||||
|
effect on this bucket).
|
||||||
|
type: boolean
|
||||||
objectLock:
|
objectLock:
|
||||||
description: ObjectLock configures S3 object lock. Enabling it forces
|
description: ObjectLock configures S3 object lock. Enabling it forces
|
||||||
versioning on.
|
versioning on.
|
||||||
@@ -92,9 +108,26 @@ spec:
|
|||||||
with BucketAccess objects.
|
with BucketAccess objects.
|
||||||
type: string
|
type: string
|
||||||
placementTarget:
|
placementTarget:
|
||||||
description: PlacementTarget optionally selects a non-default placement
|
description: |-
|
||||||
target/pool.
|
PlacementTarget optionally selects the RGW placement target that backs the
|
||||||
|
bucket, choosing which pools (and thus replication/erasure profile) store
|
||||||
|
its data. Empty (the default) uses the owning user's default_placement, or
|
||||||
|
the zonegroup default. The valid values are cluster configuration, not a
|
||||||
|
fixed set; on this estate the two configured targets are
|
||||||
|
"default-placement" (3x replicated) and "ec" (4+1 erasure-coded).
|
||||||
|
|
||||||
|
Immutable: RGW chooses the placement at bucket creation (from the S3
|
||||||
|
LocationConstraint) and cannot move an existing bucket between placement
|
||||||
|
targets. Set it on a fresh Bucket; changing it later is rejected, and if a
|
||||||
|
pre-existing bucket is on a different placement the operator reports an
|
||||||
|
error instead of recreating it.
|
||||||
|
maxLength: 63
|
||||||
|
pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$
|
||||||
type: string
|
type: string
|
||||||
|
x-kubernetes-validations:
|
||||||
|
- message: placementTarget is immutable; RGW cannot move a bucket
|
||||||
|
between placement targets
|
||||||
|
rule: self == oldSelf
|
||||||
purgeOnDelete:
|
purgeOnDelete:
|
||||||
description: |-
|
description: |-
|
||||||
PurgeOnDelete deletes the bucket together with all objects it contains
|
PurgeOnDelete deletes the bucket together with all objects it contains
|
||||||
@@ -135,15 +168,26 @@ spec:
|
|||||||
description: Versioning enables S3 object versioning on the bucket.
|
description: Versioning enables S3 object versioning on the bucket.
|
||||||
type: boolean
|
type: boolean
|
||||||
zonegroup:
|
zonegroup:
|
||||||
description: Zonegroup optionally pins the bucket to a specific RGW
|
description: |-
|
||||||
zonegroup.
|
Zonegroup optionally pins the bucket to a specific RGW zonegroup by its
|
||||||
|
api-name. Empty (the default) uses the cluster's local/master zonegroup, so
|
||||||
|
PlacementTarget selection works without naming the zonegroup. Immutable:
|
||||||
|
RGW resolves the zonegroup at bucket creation and cannot move it afterwards.
|
||||||
type: string
|
type: string
|
||||||
|
x-kubernetes-validations:
|
||||||
|
- message: zonegroup is immutable; RGW fixes it at bucket creation
|
||||||
|
rule: self == oldSelf
|
||||||
required:
|
required:
|
||||||
- ownerRef
|
- ownerRef
|
||||||
type: object
|
type: object
|
||||||
status:
|
status:
|
||||||
description: BucketStatus reports observed bucket state.
|
description: BucketStatus reports observed bucket state.
|
||||||
properties:
|
properties:
|
||||||
|
adopted:
|
||||||
|
description: |-
|
||||||
|
Adopted reports that the RGW bucket already existed when the operator
|
||||||
|
first reconciled this resource (it was taken over, not created).
|
||||||
|
type: boolean
|
||||||
bucketID:
|
bucketID:
|
||||||
description: BucketID is the RGW internal bucket instance id.
|
description: BucketID is the RGW internal bucket instance id.
|
||||||
type: string
|
type: string
|
||||||
@@ -218,6 +262,12 @@ spec:
|
|||||||
phase:
|
phase:
|
||||||
description: Phase is a coarse lifecycle summary (Pending/Ready/Error).
|
description: Phase is a coarse lifecycle summary (Pending/Ready/Error).
|
||||||
type: string
|
type: string
|
||||||
|
placementTarget:
|
||||||
|
description: |-
|
||||||
|
PlacementTarget is the placement target RGW actually stores the bucket on,
|
||||||
|
read back from the live bucket. It makes placement drift (a bucket landing
|
||||||
|
on a different target than spec requested) visible.
|
||||||
|
type: string
|
||||||
policyPrincipals:
|
policyPrincipals:
|
||||||
description: |-
|
description: |-
|
||||||
PolicyPrincipals is the number of extra principals granted via
|
PolicyPrincipals is the number of extra principals granted via
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ spec:
|
|||||||
- jsonPath: .status.secretName
|
- jsonPath: .status.secretName
|
||||||
name: Secret
|
name: Secret
|
||||||
type: string
|
type: string
|
||||||
|
- jsonPath: .status.adopted
|
||||||
|
name: Adopted
|
||||||
|
type: boolean
|
||||||
- jsonPath: .status.phase
|
- jsonPath: .status.phase
|
||||||
name: Phase
|
name: Phase
|
||||||
type: string
|
type: string
|
||||||
@@ -90,6 +93,12 @@ spec:
|
|||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
retainOnDelete:
|
||||||
|
description: |-
|
||||||
|
RetainOnDelete keeps the RGW user (and its keys) when the ObjectStoreUser
|
||||||
|
resource is deleted, instead of removing it. Set this before adopting an
|
||||||
|
existing user you may later want to hand back. Defaults to false.
|
||||||
|
type: boolean
|
||||||
secretName:
|
secretName:
|
||||||
description: |-
|
description: |-
|
||||||
SecretName is the Secret the operator writes the access/secret key into.
|
SecretName is the Secret the operator writes the access/secret key into.
|
||||||
@@ -97,8 +106,10 @@ spec:
|
|||||||
AWS_SECRET_ACCESS_KEY, BUCKET_HOST and the RGW uid.
|
AWS_SECRET_ACCESS_KEY, BUCKET_HOST and the RGW uid.
|
||||||
type: string
|
type: string
|
||||||
suspended:
|
suspended:
|
||||||
description: Suspended, when true, suspends the user so its keys stop
|
description: |-
|
||||||
working.
|
Suspended manages the user's suspended state: true suspends the user so
|
||||||
|
its keys stop working, false resumes it. When unset the operator does not
|
||||||
|
touch the suspended state (useful when adopting an existing user).
|
||||||
type: boolean
|
type: boolean
|
||||||
uid:
|
uid:
|
||||||
description: UID is the RGW user id. Defaults to metadata.name. Immutable
|
description: UID is the RGW user id. Defaults to metadata.name. Immutable
|
||||||
@@ -108,6 +119,11 @@ spec:
|
|||||||
status:
|
status:
|
||||||
description: ObjectStoreUserStatus reports observed user state.
|
description: ObjectStoreUserStatus reports observed user state.
|
||||||
properties:
|
properties:
|
||||||
|
adopted:
|
||||||
|
description: |-
|
||||||
|
Adopted reports that the RGW user already existed when the operator first
|
||||||
|
reconciled this resource (it was taken over, not created).
|
||||||
|
type: boolean
|
||||||
conditions:
|
conditions:
|
||||||
items:
|
items:
|
||||||
description: Condition contains details for one aspect of the current
|
description: Condition contains details for one aspect of the current
|
||||||
|
|||||||
+78
-6
@@ -157,6 +157,12 @@ spec:
|
|||||||
- actions
|
- actions
|
||||||
type: object
|
type: object
|
||||||
type: array
|
type: array
|
||||||
|
retainOnDelete:
|
||||||
|
description: |-
|
||||||
|
RetainOnDelete keeps the dedicated RGW user (created when UserRef is empty)
|
||||||
|
instead of deleting it when this BucketAccess is removed. Ignored when
|
||||||
|
UserRef is set (that user is never managed here). Defaults to false.
|
||||||
|
type: boolean
|
||||||
secretName:
|
secretName:
|
||||||
description: |-
|
description: |-
|
||||||
SecretName is the Secret the operator writes credentials into for the
|
SecretName is the Secret the operator writes credentials into for the
|
||||||
@@ -287,9 +293,15 @@ spec:
|
|||||||
- jsonPath: .status.owner
|
- jsonPath: .status.owner
|
||||||
name: Owner
|
name: Owner
|
||||||
type: string
|
type: string
|
||||||
|
- jsonPath: .status.placementTarget
|
||||||
|
name: Placement
|
||||||
|
type: string
|
||||||
- jsonPath: .status.policyPrincipals
|
- jsonPath: .status.policyPrincipals
|
||||||
name: Grants
|
name: Grants
|
||||||
type: integer
|
type: integer
|
||||||
|
- jsonPath: .status.adopted
|
||||||
|
name: Adopted
|
||||||
|
type: boolean
|
||||||
- jsonPath: .status.phase
|
- jsonPath: .status.phase
|
||||||
name: Phase
|
name: Phase
|
||||||
type: string
|
type: string
|
||||||
@@ -322,6 +334,16 @@ spec:
|
|||||||
description: BucketName is the S3 bucket name. Defaults to metadata.name.
|
description: BucketName is the S3 bucket name. Defaults to metadata.name.
|
||||||
Immutable.
|
Immutable.
|
||||||
type: string
|
type: string
|
||||||
|
managePolicy:
|
||||||
|
default: true
|
||||||
|
description: |-
|
||||||
|
ManagePolicy controls whether the operator manages the bucket's S3 policy
|
||||||
|
from BucketAccess grants. When true (the default) the operator reconciles
|
||||||
|
its own statements while preserving any statements it does not own, so it
|
||||||
|
is safe to adopt a bucket that already has a policy. Set to false to leave
|
||||||
|
the bucket policy entirely untouched (BucketAccess grants then have no
|
||||||
|
effect on this bucket).
|
||||||
|
type: boolean
|
||||||
objectLock:
|
objectLock:
|
||||||
description: ObjectLock configures S3 object lock. Enabling it forces
|
description: ObjectLock configures S3 object lock. Enabling it forces
|
||||||
versioning on.
|
versioning on.
|
||||||
@@ -356,9 +378,26 @@ spec:
|
|||||||
with BucketAccess objects.
|
with BucketAccess objects.
|
||||||
type: string
|
type: string
|
||||||
placementTarget:
|
placementTarget:
|
||||||
description: PlacementTarget optionally selects a non-default placement
|
description: |-
|
||||||
target/pool.
|
PlacementTarget optionally selects the RGW placement target that backs the
|
||||||
|
bucket, choosing which pools (and thus replication/erasure profile) store
|
||||||
|
its data. Empty (the default) uses the owning user's default_placement, or
|
||||||
|
the zonegroup default. The valid values are cluster configuration, not a
|
||||||
|
fixed set; on this estate the two configured targets are
|
||||||
|
"default-placement" (3x replicated) and "ec" (4+1 erasure-coded).
|
||||||
|
|
||||||
|
Immutable: RGW chooses the placement at bucket creation (from the S3
|
||||||
|
LocationConstraint) and cannot move an existing bucket between placement
|
||||||
|
targets. Set it on a fresh Bucket; changing it later is rejected, and if a
|
||||||
|
pre-existing bucket is on a different placement the operator reports an
|
||||||
|
error instead of recreating it.
|
||||||
|
maxLength: 63
|
||||||
|
pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$
|
||||||
type: string
|
type: string
|
||||||
|
x-kubernetes-validations:
|
||||||
|
- message: placementTarget is immutable; RGW cannot move a bucket
|
||||||
|
between placement targets
|
||||||
|
rule: self == oldSelf
|
||||||
purgeOnDelete:
|
purgeOnDelete:
|
||||||
description: |-
|
description: |-
|
||||||
PurgeOnDelete deletes the bucket together with all objects it contains
|
PurgeOnDelete deletes the bucket together with all objects it contains
|
||||||
@@ -399,15 +438,26 @@ spec:
|
|||||||
description: Versioning enables S3 object versioning on the bucket.
|
description: Versioning enables S3 object versioning on the bucket.
|
||||||
type: boolean
|
type: boolean
|
||||||
zonegroup:
|
zonegroup:
|
||||||
description: Zonegroup optionally pins the bucket to a specific RGW
|
description: |-
|
||||||
zonegroup.
|
Zonegroup optionally pins the bucket to a specific RGW zonegroup by its
|
||||||
|
api-name. Empty (the default) uses the cluster's local/master zonegroup, so
|
||||||
|
PlacementTarget selection works without naming the zonegroup. Immutable:
|
||||||
|
RGW resolves the zonegroup at bucket creation and cannot move it afterwards.
|
||||||
type: string
|
type: string
|
||||||
|
x-kubernetes-validations:
|
||||||
|
- message: zonegroup is immutable; RGW fixes it at bucket creation
|
||||||
|
rule: self == oldSelf
|
||||||
required:
|
required:
|
||||||
- ownerRef
|
- ownerRef
|
||||||
type: object
|
type: object
|
||||||
status:
|
status:
|
||||||
description: BucketStatus reports observed bucket state.
|
description: BucketStatus reports observed bucket state.
|
||||||
properties:
|
properties:
|
||||||
|
adopted:
|
||||||
|
description: |-
|
||||||
|
Adopted reports that the RGW bucket already existed when the operator
|
||||||
|
first reconciled this resource (it was taken over, not created).
|
||||||
|
type: boolean
|
||||||
bucketID:
|
bucketID:
|
||||||
description: BucketID is the RGW internal bucket instance id.
|
description: BucketID is the RGW internal bucket instance id.
|
||||||
type: string
|
type: string
|
||||||
@@ -482,6 +532,12 @@ spec:
|
|||||||
phase:
|
phase:
|
||||||
description: Phase is a coarse lifecycle summary (Pending/Ready/Error).
|
description: Phase is a coarse lifecycle summary (Pending/Ready/Error).
|
||||||
type: string
|
type: string
|
||||||
|
placementTarget:
|
||||||
|
description: |-
|
||||||
|
PlacementTarget is the placement target RGW actually stores the bucket on,
|
||||||
|
read back from the live bucket. It makes placement drift (a bucket landing
|
||||||
|
on a different target than spec requested) visible.
|
||||||
|
type: string
|
||||||
policyPrincipals:
|
policyPrincipals:
|
||||||
description: |-
|
description: |-
|
||||||
PolicyPrincipals is the number of extra principals granted via
|
PolicyPrincipals is the number of extra principals granted via
|
||||||
@@ -519,6 +575,9 @@ spec:
|
|||||||
- jsonPath: .status.secretName
|
- jsonPath: .status.secretName
|
||||||
name: Secret
|
name: Secret
|
||||||
type: string
|
type: string
|
||||||
|
- jsonPath: .status.adopted
|
||||||
|
name: Adopted
|
||||||
|
type: boolean
|
||||||
- jsonPath: .status.phase
|
- jsonPath: .status.phase
|
||||||
name: Phase
|
name: Phase
|
||||||
type: string
|
type: string
|
||||||
@@ -586,6 +645,12 @@ spec:
|
|||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
retainOnDelete:
|
||||||
|
description: |-
|
||||||
|
RetainOnDelete keeps the RGW user (and its keys) when the ObjectStoreUser
|
||||||
|
resource is deleted, instead of removing it. Set this before adopting an
|
||||||
|
existing user you may later want to hand back. Defaults to false.
|
||||||
|
type: boolean
|
||||||
secretName:
|
secretName:
|
||||||
description: |-
|
description: |-
|
||||||
SecretName is the Secret the operator writes the access/secret key into.
|
SecretName is the Secret the operator writes the access/secret key into.
|
||||||
@@ -593,8 +658,10 @@ spec:
|
|||||||
AWS_SECRET_ACCESS_KEY, BUCKET_HOST and the RGW uid.
|
AWS_SECRET_ACCESS_KEY, BUCKET_HOST and the RGW uid.
|
||||||
type: string
|
type: string
|
||||||
suspended:
|
suspended:
|
||||||
description: Suspended, when true, suspends the user so its keys stop
|
description: |-
|
||||||
working.
|
Suspended manages the user's suspended state: true suspends the user so
|
||||||
|
its keys stop working, false resumes it. When unset the operator does not
|
||||||
|
touch the suspended state (useful when adopting an existing user).
|
||||||
type: boolean
|
type: boolean
|
||||||
uid:
|
uid:
|
||||||
description: UID is the RGW user id. Defaults to metadata.name. Immutable
|
description: UID is the RGW user id. Defaults to metadata.name. Immutable
|
||||||
@@ -604,6 +671,11 @@ spec:
|
|||||||
status:
|
status:
|
||||||
description: ObjectStoreUserStatus reports observed user state.
|
description: ObjectStoreUserStatus reports observed user state.
|
||||||
properties:
|
properties:
|
||||||
|
adopted:
|
||||||
|
description: |-
|
||||||
|
Adopted reports that the RGW user already existed when the operator first
|
||||||
|
reconciled this resource (it was taken over, not created).
|
||||||
|
type: boolean
|
||||||
conditions:
|
conditions:
|
||||||
items:
|
items:
|
||||||
description: Condition contains details for one aspect of the current
|
description: Condition contains details for one aspect of the current
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ rules:
|
|||||||
- patch
|
- patch
|
||||||
- update
|
- update
|
||||||
- watch
|
- watch
|
||||||
|
- apiGroups:
|
||||||
|
- apiextensions.k8s.io
|
||||||
|
resources:
|
||||||
|
- customresourcedefinitions
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
|
- list
|
||||||
- apiGroups:
|
- apiGroups:
|
||||||
- ceph.unkin.net
|
- ceph.unkin.net
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Adopting an existing radosgw user + bucket. The operator takes them over in
|
||||||
|
# place: no recreation, existing keys reused, existing bucket policy preserved.
|
||||||
|
# retainOnDelete keeps the RGW objects if these CRDs are later deleted.
|
||||||
|
# See docs/adoption.md.
|
||||||
|
apiVersion: ceph.unkin.net/v1alpha1
|
||||||
|
kind: ObjectStoreUser
|
||||||
|
metadata:
|
||||||
|
name: legacy-owner
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
# uid must match the existing RGW user id.
|
||||||
|
uid: legacy-owner
|
||||||
|
# Set maxBuckets to the existing user's limit (it otherwise defaults to 1000
|
||||||
|
# and would be applied). Leave displayName/suspended unset to keep them as-is.
|
||||||
|
maxBuckets: 1000
|
||||||
|
retainOnDelete: true
|
||||||
|
---
|
||||||
|
apiVersion: ceph.unkin.net/v1alpha1
|
||||||
|
kind: Bucket
|
||||||
|
metadata:
|
||||||
|
name: legacy-data
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
# bucketName must match the existing bucket.
|
||||||
|
bucketName: legacy-data
|
||||||
|
ownerRef: legacy-owner
|
||||||
|
retainOnDelete: true
|
||||||
|
# managePolicy defaults to true: the operator merges its BucketAccess grants
|
||||||
|
# into the existing policy, preserving statements it does not own. Set it to
|
||||||
|
# false to leave the bucket policy entirely under manual control.
|
||||||
|
managePolicy: true
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# A bucket placed on the erasure-coded (4+1) placement target instead of the
|
||||||
|
# default 3x-replicated pool. Good for bulk/archival data where capacity matters
|
||||||
|
# more than the extra replica.
|
||||||
|
#
|
||||||
|
# placementTarget is immutable: RGW chooses the placement at bucket creation and
|
||||||
|
# cannot move an existing bucket between targets, so it can only be set on a
|
||||||
|
# fresh Bucket. The operator reports the live placement in status.placementTarget.
|
||||||
|
apiVersion: ceph.unkin.net/v1alpha1
|
||||||
|
kind: Bucket
|
||||||
|
metadata:
|
||||||
|
name: raw-archive
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
bucketName: raw-archive
|
||||||
|
ownerRef: app-owner
|
||||||
|
# Cluster-configured placement target. On this estate: "default-placement"
|
||||||
|
# (3x replicated) or "ec" (4+1 erasure-coded).
|
||||||
|
placementTarget: ec
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Adopting existing radosgw buckets and users
|
||||||
|
|
||||||
|
The operator can **take over** buckets and users that already exist in radosgw:
|
||||||
|
write CRDs that match them and it manages them in place instead of recreating
|
||||||
|
them. It can also **hand them back** without deleting the underlying RGW objects.
|
||||||
|
|
||||||
|
## What happens when you create a CRD for an existing resource
|
||||||
|
|
||||||
|
| CRD | On adoption |
|
||||||
|
|-----|-------------|
|
||||||
|
| `ObjectStoreUser` | The user is **not** recreated and its **existing keys are reused** (never rotated). `status.adopted` becomes `true`. Attributes are only changed if the spec sets them (see below). |
|
||||||
|
| `Bucket` | The bucket is **not** recreated. `status.adopted` becomes `true`. Versioning/tags/quota are only touched if the spec sets them. The policy is **merged**, not overwritten (see below). |
|
||||||
|
| `BucketAccess` | Adds the grant's statement to the bucket policy and (for a dedicated user) reuses that user's keys. |
|
||||||
|
|
||||||
|
`status.adopted` is also shown in `kubectl get osu` / `kubectl get bkt` under
|
||||||
|
the **ADOPTED** column.
|
||||||
|
|
||||||
|
### User attributes
|
||||||
|
|
||||||
|
To avoid clobbering an adopted user, the operator only sends attributes you set:
|
||||||
|
|
||||||
|
- `displayName` — left unchanged when empty.
|
||||||
|
- `suspended` — left unchanged when unset (it is an optional `*bool`; set it
|
||||||
|
explicitly to `true`/`false` to manage it).
|
||||||
|
- `email` — left unchanged when empty.
|
||||||
|
- `maxBuckets` — **defaults to `1000`** and is always applied. If the existing
|
||||||
|
user has a different limit you want to keep, set `maxBuckets` to match (or to
|
||||||
|
the value you want).
|
||||||
|
|
||||||
|
### Bucket policy is merged, not replaced
|
||||||
|
|
||||||
|
The operator owns only the policy statements it writes — they carry a
|
||||||
|
`cephrgwop…` statement id. On every reconcile it **preserves statements it does
|
||||||
|
not own** and reconciles only its own. So adopting a bucket that already has a
|
||||||
|
hand-written policy keeps that policy; your `BucketAccess` grants are added
|
||||||
|
alongside it.
|
||||||
|
|
||||||
|
- Reserve the `cephrgwop` prefix (and the legacy prefixes `full`, `custom`,
|
||||||
|
`raw`, `readonly*`, `readwrite*`) for the operator — do not name your own
|
||||||
|
statements with them, or they will be treated as operator-owned and replaced.
|
||||||
|
- To have the operator **never touch** a bucket's policy, set
|
||||||
|
`spec.managePolicy: false`. `BucketAccess` grants then have no effect on that
|
||||||
|
bucket.
|
||||||
|
|
||||||
|
## What happens when you delete the CRD
|
||||||
|
|
||||||
|
By default, deleting a CRD deletes the underlying RGW resource. Opt out per
|
||||||
|
resource to **orphan** it instead (the finalizer is dropped, the RGW object is
|
||||||
|
left in place):
|
||||||
|
|
||||||
|
| CRD | Default on delete | Keep the RGW object |
|
||||||
|
|-----|-------------------|---------------------|
|
||||||
|
| `ObjectStoreUser` | deletes the RGW user + keys | `spec.retainOnDelete: true` |
|
||||||
|
| `Bucket` | deletes the (empty) bucket | `spec.retainOnDelete: true` (and never set `purgeOnDelete`) |
|
||||||
|
| `BucketAccess` (dedicated user) | deletes the dedicated user | `spec.retainOnDelete: true` |
|
||||||
|
| `BucketAccess` (`userRef`) | only drops the policy statement | n/a (never manages that user) |
|
||||||
|
|
||||||
|
## Recommended adoption procedure
|
||||||
|
|
||||||
|
1. Create an `ObjectStoreUser` for each owner, setting `retainOnDelete: true`
|
||||||
|
and `maxBuckets` to the value you want. Confirm `ADOPTED=true`.
|
||||||
|
2. Create the `Bucket` (with `retainOnDelete: true`) referencing that owner.
|
||||||
|
With `managePolicy: true` (default) the existing policy is preserved.
|
||||||
|
3. Optionally add `BucketAccess` objects to model existing grants; they merge
|
||||||
|
into the policy. If you would rather keep managing the policy by hand, set
|
||||||
|
`managePolicy: false` on the Bucket.
|
||||||
|
4. To hand a resource back, delete its CRD — with `retainOnDelete: true` the RGW
|
||||||
|
bucket/user is left untouched.
|
||||||
@@ -9,6 +9,7 @@ require (
|
|||||||
github.com/aws/smithy-go v1.27.4
|
github.com/aws/smithy-go v1.27.4
|
||||||
github.com/ceph/go-ceph v0.40.0
|
github.com/ceph/go-ceph v0.40.0
|
||||||
k8s.io/api v0.34.4
|
k8s.io/api v0.34.4
|
||||||
|
k8s.io/apiextensions-apiserver v0.34.1
|
||||||
k8s.io/apimachinery v0.34.4
|
k8s.io/apimachinery v0.34.4
|
||||||
k8s.io/client-go v0.34.4
|
k8s.io/client-go v0.34.4
|
||||||
sigs.k8s.io/controller-runtime v0.22.4
|
sigs.k8s.io/controller-runtime v0.22.4
|
||||||
@@ -70,7 +71,6 @@ require (
|
|||||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
k8s.io/apiextensions-apiserver v0.34.1 // indirect
|
|
||||||
k8s.io/klog/v2 v2.130.1 // indirect
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
||||||
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ type BucketInfo struct {
|
|||||||
Bid string
|
Bid string
|
||||||
ID string
|
ID string
|
||||||
Owner 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.
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// CreateBucket provisions a bucket owned by spec.OwnerUID. The Admin Ops API
|
||||||
@@ -125,6 +136,26 @@ func (c *Client) SetBucketPolicy(ctx context.Context, name, bucketID, ownerUID,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetBucketPolicy returns the bucket's current S3 policy JSON, or "" when it has
|
||||||
|
// none. It is signed as the bucket owner.
|
||||||
|
func (c *Client) GetBucketPolicy(ctx context.Context, name, ownerUID string) (string, error) {
|
||||||
|
owner, err := c.asOwner(ctx, ownerUID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
out, err := c.s3.GetBucketPolicy(ctx, &s3.GetBucketPolicyInput{Bucket: aws.String(name)}, owner)
|
||||||
|
if err != nil {
|
||||||
|
if IsNotFound(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if out.Policy == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return *out.Policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetBucketTags replaces the bucket tag set. tagsJSON is the JSON produced by
|
// SetBucketTags replaces the bucket tag set. tagsJSON is the JSON produced by
|
||||||
// BuildTagJSON (a list of {"Key","Value"} objects). bucketID is unused (kept for
|
// BuildTagJSON (a list of {"Key","Value"} objects). bucketID is unused (kept for
|
||||||
// call-site stability).
|
// call-site stability).
|
||||||
@@ -191,8 +222,14 @@ func (c *Client) setObjectLockDefault(ctx context.Context, owner func(*s3.Option
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// locationConstraint renders the RGW LocationConstraint from a zonegroup and
|
// locationConstraint renders the RGW S3 CreateBucket LocationConstraint from a
|
||||||
// placement target ("<zonegroup>:<placement>"), or "" for default placement.
|
// 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 {
|
func locationConstraint(zonegroup, placement string) string {
|
||||||
loc := zonegroup
|
loc := zonegroup
|
||||||
if placement != "" {
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
-12
@@ -2,6 +2,7 @@ package ceph
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,6 +16,19 @@ const (
|
|||||||
LevelFull = "full"
|
LevelFull = "full"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// managedSidPrefix marks statement ids the operator owns, so it can reconcile
|
||||||
|
// its own statements while preserving foreign ones when adopting a bucket that
|
||||||
|
// already has a policy. Do not reuse this prefix (or the legacy prefixes below)
|
||||||
|
// for statements you manage yourself.
|
||||||
|
const managedSidPrefix = "cephrgwop"
|
||||||
|
|
||||||
|
// legacyManagedSidPrefixes are the statement-id prefixes the operator emitted
|
||||||
|
// before managedSidPrefix existed; they are still recognised as operator-owned
|
||||||
|
// so upgrading does not duplicate statements.
|
||||||
|
var legacyManagedSidPrefixes = []string{
|
||||||
|
"readonlybkt", "readonlyobj", "readwritebkt", "readwriteobj", "full", "custom", "raw",
|
||||||
|
}
|
||||||
|
|
||||||
// GrantConditions restricts when a grant's statements apply. The zero value adds
|
// GrantConditions restricts when a grant's statements apply. The zero value adds
|
||||||
// no conditions.
|
// no conditions.
|
||||||
type GrantConditions struct {
|
type GrantConditions struct {
|
||||||
@@ -103,10 +117,98 @@ var objectActions = map[string][]string{
|
|||||||
// principal its requested access. It returns "" when there are no grants so the
|
// principal its requested access. It returns "" when there are no grants so the
|
||||||
// caller can clear the policy.
|
// caller can clear the policy.
|
||||||
func BuildBucketPolicy(bucket string, grants []Grant) (string, error) {
|
func BuildBucketPolicy(bucket string, grants []Grant) (string, error) {
|
||||||
if len(grants) == 0 {
|
statements := buildStatements(bucket, grants)
|
||||||
|
if len(statements) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(policyDocument{Version: "2012-10-17", Statement: statements})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeBucketPolicy renders the operator's statements for grants and merges them
|
||||||
|
// into an existing policy, preserving any statement the operator does not own
|
||||||
|
// (identified by its Sid). It returns "" only when the merged policy would be
|
||||||
|
// empty, so a bucket adopted with a hand-written policy keeps that policy.
|
||||||
|
func MergeBucketPolicy(existing, bucket string, grants []Grant) (string, error) {
|
||||||
|
version := "2012-10-17"
|
||||||
|
var id string
|
||||||
|
var foreign []json.RawMessage
|
||||||
|
|
||||||
|
if strings.TrimSpace(existing) != "" {
|
||||||
|
var doc struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
ID string `json:"Id,omitempty"`
|
||||||
|
Statement []json.RawMessage `json:"Statement"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(existing), &doc); err != nil {
|
||||||
|
return "", fmt.Errorf("parse existing bucket policy: %w", err)
|
||||||
|
}
|
||||||
|
if doc.Version != "" {
|
||||||
|
version = doc.Version
|
||||||
|
}
|
||||||
|
id = doc.ID
|
||||||
|
for _, raw := range doc.Statement {
|
||||||
|
var meta struct {
|
||||||
|
Sid string `json:"Sid"`
|
||||||
|
}
|
||||||
|
// Ignore unmarshal errors: a statement we cannot read the Sid of is
|
||||||
|
// treated as foreign and preserved verbatim.
|
||||||
|
_ = json.Unmarshal(raw, &meta)
|
||||||
|
if isManagedSid(meta.Sid) {
|
||||||
|
continue // operator-owned; re-rendered below
|
||||||
|
}
|
||||||
|
foreign = append(foreign, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
managed := buildStatements(bucket, grants)
|
||||||
|
if len(foreign) == 0 && len(managed) == 0 {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statements := make([]json.RawMessage, 0, len(foreign)+len(managed))
|
||||||
|
statements = append(statements, foreign...)
|
||||||
|
for _, st := range managed {
|
||||||
|
b, err := json.Marshal(st)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
statements = append(statements, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.Marshal(struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
ID string `json:"Id,omitempty"`
|
||||||
|
Statement []json.RawMessage `json:"Statement"`
|
||||||
|
}{Version: version, ID: id, Statement: statements})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isManagedSid reports whether a statement id was emitted by the operator.
|
||||||
|
func isManagedSid(s string) bool {
|
||||||
|
if strings.HasPrefix(s, managedSidPrefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, p := range legacyManagedSidPrefixes {
|
||||||
|
if strings.HasPrefix(s, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildStatements renders the operator's statements for grants, sorted for
|
||||||
|
// deterministic output.
|
||||||
|
func buildStatements(bucket string, grants []Grant) []policyStatement {
|
||||||
|
if len(grants) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
sorted := make([]Grant, len(grants))
|
sorted := make([]Grant, len(grants))
|
||||||
copy(sorted, grants)
|
copy(sorted, grants)
|
||||||
sort.Slice(sorted, func(i, j int) bool {
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
@@ -117,17 +219,11 @@ func BuildBucketPolicy(bucket string, grants []Grant) (string, error) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bucketARN := "arn:aws:s3:::" + bucket
|
bucketARN := "arn:aws:s3:::" + bucket
|
||||||
|
var statements []policyStatement
|
||||||
doc := policyDocument{Version: "2012-10-17"}
|
|
||||||
for _, g := range sorted {
|
for _, g := range sorted {
|
||||||
doc.Statement = append(doc.Statement, statementsForGrant(bucketARN, g)...)
|
statements = append(statements, statementsForGrant(bucketARN, g)...)
|
||||||
}
|
}
|
||||||
|
return statements
|
||||||
b, err := json.Marshal(doc)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(b), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// statementsForGrant renders the policy statements for a single grant.
|
// statementsForGrant renders the policy statements for a single grant.
|
||||||
@@ -138,7 +234,9 @@ func statementsForGrant(bucketARN string, g Grant) []policyStatement {
|
|||||||
out := make([]policyStatement, 0, len(g.Raw))
|
out := make([]policyStatement, 0, len(g.Raw))
|
||||||
for i, rs := range g.Raw {
|
for i, rs := range g.Raw {
|
||||||
st := policyStatement{
|
st := policyStatement{
|
||||||
Sid: firstNonEmpty(rs.Sid, sid("raw", g.UID)+strconv.Itoa(i)),
|
// Always operator-owned so a merge re-renders (not duplicates)
|
||||||
|
// it; any user-supplied Sid is folded into the managed id.
|
||||||
|
Sid: sid(firstNonEmpty(rs.Sid, "raw"+strconv.Itoa(i)), g.UID),
|
||||||
Effect: firstNonEmpty(rs.Effect, "Allow"),
|
Effect: firstNonEmpty(rs.Effect, "Allow"),
|
||||||
Principal: principal,
|
Principal: principal,
|
||||||
Action: rs.Actions,
|
Action: rs.Actions,
|
||||||
@@ -257,9 +355,12 @@ func buildCondition(c *GrantConditions) map[string]map[string][]string {
|
|||||||
return cond
|
return cond
|
||||||
}
|
}
|
||||||
|
|
||||||
// sid builds a policy statement id that only contains characters S3 accepts.
|
// sid builds a policy statement id that only contains characters S3 accepts,
|
||||||
|
// prefixed with managedSidPrefix so the operator can recognise its own
|
||||||
|
// statements when merging into an adopted bucket's policy.
|
||||||
func sid(prefix, uid string) string {
|
func sid(prefix, uid string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
b.WriteString(managedSidPrefix)
|
||||||
b.WriteString(strings.ReplaceAll(prefix, "-", ""))
|
b.WriteString(strings.ReplaceAll(prefix, "-", ""))
|
||||||
for _, r := range uid {
|
for _, r := range uid {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -223,6 +223,124 @@ func TestBuildBucketPolicyRawStatements(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeBucketPolicyPreservesForeign(t *testing.T) {
|
||||||
|
// An existing policy with a foreign statement (unknown Sid, and a scalar
|
||||||
|
// condition value S3 allows but our typed struct does not model).
|
||||||
|
existing := `{"Version":"2012-10-17","Statement":[` +
|
||||||
|
`{"Sid":"AllowPublicRead","Effect":"Allow","Principal":"*","Action":["s3:GetObject"],` +
|
||||||
|
`"Resource":"arn:aws:s3:::data/public/*","Condition":{"Bool":{"aws:SecureTransport":"true"}}}]}`
|
||||||
|
|
||||||
|
merged, err := MergeBucketPolicy(existing, "data", []Grant{{UID: "reader", Level: LevelReadOnly}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
doc := parsePolicyRaw(t, merged)
|
||||||
|
|
||||||
|
var sawForeign, sawManaged bool
|
||||||
|
for _, raw := range doc.Statement {
|
||||||
|
var s struct {
|
||||||
|
Sid string `json:"Sid"`
|
||||||
|
Condition map[string]map[string]any
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &s); err != nil {
|
||||||
|
t.Fatalf("statement not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if s.Sid == "AllowPublicRead" {
|
||||||
|
sawForeign = true
|
||||||
|
// The scalar condition value must survive verbatim.
|
||||||
|
if v := s.Condition["Bool"]["aws:SecureTransport"]; v != "true" {
|
||||||
|
t.Fatalf("foreign scalar condition mangled: %v", s.Condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isManagedSid(s.Sid) {
|
||||||
|
sawManaged = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawForeign {
|
||||||
|
t.Fatal("foreign statement was dropped")
|
||||||
|
}
|
||||||
|
if !sawManaged {
|
||||||
|
t.Fatal("operator statement missing from merge")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeBucketPolicyReplacesManaged(t *testing.T) {
|
||||||
|
// Two rounds: an existing policy already carrying the operator's statements
|
||||||
|
// must not accumulate duplicates when re-merged.
|
||||||
|
first, err := MergeBucketPolicy("", "data", []Grant{{UID: "reader", Level: LevelReadOnly}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
second, err := MergeBucketPolicy(first, "data", []Grant{{UID: "reader", Level: LevelReadOnly}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if first != second {
|
||||||
|
t.Fatalf("re-merging its own policy was not idempotent:\n first=%s\nsecond=%s", first, second)
|
||||||
|
}
|
||||||
|
// A legacy (unprefixed) operator statement must also be recognised and
|
||||||
|
// replaced rather than preserved as foreign.
|
||||||
|
legacy := `{"Version":"2012-10-17","Statement":[` +
|
||||||
|
`{"Sid":"readonlybktreader","Effect":"Allow","Principal":{"AWS":["arn:aws:iam:::user/reader"]},` +
|
||||||
|
`"Action":["s3:ListBucket"],"Resource":["arn:aws:s3:::data"]}]}`
|
||||||
|
merged, err := MergeBucketPolicy(legacy, "data", []Grant{{UID: "reader", Level: LevelReadOnly}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
for _, raw := range parsePolicyRaw(t, merged).Statement {
|
||||||
|
var s struct {
|
||||||
|
Sid string `json:"Sid"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &s)
|
||||||
|
if s.Sid == "readonlybktreader" {
|
||||||
|
t.Fatal("legacy operator statement was preserved instead of replaced")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeBucketPolicyForeignOnlyKept(t *testing.T) {
|
||||||
|
existing := `{"Version":"2012-10-17","Statement":[` +
|
||||||
|
`{"Sid":"AllowPublicRead","Effect":"Allow","Principal":"*","Action":["s3:GetObject"],` +
|
||||||
|
`"Resource":"arn:aws:s3:::data/*"}]}`
|
||||||
|
// No grants: the operator adds nothing but must not wipe the foreign policy.
|
||||||
|
merged, err := MergeBucketPolicy(existing, "data", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if merged == "" {
|
||||||
|
t.Fatal("merge cleared a policy that had a foreign statement")
|
||||||
|
}
|
||||||
|
if len(parsePolicyRaw(t, merged).Statement) != 1 {
|
||||||
|
t.Fatalf("expected the single foreign statement, got %s", merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeBucketPolicyEmpty(t *testing.T) {
|
||||||
|
merged, err := MergeBucketPolicy("", "data", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if merged != "" {
|
||||||
|
t.Fatalf("expected empty policy, got %q", merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePolicyRaw parses a policy keeping statements as raw JSON.
|
||||||
|
func parsePolicyRaw(t *testing.T, raw string) struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
Statement []json.RawMessage `json:"Statement"`
|
||||||
|
} {
|
||||||
|
t.Helper()
|
||||||
|
var doc struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
Statement []json.RawMessage `json:"Statement"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
|
||||||
|
t.Fatalf("policy is not valid JSON: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildBucketPolicyFineGrainedDeterministic(t *testing.T) {
|
func TestBuildBucketPolicyFineGrainedDeterministic(t *testing.T) {
|
||||||
grants := []Grant{
|
grants := []Grant{
|
||||||
{UID: "reader", Level: LevelReadOnly, Paths: []string{"a/", "b/"}},
|
{UID: "reader", Level: LevelReadOnly, Paths: []string{"a/", "b/"}},
|
||||||
|
|||||||
+24
-13
@@ -31,13 +31,15 @@ func (u *User) S3Key() (UserKey, bool) {
|
|||||||
return u.Keys[0], true
|
return u.Keys[0], true
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserSpec describes the desired state of an RGW user.
|
// UserSpec describes the desired state of an RGW user. A nil DisplayName/
|
||||||
|
// Suspended (empty string / nil pointer) leaves that attribute untouched on an
|
||||||
|
// existing user, so an adopted user is not mutated unless the fields are set.
|
||||||
type UserSpec struct {
|
type UserSpec struct {
|
||||||
UID string
|
UID string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
Email string
|
Email string
|
||||||
MaxBuckets *int32
|
MaxBuckets *int32
|
||||||
Suspended bool
|
Suspended *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// fromAdminUser converts a go-ceph admin.User into the subset the operator uses.
|
// fromAdminUser converts a go-ceph admin.User into the subset the operator uses.
|
||||||
@@ -73,7 +75,7 @@ func (c *Client) CreateUser(ctx context.Context, spec UserSpec) (*User, error) {
|
|||||||
DisplayName: firstNonEmpty(spec.DisplayName, spec.UID),
|
DisplayName: firstNonEmpty(spec.DisplayName, spec.UID),
|
||||||
Email: spec.Email,
|
Email: spec.Email,
|
||||||
MaxBuckets: int32PtrToIntPtr(spec.MaxBuckets),
|
MaxBuckets: int32PtrToIntPtr(spec.MaxBuckets),
|
||||||
Suspended: boolToIntPtr(spec.Suspended),
|
Suspended: boolPtrToIntPtr(spec.Suspended),
|
||||||
GenerateKey: boolPtr(true),
|
GenerateKey: boolPtr(true),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -83,15 +85,20 @@ func (c *Client) CreateUser(ctx context.Context, spec UserSpec) (*User, error) {
|
|||||||
return fromAdminUser(u), nil
|
return fromAdminUser(u), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUser reconciles the mutable attributes of an existing RGW user.
|
// UpdateUser reconciles the mutable attributes of an existing RGW user. It only
|
||||||
|
// sends attributes the spec sets: an empty DisplayName or nil Suspended is left
|
||||||
|
// as-is, so reconciling (or adopting) a user does not clobber those fields.
|
||||||
func (c *Client) UpdateUser(ctx context.Context, spec UserSpec) (*User, error) {
|
func (c *Client) UpdateUser(ctx context.Context, spec UserSpec) (*User, error) {
|
||||||
u, err := c.admin.ModifyUser(ctx, admin.User{
|
req := admin.User{
|
||||||
ID: spec.UID,
|
ID: spec.UID,
|
||||||
DisplayName: firstNonEmpty(spec.DisplayName, spec.UID),
|
Email: spec.Email,
|
||||||
Email: spec.Email,
|
MaxBuckets: int32PtrToIntPtr(spec.MaxBuckets),
|
||||||
MaxBuckets: int32PtrToIntPtr(spec.MaxBuckets),
|
Suspended: boolPtrToIntPtr(spec.Suspended),
|
||||||
Suspended: boolToIntPtr(spec.Suspended),
|
}
|
||||||
})
|
if spec.DisplayName != "" {
|
||||||
|
req.DisplayName = spec.DisplayName
|
||||||
|
}
|
||||||
|
u, err := c.admin.ModifyUser(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -156,9 +163,13 @@ func int32PtrToIntPtr(p *int32) *int {
|
|||||||
return &v
|
return &v
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolToIntPtr(b bool) *int {
|
// boolPtrToIntPtr renders an optional bool as RGW's 0/1 int, or nil to omit.
|
||||||
|
func boolPtrToIntPtr(b *bool) *int {
|
||||||
|
if b == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
v := 0
|
v := 0
|
||||||
if b {
|
if *b {
|
||||||
v = 1
|
v = 1
|
||||||
}
|
}
|
||||||
return &v
|
return &v
|
||||||
|
|||||||
@@ -77,7 +77,11 @@ func (r *BucketReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
|
|||||||
}
|
}
|
||||||
ownerUID := owner.Status.UID
|
ownerUID := owner.Status.UID
|
||||||
|
|
||||||
// Ensure the bucket exists.
|
// Ensure the bucket exists. Record adoption once: whether the RGW bucket
|
||||||
|
// already existed the first time we reconciled this resource. status.BucketID
|
||||||
|
// is only set on a successful reconcile, so a Pending wait on the owner (or a
|
||||||
|
// transient failure) does not pollute the signal.
|
||||||
|
firstObserve := b.Status.BucketID == ""
|
||||||
info, err := r.Ceph.GetBucket(ctx, bucketName)
|
info, err := r.Ceph.GetBucket(ctx, bucketName)
|
||||||
if ceph.IsNotFound(err) {
|
if ceph.IsNotFound(err) {
|
||||||
createSpec := ceph.CreateBucketSpec{
|
createSpec := ceph.CreateBucketSpec{
|
||||||
@@ -97,11 +101,29 @@ func (r *BucketReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
|
|||||||
return r.fail(ctx, &b, "CreateFailed", err)
|
return r.fail(ctx, &b, "CreateFailed", err)
|
||||||
}
|
}
|
||||||
logger.Info("created bucket", "bucket", bucketName, "owner", ownerUID)
|
logger.Info("created bucket", "bucket", bucketName, "owner", ownerUID)
|
||||||
|
if firstObserve {
|
||||||
|
b.Status.Adopted = false
|
||||||
|
}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return r.fail(ctx, &b, "LookupFailed", err)
|
return r.fail(ctx, &b, "LookupFailed", err)
|
||||||
|
} else if firstObserve {
|
||||||
|
b.Status.Adopted = true
|
||||||
|
logger.Info("adopted existing bucket", "bucket", bucketName, "owner", ownerUID)
|
||||||
}
|
}
|
||||||
bucketID := info.InstanceID()
|
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).
|
// Versioning (forced on when object lock is enabled).
|
||||||
if b.Spec.Versioning || (b.Spec.ObjectLock != nil && b.Spec.ObjectLock.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 {
|
if err := r.Ceph.SetBucketVersioning(ctx, bucketName, bucketID, ownerUID, true); err != nil {
|
||||||
@@ -129,23 +151,35 @@ func (r *BucketReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render and apply the aggregate S3 policy from all BucketAccess grants.
|
// Render and apply the aggregate S3 policy from all BucketAccess grants,
|
||||||
grants, principals, err := r.collectGrants(ctx, b.Namespace, b.Name)
|
// unless the bucket opts out of policy management. The merge preserves any
|
||||||
if err != nil {
|
// statements the operator does not own, so an adopted bucket keeps its
|
||||||
return r.fail(ctx, &b, "GrantsFailed", err)
|
// existing policy.
|
||||||
}
|
principals := 0
|
||||||
policy, err := ceph.BuildBucketPolicy(bucketName, grants)
|
if managePolicy(&b) {
|
||||||
if err != nil {
|
grants, p, err := r.collectGrants(ctx, b.Namespace, b.Name)
|
||||||
return r.fail(ctx, &b, "PolicyBuildFailed", err)
|
if err != nil {
|
||||||
}
|
return r.fail(ctx, &b, "GrantsFailed", err)
|
||||||
if err := r.Ceph.SetBucketPolicy(ctx, bucketName, bucketID, ownerUID, policy); err != nil {
|
}
|
||||||
return r.fail(ctx, &b, "PolicyFailed", err)
|
existing, err := r.Ceph.GetBucketPolicy(ctx, bucketName, ownerUID)
|
||||||
|
if err != nil {
|
||||||
|
return r.fail(ctx, &b, "PolicyReadFailed", err)
|
||||||
|
}
|
||||||
|
policy, err := ceph.MergeBucketPolicy(existing, bucketName, grants)
|
||||||
|
if err != nil {
|
||||||
|
return r.fail(ctx, &b, "PolicyBuildFailed", err)
|
||||||
|
}
|
||||||
|
if err := r.Ceph.SetBucketPolicy(ctx, bucketName, bucketID, ownerUID, policy); err != nil {
|
||||||
|
return r.fail(ctx, &b, "PolicyFailed", err)
|
||||||
|
}
|
||||||
|
principals = p
|
||||||
}
|
}
|
||||||
|
|
||||||
b.Status.Phase = "Ready"
|
b.Status.Phase = "Ready"
|
||||||
b.Status.BucketName = bucketName
|
b.Status.BucketName = bucketName
|
||||||
b.Status.BucketID = bucketID
|
b.Status.BucketID = bucketID
|
||||||
b.Status.Owner = ownerUID
|
b.Status.Owner = ownerUID
|
||||||
|
b.Status.PlacementTarget = info.PlacementRule
|
||||||
b.Status.PolicyPrincipals = int32(principals)
|
b.Status.PolicyPrincipals = int32(principals)
|
||||||
b.Status.ObservedGeneration = b.Generation
|
b.Status.ObservedGeneration = b.Generation
|
||||||
setReady(&b.Status.Conditions, b.Generation, true, "Provisioned", "bucket provisioned")
|
setReady(&b.Status.Conditions, b.Generation, true, "Provisioned", "bucket provisioned")
|
||||||
@@ -223,6 +257,23 @@ func grantKey(g ceph.Grant) string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// managePolicy reports whether the operator should reconcile this bucket's S3
|
||||||
|
// policy. A nil ManagePolicy (the CRD default) is treated as true.
|
||||||
|
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) {
|
func (r *BucketReconciler) pending(ctx context.Context, b *v1alpha1.Bucket, reason, msg string) (ctrl.Result, error) {
|
||||||
b.Status.Phase = "Pending"
|
b.Status.Phase = "Pending"
|
||||||
b.Status.ObservedGeneration = b.Generation
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,8 +49,9 @@ func (r *BucketAccessReconciler) Reconcile(ctx context.Context, req ctrl.Request
|
|||||||
|
|
||||||
if !ba.DeletionTimestamp.IsZero() {
|
if !ba.DeletionTimestamp.IsZero() {
|
||||||
if controllerutil.ContainsFinalizer(&ba, finalizer) {
|
if controllerutil.ContainsFinalizer(&ba, finalizer) {
|
||||||
// Only delete a user the operator created for this grant.
|
// Only delete a user the operator created for this grant, and only
|
||||||
if managed && uid != "" {
|
// when the grant does not ask to retain it.
|
||||||
|
if managed && uid != "" && !ba.Spec.RetainOnDelete {
|
||||||
if err := r.Ceph.DeleteUser(ctx, uid); err != nil {
|
if err := r.Ceph.DeleteUser(ctx, uid); err != nil {
|
||||||
return r.fail(ctx, &ba, "DeleteFailed", err)
|
return r.fail(ctx, &ba, "DeleteFailed", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||||
|
apiextensionsclient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The CRD version check reads CustomResourceDefinitions to compare the
|
||||||
|
// installed schema against what this operator expects.
|
||||||
|
//
|
||||||
|
// +kubebuilder:rbac:groups=apiextensions.k8s.io,resources=customresourcedefinitions,verbs=get;list
|
||||||
|
|
||||||
|
// crdSentinel names a CRD and a spec property that only exists in the schema
|
||||||
|
// version shipped alongside this operator build. If the installed CRD lacks the
|
||||||
|
// sentinel, its schema predates this operator and strict decoding of new spec
|
||||||
|
// fields will silently fail. Keep this list in one place so new fields are easy
|
||||||
|
// to register as sentinels.
|
||||||
|
type crdSentinel struct {
|
||||||
|
// crd is the metadata.name of the CustomResourceDefinition.
|
||||||
|
crd string
|
||||||
|
// specProperty is a key expected under
|
||||||
|
// .spec.versions[].schema.openAPIV3Schema.properties.spec.properties.
|
||||||
|
specProperty string
|
||||||
|
}
|
||||||
|
|
||||||
|
// crdSentinels is the authoritative list checked at startup. Extend it whenever
|
||||||
|
// a new spec field is added that older CRDs would reject.
|
||||||
|
var crdSentinels = []crdSentinel{
|
||||||
|
{crd: "buckets.ceph.unkin.net", specProperty: "managePolicy"},
|
||||||
|
{crd: "objectstoreusers.ceph.unkin.net", specProperty: "retainOnDelete"},
|
||||||
|
{crd: "bucketaccesses.ceph.unkin.net", specProperty: "rawStatements"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckCRDVersions verifies that every CRD this operator owns is installed and
|
||||||
|
// carries the schema fields this build expects. It is advisory only: it logs a
|
||||||
|
// distinct WARNING per problem and never returns an error or exits, so a stale
|
||||||
|
// or missing CRD cannot block startup.
|
||||||
|
func CheckCRDVersions(ctx context.Context, cfg *rest.Config) {
|
||||||
|
log := ctrl.Log.WithName("crd-version-check")
|
||||||
|
|
||||||
|
client, err := apiextensionsclient.NewForConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err, "unable to build apiextensions client; skipping CRD version check")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range crdSentinels {
|
||||||
|
crd, err := client.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, s.crd, metav1.GetOptions{})
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
log.Info("WARNING: CRD is not installed — apply the CRDs matching this operator version",
|
||||||
|
"crd", s.crd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Error(err, "unable to read CRD; cannot verify it matches this operator version",
|
||||||
|
"crd", s.crd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !crdHasSpecProperty(crd, s.specProperty) {
|
||||||
|
log.Info("WARNING: CRD is out of date — apply the CRDs matching this operator version",
|
||||||
|
"crd", s.crd, "missingField", "spec."+s.specProperty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// crdHasSpecProperty reports whether any served/stored version of the CRD
|
||||||
|
// declares the given property under spec.
|
||||||
|
func crdHasSpecProperty(crd *apiextensionsv1.CustomResourceDefinition, property string) bool {
|
||||||
|
for _, v := range crd.Spec.Versions {
|
||||||
|
schema := v.Schema
|
||||||
|
if schema == nil || schema.OpenAPIV3Schema == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
specSchema, ok := schema.OpenAPIV3Schema.Properties["spec"]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := specSchema.Properties[property]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -40,7 +40,9 @@ func (r *ObjectStoreUserReconciler) Reconcile(ctx context.Context, req ctrl.Requ
|
|||||||
|
|
||||||
if !osu.DeletionTimestamp.IsZero() {
|
if !osu.DeletionTimestamp.IsZero() {
|
||||||
if controllerutil.ContainsFinalizer(&osu, finalizer) {
|
if controllerutil.ContainsFinalizer(&osu, finalizer) {
|
||||||
if err := r.Ceph.DeleteUser(ctx, uid); err != nil {
|
if osu.Spec.RetainOnDelete {
|
||||||
|
logger.Info("retaining RGW user on delete", "uid", uid)
|
||||||
|
} else if err := r.Ceph.DeleteUser(ctx, uid); err != nil {
|
||||||
return r.fail(ctx, &osu, "DeleteFailed", err)
|
return r.fail(ctx, &osu, "DeleteFailed", err)
|
||||||
}
|
}
|
||||||
controllerutil.RemoveFinalizer(&osu, finalizer)
|
controllerutil.RemoveFinalizer(&osu, finalizer)
|
||||||
@@ -65,17 +67,31 @@ func (r *ObjectStoreUserReconciler) Reconcile(ctx context.Context, req ctrl.Requ
|
|||||||
Suspended: osu.Spec.Suspended,
|
Suspended: osu.Spec.Suspended,
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := r.Ceph.GetUser(ctx, uid); ceph.IsNotFound(err) {
|
// Record adoption once: whether the RGW user already existed the first time
|
||||||
|
// we reconciled this resource (taken over rather than created). status.UID is
|
||||||
|
// only set on a successful reconcile, so it is a clean "never provisioned"
|
||||||
|
// signal that transient failures do not pollute.
|
||||||
|
firstObserve := osu.Status.UID == ""
|
||||||
|
_, getErr := r.Ceph.GetUser(ctx, uid)
|
||||||
|
switch {
|
||||||
|
case ceph.IsNotFound(getErr):
|
||||||
if _, err := r.Ceph.CreateUser(ctx, spec); err != nil {
|
if _, err := r.Ceph.CreateUser(ctx, spec); err != nil {
|
||||||
return r.fail(ctx, &osu, "CreateFailed", err)
|
return r.fail(ctx, &osu, "CreateFailed", err)
|
||||||
}
|
}
|
||||||
logger.Info("created RGW user", "uid", uid)
|
logger.Info("created RGW user", "uid", uid)
|
||||||
} else if err != nil {
|
if firstObserve {
|
||||||
return r.fail(ctx, &osu, "LookupFailed", err)
|
osu.Status.Adopted = false
|
||||||
} else {
|
}
|
||||||
|
case getErr != nil:
|
||||||
|
return r.fail(ctx, &osu, "LookupFailed", getErr)
|
||||||
|
default:
|
||||||
if _, err := r.Ceph.UpdateUser(ctx, spec); err != nil {
|
if _, err := r.Ceph.UpdateUser(ctx, spec); err != nil {
|
||||||
return r.fail(ctx, &osu, "UpdateFailed", err)
|
return r.fail(ctx, &osu, "UpdateFailed", err)
|
||||||
}
|
}
|
||||||
|
if firstObserve {
|
||||||
|
osu.Status.Adopted = true
|
||||||
|
logger.Info("adopted existing RGW user", "uid", uid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if q := osu.Spec.Quota; q != nil {
|
if q := osu.Spec.Quota; q != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user