From c4ec7faee573b1e9e9eda0763fe1f68ec52b2670 Mon Sep 17 00:00:00 2001 From: Santosh Date: Fri, 6 Feb 2026 17:40:39 +0530 Subject: [PATCH] rgw: controller for account management PR only focuses on account creation, deletion and update. Signed-off-by: Santosh --- .github/workflows/golangci-lint.yaml | 5 + .../integration-test-helm-suite.yaml | 6 + .../integration-test-keystone-auth-suite.yaml | 6 + .../integration-test-multi-cluster-suite.yaml | 6 + .../integration-test-object-suite.yaml | 6 + .../integration-test-smoke-suite.yaml | 6 + .../integration-test-upgrade-suite.yaml | 6 + .../integration-tests-on-release.yaml | 5 + .golangci.yaml | 6 + .vscode/settings.json | 5 +- Documentation/CRDs/specification.md | 161 ++ Makefile | 5 +- .../rook-ceph/templates/clusterrole.yaml | 3 + .../charts/rook-ceph/templates/resources.yaml | 100 ++ deploy/examples/common.yaml | 3 + deploy/examples/crds.yaml | 99 ++ deploy/examples/object-account.yaml | 18 + pkg/apis/ceph.rook.io/v1/register.go | 2 + pkg/apis/ceph.rook.io/v1/types.go | 63 + .../ceph.rook.io/v1/zz_generated.deepcopy.go | 102 ++ .../ceph.rook.io/v1/ceph.rook.io_client.go | 5 + .../ceph.rook.io/v1/cephobjectstoreaccount.go | 68 + .../v1/fake/fake_ceph.rook.io_client.go | 4 + .../v1/fake/fake_cephobjectstoreaccount.go | 52 + .../ceph.rook.io/v1/generated_expansion.go | 2 + .../ceph.rook.io/v1/cephobjectstoreaccount.go | 102 ++ .../ceph.rook.io/v1/interface.go | 7 + .../informers/externalversions/generic.go | 2 + .../ceph.rook.io/v1/cephobjectstoreaccount.go | 70 + .../ceph.rook.io/v1/expansion_generated.go | 8 + pkg/operator/ceph/cr_manager.go | 2 + pkg/operator/ceph/object/account.go | 81 + .../ceph/object/account/controller.go | 492 +++++++ .../ceph/object/account/controller_test.go | 1298 +++++++++++++++++ pkg/operator/ceph/object/admin.go | 2 +- pkg/operator/ceph/object/objectstore.go | 69 + pkg/operator/ceph/object/user/controller.go | 74 +- 37 files changed, 2876 insertions(+), 75 deletions(-) create mode 100644 deploy/examples/object-account.yaml create mode 100644 pkg/client/clientset/versioned/typed/ceph.rook.io/v1/cephobjectstoreaccount.go create mode 100644 pkg/client/clientset/versioned/typed/ceph.rook.io/v1/fake/fake_cephobjectstoreaccount.go create mode 100644 pkg/client/informers/externalversions/ceph.rook.io/v1/cephobjectstoreaccount.go create mode 100644 pkg/client/listers/ceph.rook.io/v1/cephobjectstoreaccount.go create mode 100644 pkg/operator/ceph/object/account.go create mode 100644 pkg/operator/ceph/object/account/controller.go create mode 100644 pkg/operator/ceph/object/account/controller_test.go diff --git a/.github/workflows/golangci-lint.yaml b/.github/workflows/golangci-lint.yaml index 070b3b39b..bc5abf9ff 100644 --- a/.github/workflows/golangci-lint.yaml +++ b/.github/workflows/golangci-lint.yaml @@ -47,6 +47,11 @@ jobs: check-latest: true - name: govulncheck uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 + env: + # Required for go-ceph rgw/admin account APIs which are gated behind + # the ceph_preview build tag. Can be removed when go-ceph promotes + # the account API out of preview. + GOFLAGS: "-tags=ceph_preview" kube-api-lint: name: kube-api-lint diff --git a/.github/workflows/integration-test-helm-suite.yaml b/.github/workflows/integration-test-helm-suite.yaml index b5986cbe5..d36814eb5 100644 --- a/.github/workflows/integration-test-helm-suite.yaml +++ b/.github/workflows/integration-test-helm-suite.yaml @@ -21,6 +21,12 @@ concurrency: permissions: contents: read +env: + # Required for go-ceph rgw/admin account APIs which are gated behind + # the ceph_preview build tag. Can be removed when go-ceph promotes + # the account API out of preview. + GOFLAGS: "-tags=ceph_preview" + jobs: TestCephHelmSuite: if: ${{ github.event_name == 'pull_request' && github.ref != 'refs/heads/master' && !contains(github.event.pull_request.labels.*.name, 'skip-ci') }} diff --git a/.github/workflows/integration-test-keystone-auth-suite.yaml b/.github/workflows/integration-test-keystone-auth-suite.yaml index a90b53bbc..88fe5c319 100644 --- a/.github/workflows/integration-test-keystone-auth-suite.yaml +++ b/.github/workflows/integration-test-keystone-auth-suite.yaml @@ -16,6 +16,12 @@ defaults: permissions: contents: read +env: + # Required for go-ceph rgw/admin account APIs which are gated behind + # the ceph_preview build tag. Can be removed when go-ceph promotes + # the account API out of preview. + GOFLAGS: "-tags=ceph_preview" + # cancel the in-progress workflow when PR is refreshed. concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} diff --git a/.github/workflows/integration-test-multi-cluster-suite.yaml b/.github/workflows/integration-test-multi-cluster-suite.yaml index a6de93b99..a1c43f459 100644 --- a/.github/workflows/integration-test-multi-cluster-suite.yaml +++ b/.github/workflows/integration-test-multi-cluster-suite.yaml @@ -21,6 +21,12 @@ concurrency: permissions: contents: read +env: + # Required for go-ceph rgw/admin account APIs which are gated behind + # the ceph_preview build tag. Can be removed when go-ceph promotes + # the account API out of preview. + GOFLAGS: "-tags=ceph_preview" + jobs: TestCephMultiClusterDeploySuite: if: ${{ github.event_name == 'pull_request' && github.ref != 'refs/heads/master' && !contains(github.event.pull_request.labels.*.name, 'skip-ci') }} diff --git a/.github/workflows/integration-test-object-suite.yaml b/.github/workflows/integration-test-object-suite.yaml index 52d0d2466..5e1e32548 100644 --- a/.github/workflows/integration-test-object-suite.yaml +++ b/.github/workflows/integration-test-object-suite.yaml @@ -21,6 +21,12 @@ concurrency: permissions: contents: read +env: + # Required for go-ceph rgw/admin account APIs which are gated behind + # the ceph_preview build tag. Can be removed when go-ceph promotes + # the account API out of preview. + GOFLAGS: "-tags=ceph_preview" + jobs: TestCephObjectSuite: if: ${{ github.event_name == 'pull_request' && github.ref != 'refs/heads/master' && !contains(github.event.pull_request.labels.*.name, 'skip-ci') }} diff --git a/.github/workflows/integration-test-smoke-suite.yaml b/.github/workflows/integration-test-smoke-suite.yaml index 75a472527..54d8f761f 100644 --- a/.github/workflows/integration-test-smoke-suite.yaml +++ b/.github/workflows/integration-test-smoke-suite.yaml @@ -21,6 +21,12 @@ concurrency: permissions: contents: read +env: + # Required for go-ceph rgw/admin account APIs which are gated behind + # the ceph_preview build tag. Can be removed when go-ceph promotes + # the account API out of preview. + GOFLAGS: "-tags=ceph_preview" + jobs: TestCephSmokeSuite: if: ${{ github.event_name == 'pull_request' && github.ref != 'refs/heads/master' && !contains(github.event.pull_request.labels.*.name, 'skip-ci') }} diff --git a/.github/workflows/integration-test-upgrade-suite.yaml b/.github/workflows/integration-test-upgrade-suite.yaml index 176132d68..534407cf7 100644 --- a/.github/workflows/integration-test-upgrade-suite.yaml +++ b/.github/workflows/integration-test-upgrade-suite.yaml @@ -21,6 +21,12 @@ concurrency: permissions: contents: read +env: + # Required for go-ceph rgw/admin account APIs which are gated behind + # the ceph_preview build tag. Can be removed when go-ceph promotes + # the account API out of preview. + GOFLAGS: "-tags=ceph_preview" + jobs: TestCephUpgradeSuite: if: ${{ github.event_name == 'pull_request' && github.ref != 'refs/heads/master' && !contains(github.event.pull_request.labels.*.name, 'skip-ci') }} diff --git a/.github/workflows/integration-tests-on-release.yaml b/.github/workflows/integration-tests-on-release.yaml index ed39805c3..23385ddb9 100644 --- a/.github/workflows/integration-tests-on-release.yaml +++ b/.github/workflows/integration-tests-on-release.yaml @@ -213,6 +213,11 @@ jobs: TestCephObjectSuite: runs-on: ubuntu-22.04 + env: + # Required for go-ceph rgw/admin account APIs which are gated behind + # the ceph_preview build tag. Can be removed when go-ceph promotes + # the account API out of preview. + GOFLAGS: "-tags=ceph_preview" strategy: fail-fast: false matrix: diff --git a/.golangci.yaml b/.golangci.yaml index 41cf330f9..1d26c2d0a 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,5 +1,11 @@ --- version: "2" +run: + build-tags: + # Required for go-ceph rgw/admin account APIs which are gated behind + # the ceph_preview build tag. Can be removed when go-ceph promotes + # the account API out of preview. + - ceph_preview linters: default: none enable: diff --git a/.vscode/settings.json b/.vscode/settings.json index 892946ca2..b6cc97833 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,6 +26,9 @@ "!Documentation/Helm-Charts", ], "gopls": { - "formatting.gofumpt": true + "formatting.gofumpt": true, + "buildFlags": [ + "-tags=ceph_preview" + ] } } diff --git a/Documentation/CRDs/specification.md b/Documentation/CRDs/specification.md index d22daa969..a518eae08 100644 --- a/Documentation/CRDs/specification.md +++ b/Documentation/CRDs/specification.md @@ -4648,6 +4648,60 @@ Allow any string so that over-specified legacy clusters do not break on CRD upda +

CephObjectStoreAccount +

+
+

CephObjectStoreAccount represent the RGW user account

+
+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+metadata
+ + +Kubernetes meta/v1.ObjectMeta + + +
+Refer to the Kubernetes API documentation for the fields of the +metadata field. +
+spec,omitzero
+ + +ObjectStoreAccountSpec + + +
+
+status,omitzero
+ + +ObjectStoreAccountStatus + + +
+(Optional) +

CephStatus

@@ -11623,6 +11677,113 @@ If spec.sharedPools are also empty, then RGW pools (spec.dataPool and spec.metad

+

ObjectStoreAccountSpec +

+

+(Appears on:CephObjectStoreAccount) +

+
+

ObjectStoreAccountSpec represent the spec of a RGW Account

+
+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+store
+ +string + +
+

Store is the CephObjectStore the account will be associated with

+
+name
+ +string + +
+(Optional) +

Name is the desired display name of the RGW account if different from the CephObjectStoreAccount CR name.

+
+accountID
+ +string + +
+(Optional) +

AccountID uniquely identifies an account and resource ownership. Format should be RGW followed by 17 digits +(e.g., RGW00889737169837717). If not specified, the ID will be automatically generated.

+
+

ObjectStoreAccountStatus +

+

+(Appears on:CephObjectStoreAccount) +

+
+

ObjectStoreAccountStatus represents the status of a CephObjectStoreAccount resource

+
+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+phase
+ +string + +
+(Optional) +
+accountID
+ +string + +
+(Optional) +

AccountID associated with the RGW user account

+
+observedGeneration
+ +int64 + +
+(Optional) +

ObservedGeneration is the latest generation observed by the controller.

+

ObjectStoreHostingSpec

diff --git a/Makefile b/Makefile index a432507df..e1e2081bc 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,10 @@ SHELL := /usr/bin/env bash # Can be used or additional go build flags BUILDFLAGS ?= LDFLAGS ?= -TAGS ?= +# Required for go-ceph rgw/admin account APIs which are gated behind +# the ceph_preview build tag. Can be removed when go-ceph promotes +# the account API out of preview. +TAGS ?= ceph_preview # turn on more verbose build V ?= 0 diff --git a/deploy/charts/rook-ceph/templates/clusterrole.yaml b/deploy/charts/rook-ceph/templates/clusterrole.yaml index b6def8b83..7a0356938 100644 --- a/deploy/charts/rook-ceph/templates/clusterrole.yaml +++ b/deploy/charts/rook-ceph/templates/clusterrole.yaml @@ -149,6 +149,7 @@ rules: - cephnvmeofgateways - cephobjectstores - cephobjectstoreusers + - cephobjectstoreaccounts - cephobjectrealms - cephobjectzonegroups - cephobjectzones @@ -176,6 +177,7 @@ rules: - cephnvmeofgateways/status - cephobjectstores/status - cephobjectstoreusers/status + - cephobjectstoreaccounts/status - cephobjectrealms/status - cephobjectzonegroups/status - cephobjectzones/status @@ -200,6 +202,7 @@ rules: - cephnvmeofgateways/finalizers - cephobjectstores/finalizers - cephobjectstoreusers/finalizers + - cephobjectstoreaccounts/finalizers - cephobjectrealms/finalizers - cephobjectzonegroups/finalizers - cephobjectzones/finalizers diff --git a/deploy/charts/rook-ceph/templates/resources.yaml b/deploy/charts/rook-ceph/templates/resources.yaml index aac9a8ee8..7da6e86b8 100644 --- a/deploy/charts/rook-ceph/templates/resources.yaml +++ b/deploy/charts/rook-ceph/templates/resources.yaml @@ -12565,6 +12565,106 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + helm.sh/resource-policy: keep + name: cephobjectstoreaccounts.ceph.rook.io +spec: + group: ceph.rook.io + names: + kind: CephObjectStoreAccount + listKind: CephObjectStoreAccountList + plural: cephobjectstoreaccounts + singular: cephobjectstoreaccount + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: CephObjectStoreAccount represent the RGW user account + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ObjectStoreAccountSpec represent the spec of a RGW Account + properties: + accountID: + description: |- + AccountID uniquely identifies an account and resource ownership. Format should be RGW followed by 17 digits + (e.g., RGW00889737169837717). If not specified, the ID will be automatically generated. + maxLength: 20 + minLength: 20 + pattern: ^RGW\d{17}$ + type: string + x-kubernetes-validations: + - message: accountID is immutable + rule: self == oldSelf + name: + description: Name is the desired display name of the RGW account if different from the CephObjectStoreAccount CR name. + maxLength: 2048 + minLength: 1 + pattern: ^[a-zA-Z0-9 ._-]+$ + type: string + store: + description: Store is the CephObjectStore the account will be associated with + maxLength: 253 + minLength: 1 + type: string + x-kubernetes-validations: + - message: store is immutable + rule: self == oldSelf + required: + - store + type: object + status: + description: ObjectStoreAccountStatus represents the status of a CephObjectStoreAccount resource + properties: + accountID: + description: AccountID associated with the RGW user account + maxLength: 20 + minLength: 20 + type: string + observedGeneration: + description: ObservedGeneration is the latest generation observed by the controller. + format: int64 + type: integer + phase: + type: string + type: object + required: + - metadata + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 diff --git a/deploy/examples/common.yaml b/deploy/examples/common.yaml index e81315f02..d3a6b2415 100644 --- a/deploy/examples/common.yaml +++ b/deploy/examples/common.yaml @@ -379,6 +379,7 @@ rules: - cephnvmeofgateways - cephobjectstores - cephobjectstoreusers + - cephobjectstoreaccounts - cephobjectrealms - cephobjectzonegroups - cephobjectzones @@ -406,6 +407,7 @@ rules: - cephnvmeofgateways/status - cephobjectstores/status - cephobjectstoreusers/status + - cephobjectstoreaccounts/status - cephobjectrealms/status - cephobjectzonegroups/status - cephobjectzones/status @@ -430,6 +432,7 @@ rules: - cephnvmeofgateways/finalizers - cephobjectstores/finalizers - cephobjectstoreusers/finalizers + - cephobjectstoreaccounts/finalizers - cephobjectrealms/finalizers - cephobjectzonegroups/finalizers - cephobjectzones/finalizers diff --git a/deploy/examples/crds.yaml b/deploy/examples/crds.yaml index 42f56a6bd..41e910bcb 100644 --- a/deploy/examples/crds.yaml +++ b/deploy/examples/crds.yaml @@ -12556,6 +12556,105 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: cephobjectstoreaccounts.ceph.rook.io +spec: + group: ceph.rook.io + names: + kind: CephObjectStoreAccount + listKind: CephObjectStoreAccountList + plural: cephobjectstoreaccounts + singular: cephobjectstoreaccount + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: CephObjectStoreAccount represent the RGW user account + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: ObjectStoreAccountSpec represent the spec of a RGW Account + properties: + accountID: + description: |- + AccountID uniquely identifies an account and resource ownership. Format should be RGW followed by 17 digits + (e.g., RGW00889737169837717). If not specified, the ID will be automatically generated. + maxLength: 20 + minLength: 20 + pattern: ^RGW\d{17}$ + type: string + x-kubernetes-validations: + - message: accountID is immutable + rule: self == oldSelf + name: + description: Name is the desired display name of the RGW account if different from the CephObjectStoreAccount CR name. + maxLength: 2048 + minLength: 1 + pattern: ^[a-zA-Z0-9 ._-]+$ + type: string + store: + description: Store is the CephObjectStore the account will be associated with + maxLength: 253 + minLength: 1 + type: string + x-kubernetes-validations: + - message: store is immutable + rule: self == oldSelf + required: + - store + type: object + status: + description: ObjectStoreAccountStatus represents the status of a CephObjectStoreAccount resource + properties: + accountID: + description: AccountID associated with the RGW user account + maxLength: 20 + minLength: 20 + type: string + observedGeneration: + description: ObservedGeneration is the latest generation observed by the controller. + format: int64 + type: integer + phase: + type: string + type: object + required: + - metadata + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 diff --git a/deploy/examples/object-account.yaml b/deploy/examples/object-account.yaml new file mode 100644 index 000000000..1973f6e23 --- /dev/null +++ b/deploy/examples/object-account.yaml @@ -0,0 +1,18 @@ +################################################################################################################# +# Create an object store account to manage RGW resources. +# kubectl create -f object-account.yaml +################################################################################################################# + +apiVersion: ceph.rook.io/v1 +kind: CephObjectStoreAccount +metadata: + name: my-account + namespace: rook-ceph # namespace:cluster +spec: + # The object store in which the account will be created + store: my-store + # An optional name for the RGW account if different from the CephObjectStoreAccount CR name + # name: "my-rgw-account" + # An optional unique account ID. Format: RGW followed by 17 digits (e.g., RGW00889737169837717). + # If not specified, Ceph will auto-generate the account ID. + # accountID: "RGW00889737169837717" diff --git a/pkg/apis/ceph.rook.io/v1/register.go b/pkg/apis/ceph.rook.io/v1/register.go index 488e85ffd..d5895c03f 100644 --- a/pkg/apis/ceph.rook.io/v1/register.go +++ b/pkg/apis/ceph.rook.io/v1/register.go @@ -71,6 +71,8 @@ func addKnownTypes(scheme *runtime.Scheme) error { &CephObjectStoreList{}, &CephObjectStoreUser{}, &CephObjectStoreUserList{}, + &CephObjectStoreAccount{}, + &CephObjectStoreAccountList{}, &CephObjectRealm{}, &CephObjectRealmList{}, &CephObjectZoneGroup{}, diff --git a/pkg/apis/ceph.rook.io/v1/types.go b/pkg/apis/ceph.rook.io/v1/types.go index affb98aa9..3ab6fea96 100755 --- a/pkg/apis/ceph.rook.io/v1/types.go +++ b/pkg/apis/ceph.rook.io/v1/types.go @@ -2731,6 +2731,69 @@ type RGWServiceSpec struct { Annotations Annotations `json:"annotations,omitempty"` } +// +genclient +// +genclient:noStatus +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` +// +kubebuilder:subresource:status +// CephObjectStoreAccount represent the RGW user account +type CephObjectStoreAccount struct { + metav1.TypeMeta `json:",inline"` + // +required + metav1.ObjectMeta `json:"metadata"` + // +required + Spec ObjectStoreAccountSpec `json:"spec,omitzero"` + // +optional + Status *ObjectStoreAccountStatus `json:"status,omitzero"` //nolint:kubeapilinter // MinProperties cannot be applied to a struct pointer field +} + +// ObjectStoreAccountSpec represent the spec of a RGW Account +type ObjectStoreAccountSpec struct { + // Store is the CephObjectStore the account will be associated with + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:XValidation:message="store is immutable",rule="self == oldSelf" + Store string `json:"store,omitempty"` + // Name is the desired display name of the RGW account if different from the CephObjectStoreAccount CR name. + // +optional + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=2048 + // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9 ._-]+$` + Name string `json:"name,omitempty"` + // AccountID uniquely identifies an account and resource ownership. Format should be RGW followed by 17 digits + // (e.g., RGW00889737169837717). If not specified, the ID will be automatically generated. + // +optional + // +kubebuilder:validation:MinLength=20 + // +kubebuilder:validation:MaxLength=20 + // +kubebuilder:validation:Pattern=`^RGW\d{17}$` + // +kubebuilder:validation:XValidation:message="accountID is immutable",rule="self == oldSelf" + AccountID string `json:"accountID,omitempty"` +} + +// ObjectStoreAccountStatus represents the status of a CephObjectStoreAccount resource +type ObjectStoreAccountStatus struct { + // +optional + Phase string `json:"phase,omitempty"` //nolint:kubeapilinter // Conditions are preferred over Phase + // +optional + // AccountID associated with the RGW user account + // +kubebuilder:validation:MinLength=20 + // +kubebuilder:validation:MaxLength=20 + AccountID string `json:"accountID,omitempty"` + // ObservedGeneration is the latest generation observed by the controller. + // +optional + ObservedGeneration *int64 `json:"observedGeneration,omitempty"` +} + +// CephObjectStoreAccountList represents the Ceph object store accounts +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type CephObjectStoreAccountList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []CephObjectStoreAccount `json:"items"` +} + // +genclient // +genclient:noStatus // +kubebuilder:resource:shortName=nfs,path=cephnfses diff --git a/pkg/apis/ceph.rook.io/v1/zz_generated.deepcopy.go b/pkg/apis/ceph.rook.io/v1/zz_generated.deepcopy.go index e615f19a1..14e1bdc0d 100644 --- a/pkg/apis/ceph.rook.io/v1/zz_generated.deepcopy.go +++ b/pkg/apis/ceph.rook.io/v1/zz_generated.deepcopy.go @@ -1621,6 +1621,71 @@ func (in *CephObjectStore) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CephObjectStoreAccount) DeepCopyInto(out *CephObjectStoreAccount) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + if in.Status != nil { + in, out := &in.Status, &out.Status + *out = new(ObjectStoreAccountStatus) + (*in).DeepCopyInto(*out) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CephObjectStoreAccount. +func (in *CephObjectStoreAccount) DeepCopy() *CephObjectStoreAccount { + if in == nil { + return nil + } + out := new(CephObjectStoreAccount) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CephObjectStoreAccount) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CephObjectStoreAccountList) DeepCopyInto(out *CephObjectStoreAccountList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]CephObjectStoreAccount, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CephObjectStoreAccountList. +func (in *CephObjectStoreAccountList) DeepCopy() *CephObjectStoreAccountList { + if in == nil { + return nil + } + out := new(CephObjectStoreAccountList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CephObjectStoreAccountList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CephObjectStoreList) DeepCopyInto(out *CephObjectStoreList) { *out = *in @@ -4252,6 +4317,43 @@ func (in *ObjectSharedPoolsSpec) DeepCopy() *ObjectSharedPoolsSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObjectStoreAccountSpec) DeepCopyInto(out *ObjectStoreAccountSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObjectStoreAccountSpec. +func (in *ObjectStoreAccountSpec) DeepCopy() *ObjectStoreAccountSpec { + if in == nil { + return nil + } + out := new(ObjectStoreAccountSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObjectStoreAccountStatus) DeepCopyInto(out *ObjectStoreAccountStatus) { + *out = *in + if in.ObservedGeneration != nil { + in, out := &in.ObservedGeneration, &out.ObservedGeneration + *out = new(int64) + **out = **in + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObjectStoreAccountStatus. +func (in *ObjectStoreAccountStatus) DeepCopy() *ObjectStoreAccountStatus { + if in == nil { + return nil + } + out := new(ObjectStoreAccountStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ObjectStoreHostingSpec) DeepCopyInto(out *ObjectStoreHostingSpec) { *out = *in diff --git a/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/ceph.rook.io_client.go b/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/ceph.rook.io_client.go index dc7c2faa4..39dc6213c 100644 --- a/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/ceph.rook.io_client.go +++ b/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/ceph.rook.io_client.go @@ -42,6 +42,7 @@ type CephV1Interface interface { CephNVMeOFGatewaysGetter CephObjectRealmsGetter CephObjectStoresGetter + CephObjectStoreAccountsGetter CephObjectStoreUsersGetter CephObjectZonesGetter CephObjectZoneGroupsGetter @@ -109,6 +110,10 @@ func (c *CephV1Client) CephObjectStores(namespace string) CephObjectStoreInterfa return newCephObjectStores(c, namespace) } +func (c *CephV1Client) CephObjectStoreAccounts(namespace string) CephObjectStoreAccountInterface { + return newCephObjectStoreAccounts(c, namespace) +} + func (c *CephV1Client) CephObjectStoreUsers(namespace string) CephObjectStoreUserInterface { return newCephObjectStoreUsers(c, namespace) } diff --git a/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/cephobjectstoreaccount.go b/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/cephobjectstoreaccount.go new file mode 100644 index 000000000..859c34bd2 --- /dev/null +++ b/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/cephobjectstoreaccount.go @@ -0,0 +1,68 @@ +/* +Copyright 2018 The Rook Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +    http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1 + +import ( + context "context" + + cephrookiov1 "github.com/rook/rook/pkg/apis/ceph.rook.io/v1" + scheme "github.com/rook/rook/pkg/client/clientset/versioned/scheme" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// CephObjectStoreAccountsGetter has a method to return a CephObjectStoreAccountInterface. +// A group's client should implement this interface. +type CephObjectStoreAccountsGetter interface { + CephObjectStoreAccounts(namespace string) CephObjectStoreAccountInterface +} + +// CephObjectStoreAccountInterface has methods to work with CephObjectStoreAccount resources. +type CephObjectStoreAccountInterface interface { + Create(ctx context.Context, cephObjectStoreAccount *cephrookiov1.CephObjectStoreAccount, opts metav1.CreateOptions) (*cephrookiov1.CephObjectStoreAccount, error) + Update(ctx context.Context, cephObjectStoreAccount *cephrookiov1.CephObjectStoreAccount, opts metav1.UpdateOptions) (*cephrookiov1.CephObjectStoreAccount, error) + Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error + Get(ctx context.Context, name string, opts metav1.GetOptions) (*cephrookiov1.CephObjectStoreAccount, error) + List(ctx context.Context, opts metav1.ListOptions) (*cephrookiov1.CephObjectStoreAccountList, error) + Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (result *cephrookiov1.CephObjectStoreAccount, err error) + CephObjectStoreAccountExpansion +} + +// cephObjectStoreAccounts implements CephObjectStoreAccountInterface +type cephObjectStoreAccounts struct { + *gentype.ClientWithList[*cephrookiov1.CephObjectStoreAccount, *cephrookiov1.CephObjectStoreAccountList] +} + +// newCephObjectStoreAccounts returns a CephObjectStoreAccounts +func newCephObjectStoreAccounts(c *CephV1Client, namespace string) *cephObjectStoreAccounts { + return &cephObjectStoreAccounts{ + gentype.NewClientWithList[*cephrookiov1.CephObjectStoreAccount, *cephrookiov1.CephObjectStoreAccountList]( + "cephobjectstoreaccounts", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *cephrookiov1.CephObjectStoreAccount { return &cephrookiov1.CephObjectStoreAccount{} }, + func() *cephrookiov1.CephObjectStoreAccountList { return &cephrookiov1.CephObjectStoreAccountList{} }, + ), + } +} diff --git a/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/fake/fake_ceph.rook.io_client.go b/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/fake/fake_ceph.rook.io_client.go index 5986773f2..33eb1e2b8 100644 --- a/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/fake/fake_ceph.rook.io_client.go +++ b/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/fake/fake_ceph.rook.io_client.go @@ -84,6 +84,10 @@ func (c *FakeCephV1) CephObjectStores(namespace string) v1.CephObjectStoreInterf return newFakeCephObjectStores(c, namespace) } +func (c *FakeCephV1) CephObjectStoreAccounts(namespace string) v1.CephObjectStoreAccountInterface { + return newFakeCephObjectStoreAccounts(c, namespace) +} + func (c *FakeCephV1) CephObjectStoreUsers(namespace string) v1.CephObjectStoreUserInterface { return newFakeCephObjectStoreUsers(c, namespace) } diff --git a/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/fake/fake_cephobjectstoreaccount.go b/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/fake/fake_cephobjectstoreaccount.go new file mode 100644 index 000000000..e5dfc0243 --- /dev/null +++ b/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/fake/fake_cephobjectstoreaccount.go @@ -0,0 +1,52 @@ +/* +Copyright 2018 The Rook Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +    http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1 "github.com/rook/rook/pkg/apis/ceph.rook.io/v1" + cephrookiov1 "github.com/rook/rook/pkg/client/clientset/versioned/typed/ceph.rook.io/v1" + gentype "k8s.io/client-go/gentype" +) + +// fakeCephObjectStoreAccounts implements CephObjectStoreAccountInterface +type fakeCephObjectStoreAccounts struct { + *gentype.FakeClientWithList[*v1.CephObjectStoreAccount, *v1.CephObjectStoreAccountList] + Fake *FakeCephV1 +} + +func newFakeCephObjectStoreAccounts(fake *FakeCephV1, namespace string) cephrookiov1.CephObjectStoreAccountInterface { + return &fakeCephObjectStoreAccounts{ + gentype.NewFakeClientWithList[*v1.CephObjectStoreAccount, *v1.CephObjectStoreAccountList]( + fake.Fake, + namespace, + v1.SchemeGroupVersion.WithResource("cephobjectstoreaccounts"), + v1.SchemeGroupVersion.WithKind("CephObjectStoreAccount"), + func() *v1.CephObjectStoreAccount { return &v1.CephObjectStoreAccount{} }, + func() *v1.CephObjectStoreAccountList { return &v1.CephObjectStoreAccountList{} }, + func(dst, src *v1.CephObjectStoreAccountList) { dst.ListMeta = src.ListMeta }, + func(list *v1.CephObjectStoreAccountList) []*v1.CephObjectStoreAccount { + return gentype.ToPointerSlice(list.Items) + }, + func(list *v1.CephObjectStoreAccountList, items []*v1.CephObjectStoreAccount) { + list.Items = gentype.FromPointerSlice(items) + }, + ), + fake, + } +} diff --git a/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/generated_expansion.go b/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/generated_expansion.go index cbd8ec6c4..43bd80201 100644 --- a/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/generated_expansion.go +++ b/pkg/client/clientset/versioned/typed/ceph.rook.io/v1/generated_expansion.go @@ -46,6 +46,8 @@ type CephObjectRealmExpansion interface{} type CephObjectStoreExpansion interface{} +type CephObjectStoreAccountExpansion interface{} + type CephObjectStoreUserExpansion interface{} type CephObjectZoneExpansion interface{} diff --git a/pkg/client/informers/externalversions/ceph.rook.io/v1/cephobjectstoreaccount.go b/pkg/client/informers/externalversions/ceph.rook.io/v1/cephobjectstoreaccount.go new file mode 100644 index 000000000..aa7707a0f --- /dev/null +++ b/pkg/client/informers/externalversions/ceph.rook.io/v1/cephobjectstoreaccount.go @@ -0,0 +1,102 @@ +/* +Copyright 2018 The Rook Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +    http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1 + +import ( + context "context" + time "time" + + apiscephrookiov1 "github.com/rook/rook/pkg/apis/ceph.rook.io/v1" + versioned "github.com/rook/rook/pkg/client/clientset/versioned" + internalinterfaces "github.com/rook/rook/pkg/client/informers/externalversions/internalinterfaces" + cephrookiov1 "github.com/rook/rook/pkg/client/listers/ceph.rook.io/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// CephObjectStoreAccountInformer provides access to a shared informer and lister for +// CephObjectStoreAccounts. +type CephObjectStoreAccountInformer interface { + Informer() cache.SharedIndexInformer + Lister() cephrookiov1.CephObjectStoreAccountLister +} + +type cephObjectStoreAccountInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewCephObjectStoreAccountInformer constructs a new informer for CephObjectStoreAccount type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewCephObjectStoreAccountInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredCephObjectStoreAccountInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredCephObjectStoreAccountInformer constructs a new informer for CephObjectStoreAccount type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredCephObjectStoreAccountInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options metav1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.CephV1().CephObjectStoreAccounts(namespace).List(context.Background(), options) + }, + WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.CephV1().CephObjectStoreAccounts(namespace).Watch(context.Background(), options) + }, + ListWithContextFunc: func(ctx context.Context, options metav1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.CephV1().CephObjectStoreAccounts(namespace).List(ctx, options) + }, + WatchFuncWithContext: func(ctx context.Context, options metav1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.CephV1().CephObjectStoreAccounts(namespace).Watch(ctx, options) + }, + }, + &apiscephrookiov1.CephObjectStoreAccount{}, + resyncPeriod, + indexers, + ) +} + +func (f *cephObjectStoreAccountInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredCephObjectStoreAccountInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *cephObjectStoreAccountInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&apiscephrookiov1.CephObjectStoreAccount{}, f.defaultInformer) +} + +func (f *cephObjectStoreAccountInformer) Lister() cephrookiov1.CephObjectStoreAccountLister { + return cephrookiov1.NewCephObjectStoreAccountLister(f.Informer().GetIndexer()) +} diff --git a/pkg/client/informers/externalversions/ceph.rook.io/v1/interface.go b/pkg/client/informers/externalversions/ceph.rook.io/v1/interface.go index 34666976b..2a98f0dd0 100644 --- a/pkg/client/informers/externalversions/ceph.rook.io/v1/interface.go +++ b/pkg/client/informers/externalversions/ceph.rook.io/v1/interface.go @@ -52,6 +52,8 @@ type Interface interface { CephObjectRealms() CephObjectRealmInformer // CephObjectStores returns a CephObjectStoreInformer. CephObjectStores() CephObjectStoreInformer + // CephObjectStoreAccounts returns a CephObjectStoreAccountInformer. + CephObjectStoreAccounts() CephObjectStoreAccountInformer // CephObjectStoreUsers returns a CephObjectStoreUserInformer. CephObjectStoreUsers() CephObjectStoreUserInformer // CephObjectZones returns a CephObjectZoneInformer. @@ -143,6 +145,11 @@ func (v *version) CephObjectStores() CephObjectStoreInformer { return &cephObjectStoreInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} } +// CephObjectStoreAccounts returns a CephObjectStoreAccountInformer. +func (v *version) CephObjectStoreAccounts() CephObjectStoreAccountInformer { + return &cephObjectStoreAccountInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // CephObjectStoreUsers returns a CephObjectStoreUserInformer. func (v *version) CephObjectStoreUsers() CephObjectStoreUserInformer { return &cephObjectStoreUserInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/pkg/client/informers/externalversions/generic.go b/pkg/client/informers/externalversions/generic.go index 21ef1a32e..b71a11b1f 100644 --- a/pkg/client/informers/externalversions/generic.go +++ b/pkg/client/informers/externalversions/generic.go @@ -81,6 +81,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource return &genericInformer{resource: resource.GroupResource(), informer: f.Ceph().V1().CephObjectRealms().Informer()}, nil case v1.SchemeGroupVersion.WithResource("cephobjectstores"): return &genericInformer{resource: resource.GroupResource(), informer: f.Ceph().V1().CephObjectStores().Informer()}, nil + case v1.SchemeGroupVersion.WithResource("cephobjectstoreaccounts"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Ceph().V1().CephObjectStoreAccounts().Informer()}, nil case v1.SchemeGroupVersion.WithResource("cephobjectstoreusers"): return &genericInformer{resource: resource.GroupResource(), informer: f.Ceph().V1().CephObjectStoreUsers().Informer()}, nil case v1.SchemeGroupVersion.WithResource("cephobjectzones"): diff --git a/pkg/client/listers/ceph.rook.io/v1/cephobjectstoreaccount.go b/pkg/client/listers/ceph.rook.io/v1/cephobjectstoreaccount.go new file mode 100644 index 000000000..ae20f9598 --- /dev/null +++ b/pkg/client/listers/ceph.rook.io/v1/cephobjectstoreaccount.go @@ -0,0 +1,70 @@ +/* +Copyright 2018 The Rook Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +    http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1 + +import ( + cephrookiov1 "github.com/rook/rook/pkg/apis/ceph.rook.io/v1" + labels "k8s.io/apimachinery/pkg/labels" + listers "k8s.io/client-go/listers" + cache "k8s.io/client-go/tools/cache" +) + +// CephObjectStoreAccountLister helps list CephObjectStoreAccounts. +// All objects returned here must be treated as read-only. +type CephObjectStoreAccountLister interface { + // List lists all CephObjectStoreAccounts in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*cephrookiov1.CephObjectStoreAccount, err error) + // CephObjectStoreAccounts returns an object that can list and get CephObjectStoreAccounts. + CephObjectStoreAccounts(namespace string) CephObjectStoreAccountNamespaceLister + CephObjectStoreAccountListerExpansion +} + +// cephObjectStoreAccountLister implements the CephObjectStoreAccountLister interface. +type cephObjectStoreAccountLister struct { + listers.ResourceIndexer[*cephrookiov1.CephObjectStoreAccount] +} + +// NewCephObjectStoreAccountLister returns a new CephObjectStoreAccountLister. +func NewCephObjectStoreAccountLister(indexer cache.Indexer) CephObjectStoreAccountLister { + return &cephObjectStoreAccountLister{listers.New[*cephrookiov1.CephObjectStoreAccount](indexer, cephrookiov1.Resource("cephobjectstoreaccount"))} +} + +// CephObjectStoreAccounts returns an object that can list and get CephObjectStoreAccounts. +func (s *cephObjectStoreAccountLister) CephObjectStoreAccounts(namespace string) CephObjectStoreAccountNamespaceLister { + return cephObjectStoreAccountNamespaceLister{listers.NewNamespaced[*cephrookiov1.CephObjectStoreAccount](s.ResourceIndexer, namespace)} +} + +// CephObjectStoreAccountNamespaceLister helps list and get CephObjectStoreAccounts. +// All objects returned here must be treated as read-only. +type CephObjectStoreAccountNamespaceLister interface { + // List lists all CephObjectStoreAccounts in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*cephrookiov1.CephObjectStoreAccount, err error) + // Get retrieves the CephObjectStoreAccount from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*cephrookiov1.CephObjectStoreAccount, error) + CephObjectStoreAccountNamespaceListerExpansion +} + +// cephObjectStoreAccountNamespaceLister implements the CephObjectStoreAccountNamespaceLister +// interface. +type cephObjectStoreAccountNamespaceLister struct { + listers.ResourceIndexer[*cephrookiov1.CephObjectStoreAccount] +} diff --git a/pkg/client/listers/ceph.rook.io/v1/expansion_generated.go b/pkg/client/listers/ceph.rook.io/v1/expansion_generated.go index 47b6a3046..cc0bdd176 100644 --- a/pkg/client/listers/ceph.rook.io/v1/expansion_generated.go +++ b/pkg/client/listers/ceph.rook.io/v1/expansion_generated.go @@ -130,6 +130,14 @@ type CephObjectStoreListerExpansion interface{} // CephObjectStoreNamespaceLister. type CephObjectStoreNamespaceListerExpansion interface{} +// CephObjectStoreAccountListerExpansion allows custom methods to be added to +// CephObjectStoreAccountLister. +type CephObjectStoreAccountListerExpansion interface{} + +// CephObjectStoreAccountNamespaceListerExpansion allows custom methods to be added to +// CephObjectStoreAccountNamespaceLister. +type CephObjectStoreAccountNamespaceListerExpansion interface{} + // CephObjectStoreUserListerExpansion allows custom methods to be added to // CephObjectStoreUserLister. type CephObjectStoreUserListerExpansion interface{} diff --git a/pkg/operator/ceph/cr_manager.go b/pkg/operator/ceph/cr_manager.go index 42c7d5853..54d19570e 100644 --- a/pkg/operator/ceph/cr_manager.go +++ b/pkg/operator/ceph/cr_manager.go @@ -35,6 +35,7 @@ import ( "github.com/rook/rook/pkg/operator/ceph/nfs" "github.com/rook/rook/pkg/operator/ceph/nvmeof" "github.com/rook/rook/pkg/operator/ceph/object" + objectaccount "github.com/rook/rook/pkg/operator/ceph/object/account" "github.com/rook/rook/pkg/operator/ceph/object/bucket" "github.com/rook/rook/pkg/operator/ceph/object/cosi" "github.com/rook/rook/pkg/operator/ceph/object/notification" @@ -93,6 +94,7 @@ var AddToManagerFuncs = []func(manager.Manager, *clusterd.Context, context.Conte subvolumegroup.Add, radosnamespace.Add, cosi.Add, + objectaccount.Add, } // AddToManagerOpFunc is a list of functions to add all Controllers to the Manager (entrypoint for diff --git a/pkg/operator/ceph/object/account.go b/pkg/operator/ceph/object/account.go new file mode 100644 index 000000000..5b51d40b7 --- /dev/null +++ b/pkg/operator/ceph/object/account.go @@ -0,0 +1,81 @@ +/* +Copyright 2026 The Rook Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package object + +import ( + "context" + + "github.com/ceph/go-ceph/rgw/admin" + "github.com/pkg/errors" +) + +// GetAccount retrieves account information from RGW using the admin ops API. +func GetAccount(ctx context.Context, adminOpsContext *AdminOpsContext, accountID string) (admin.Account, error) { + if accountID == "" { + return admin.Account{}, errors.New("account ID cannot be empty") + } + + account, err := adminOpsContext.AdminOpsClient.GetAccount(ctx, accountID) + if err != nil { + return admin.Account{}, errors.Wrapf(err, "failed to get account %q", accountID) + } + + return account, nil +} + +// CreateAccount creates a new RGW account using the admin ops API. +// If account.ID is empty, Ceph will auto-generate the account ID. +func CreateAccount(ctx context.Context, adminOpsContext *AdminOpsContext, account admin.Account) (admin.Account, error) { + if account.Name == "" { + return admin.Account{}, errors.New("account name cannot be empty") + } + + createdAccount, err := adminOpsContext.AdminOpsClient.CreateAccount(ctx, account) + if err != nil { + return admin.Account{}, errors.Wrapf(err, "failed to create account %q", account.Name) + } + + return createdAccount, nil +} + +// ModifyAccount modifies an existing RGW account. +func ModifyAccount(ctx context.Context, adminOpsContext *AdminOpsContext, account admin.Account) (admin.Account, error) { + if account.ID == "" { + return admin.Account{}, errors.New("account ID cannot be empty") + } + + modifiedAccount, err := adminOpsContext.AdminOpsClient.ModifyAccount(ctx, account) + if err != nil { + return admin.Account{}, errors.Wrapf(err, "failed to modify account %q", account.ID) + } + + return modifiedAccount, nil +} + +// DeleteAccount removes an RGW account using the admin ops API. +func DeleteAccount(ctx context.Context, adminOpsContext *AdminOpsContext, accountID string) error { + if accountID == "" { + return errors.New("account ID cannot be empty") + } + + err := adminOpsContext.AdminOpsClient.DeleteAccount(ctx, accountID) + if err != nil { + return errors.Wrapf(err, "failed to delete account %q", accountID) + } + + return nil +} diff --git a/pkg/operator/ceph/object/account/controller.go b/pkg/operator/ceph/object/account/controller.go new file mode 100644 index 000000000..60c6eda0e --- /dev/null +++ b/pkg/operator/ceph/object/account/controller.go @@ -0,0 +1,492 @@ +/* +Copyright 2026 The Rook Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package account manages RGW accounts. +package account + +import ( + "context" + "fmt" + "reflect" + "strconv" + + "github.com/ceph/go-ceph/rgw/admin" + "github.com/coreos/pkg/capnslog" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + + cephv1 "github.com/rook/rook/pkg/apis/ceph.rook.io/v1" + "github.com/rook/rook/pkg/clusterd" + cephclient "github.com/rook/rook/pkg/daemon/ceph/client" + opcontroller "github.com/rook/rook/pkg/operator/ceph/controller" + "github.com/rook/rook/pkg/operator/ceph/object" + "github.com/rook/rook/pkg/operator/ceph/reporting" + "github.com/rook/rook/pkg/operator/k8sutil" + "github.com/rook/rook/pkg/util/log" +) + +const ( + controllerName = "ceph-object-store-account-controller" +) + +// newMultisiteAdminOpsCtxFunc helps us mocking the admin ops API client in unit test +var newMultisiteAdminOpsCtxFunc = object.NewMultisiteAdminOpsContext + +var logger = capnslog.NewPackageLogger("github.com/rook/rook", controllerName) + +// Sets the type meta for the controller main object +var controllerTypeMeta = metav1.TypeMeta{ + Kind: reflect.TypeFor[cephv1.CephObjectStoreAccount]().Name(), + APIVersion: fmt.Sprintf("%s/%s", cephv1.CustomResourceGroup, cephv1.Version), +} + +// ReconcileObjectStoreAccount reconciles a CephObjectStoreAccount object +type ReconcileObjectStoreAccount struct { + client client.Client + scheme *runtime.Scheme + context *clusterd.Context + objContext *object.AdminOpsContext + cephClusterSpec *cephv1.ClusterSpec + clusterInfo *cephclient.ClusterInfo + opManagerContext context.Context + recorder events.EventRecorder +} + +// Add creates a new CephObjectStoreAccount Controller and adds it to the Manager. The Manager will set fields on the Controller +// and Start it when the Manager is Started. +// +kubebuilder:rbac:groups=ceph.rook.io,resources=cephobjectstoreaccounts,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=ceph.rook.io,resources=cephobjectstoreaccounts/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=ceph.rook.io,resources=cephobjectstoreaccounts/finalizers,verbs=update +func Add(mgr manager.Manager, context *clusterd.Context, opManagerContext context.Context, opConfig opcontroller.OperatorConfig) error { + return add(mgr, newReconciler(mgr, context, opManagerContext)) +} + +// newReconciler returns a new reconcile.Reconciler +func newReconciler(mgr manager.Manager, context *clusterd.Context, opManagerContext context.Context) reconcile.Reconciler { + return &ReconcileObjectStoreAccount{ + client: mgr.GetClient(), + scheme: mgr.GetScheme(), + context: context, + opManagerContext: opManagerContext, + recorder: mgr.GetEventRecorder("rook-" + controllerName), + } +} + +func add(mgr manager.Manager, r reconcile.Reconciler) error { + // Create a new controller + c, err := controller.New(controllerName, mgr, controller.Options{Reconciler: r}) + if err != nil { + return err + } + logger.Info("successfully started") + + // Watch for changes on the CephObjectStoreAccount CRD object + err = c.Watch( + source.Kind( + mgr.GetCache(), + &cephv1.CephObjectStoreAccount{TypeMeta: controllerTypeMeta}, + &handler.TypedEnqueueRequestForObject[*cephv1.CephObjectStoreAccount]{}, + opcontroller.WatchControllerPredicate[*cephv1.CephObjectStoreAccount](mgr.GetScheme()), + ), + ) + if err != nil { + return err + } + + return nil +} + +// Reconcile reads that state of the cluster for a CephObjectStoreAccount object and makes changes based on the state read +// and what is in the CephObjectStoreAccount.Spec +// The Controller will requeue the Request to be processed again if the returned error is non-nil or +// Result.Requeue is true, otherwise upon completion it will remove the work from the queue. +func (r *ReconcileObjectStoreAccount) Reconcile(context context.Context, request reconcile.Request) (reconcile.Result, error) { + defer opcontroller.RecoverAndLogException() + // workaround because the rook logging mechanism is not compatible with the controller-runtime logging interface + reconcileResponse, cephObjectStoreAccount, err := r.reconcile(request) + if err != nil { + r.updateStatus(k8sutil.ObservedGenerationNotAvailable, request.NamespacedName, k8sutil.ReconcileFailedStatus) + log.NamedError(request.NamespacedName, logger, "failed to reconcile %v", err) + } + + return reporting.ReportReconcileResult(logger, r.recorder, request, &cephObjectStoreAccount, reconcileResponse, err) +} + +func (r *ReconcileObjectStoreAccount) reconcile(request reconcile.Request) (reconcile.Result, cephv1.CephObjectStoreAccount, error) { + // Fetch the CephObjectStoreAccount instance + cephObjectStoreAccount := &cephv1.CephObjectStoreAccount{} + err := r.client.Get(r.opManagerContext, request.NamespacedName, cephObjectStoreAccount) + if err != nil { + if kerrors.IsNotFound(err) { + log.NamedDebug(request.NamespacedName, logger, "CephObjectStoreAccount resource not found. Ignoring since object must be deleted.") + return reconcile.Result{}, *cephObjectStoreAccount, nil + } + // Error reading the object - requeue the request. + return reconcile.Result{}, *cephObjectStoreAccount, errors.Wrap(err, "failed to get CephObjectStoreAccount") + } + + // update observedGeneration local variable with current generation value, + // because generation can be changed before reconcile got completed + // CR status will be updated at end of reconcile, so to reflect the reconcile has finished + observedGeneration := cephObjectStoreAccount.ObjectMeta.Generation + + // Set a finalizer so we can do cleanup before the object goes away + generationUpdated, err := opcontroller.AddFinalizerIfNotPresent(r.opManagerContext, r.client, cephObjectStoreAccount) + if err != nil { + return reconcile.Result{}, *cephObjectStoreAccount, errors.Wrap(err, "failed to add finalizer") + } + if generationUpdated { + log.NamedInfo(request.NamespacedName, logger, "reconciling the object store account after adding finalizer") + return reconcile.Result{}, *cephObjectStoreAccount, nil + } + + // The CR was just created, initializing status fields + if cephObjectStoreAccount.Status == nil { + r.updateStatus(k8sutil.ObservedGenerationNotAvailable, request.NamespacedName, k8sutil.EmptyStatus) + } + + // Make sure a CephCluster is present otherwise do nothing + cephCluster, isReadyToReconcile, cephClusterExists, reconcileResponse := opcontroller.IsReadyToReconcile(r.opManagerContext, r.client, request.NamespacedName, controllerName) + if !isReadyToReconcile { + // This handles the case where the Ceph Cluster is gone and we want to delete that CR + // We skip the deleteAccount() function since everything is gone already + // + // Also, only remove the finalizer if the CephCluster is gone + // If not, we should wait for it to be ready + // This handles the case where the operator is not ready to accept Ceph command but the cluster exists + if !cephObjectStoreAccount.GetDeletionTimestamp().IsZero() && !cephClusterExists { + // Remove finalizer + err = opcontroller.RemoveFinalizer(r.opManagerContext, r.client, cephObjectStoreAccount) + if err != nil { + return reconcile.Result{}, *cephObjectStoreAccount, errors.Wrap(err, "failed to remove finalizer") + } + + // Return and do not requeue. Successful deletion. + return reconcile.Result{}, *cephObjectStoreAccount, nil + } + return reconcileResponse, *cephObjectStoreAccount, nil + } + r.cephClusterSpec = &cephCluster.Spec + + // Populate clusterInfo during each reconcile + r.clusterInfo, _, _, err = opcontroller.LoadClusterInfo(r.context, r.opManagerContext, request.NamespacedName.Namespace, r.cephClusterSpec) + if err != nil { + return reconcile.Result{}, *cephObjectStoreAccount, errors.Wrap(err, "failed to populate cluster info") + } + + // Validate the object store has been initialized + opsCtx, _, err := object.InitializeObjectStoreContext(r.context, r.clusterInfo, r.client, r.opManagerContext, cephObjectStoreAccount.Spec.Store, newMultisiteAdminOpsCtxFunc) + if err != nil { + if !cephObjectStoreAccount.GetDeletionTimestamp().IsZero() { + // Remove finalizer + err = opcontroller.RemoveFinalizer(r.opManagerContext, r.client, cephObjectStoreAccount) + if err != nil { + return reconcile.Result{}, *cephObjectStoreAccount, errors.Wrap(err, "failed to remove finalizer") + } + + // Return and do not requeue. Successful deletion. + return reconcile.Result{}, *cephObjectStoreAccount, nil + } + log.NamedDebug(request.NamespacedName, logger, "ObjectStore resource not ready, retrying in %q. %v", + opcontroller.WaitForRequeueIfCephClusterNotReady.RequeueAfter.String(), err) + return opcontroller.WaitForRequeueIfCephClusterNotReady, *cephObjectStoreAccount, err + } + r.objContext = opsCtx + + // DELETE: the CR was deleted + if !cephObjectStoreAccount.GetDeletionTimestamp().IsZero() { + log.NamedDebug(request.NamespacedName, logger, "deleting object store account") + r.recorder.Eventf(cephObjectStoreAccount, nil, corev1.EventTypeNormal, string(cephv1.ReconcileStarted), string(cephv1.ReconcileStarted), "deleting CephObjectStoreAccount %q", cephObjectStoreAccount.Name) + + err := r.deleteAccount(cephObjectStoreAccount) + if err != nil { + return reconcile.Result{}, *cephObjectStoreAccount, errors.Wrapf(err, "failed to delete ceph object store account %q", cephObjectStoreAccount.Name) + } + + // Remove finalizer + err = opcontroller.RemoveFinalizer(r.opManagerContext, r.client, cephObjectStoreAccount) + if err != nil { + return reconcile.Result{}, *cephObjectStoreAccount, errors.Wrap(err, "failed to remove finalizer") + } + + // Return and do not requeue. Successful deletion. + return reconcile.Result{}, *cephObjectStoreAccount, nil + } + + // CR is not deleted, continue reconciling + + // Reconcile the account + accountID, err := r.reconcileAccount(cephObjectStoreAccount) + if err != nil { + return reconcile.Result{}, *cephObjectStoreAccount, errors.Wrapf(err, "failed to reconcile account %q", cephObjectStoreAccount.Name) + } + + // Update the status with the account ID + r.updateStatusWithAccountID(observedGeneration, request.NamespacedName, accountID) + + return reconcile.Result{}, *cephObjectStoreAccount, nil +} + +// getAccountName returns the effective account name from the CR spec, +// falling back to the CR name if spec.Name is not set. +func getAccountName(cephObjectStoreAccount *cephv1.CephObjectStoreAccount) string { + if cephObjectStoreAccount.Spec.Name != "" { + return cephObjectStoreAccount.Spec.Name + } + return cephObjectStoreAccount.Name +} + +// getAccountID looks up an already-persisted account ID from the CR, +// checking spec and status in priority order. It never generates +// a new ID. Returns "" when no ID has been recorded yet. +func getAccountID(cephObjectStoreAccount *cephv1.CephObjectStoreAccount) string { + if cephObjectStoreAccount.Spec.AccountID != "" { + return cephObjectStoreAccount.Spec.AccountID + } + if cephObjectStoreAccount.Status != nil && cephObjectStoreAccount.Status.AccountID != "" { + return cephObjectStoreAccount.Status.AccountID + } + return "" +} + +// getOrGenerateAccountID returns the existing account ID if one is persisted, +// otherwise derives a deterministic ID from the CR's UID. This is used during +// reconciliation to ensure the same ID is reused across retries, preventing +// orphaned accounts if the operator crashes after creating an account but +// before persisting the account ID to the CR status. +func getOrGenerateAccountID(cephObjectStoreAccount *cephv1.CephObjectStoreAccount) (string, error) { + if id := getAccountID(cephObjectStoreAccount); id != "" { + return id, nil + } + if cephObjectStoreAccount.UID != "" { + nsName := types.NamespacedName{Namespace: cephObjectStoreAccount.Namespace, Name: cephObjectStoreAccount.Name} + log.NamedInfo(nsName, logger, "generating RGW account ID from the CR's UID") + id, err := generateDeterministicAccountID(cephObjectStoreAccount.UID) + if err != nil { + return "", errors.Wrap(err, "failed to generate deterministic account ID") + } + return id, nil + } + return "", nil +} + +// generateDeterministicAccountID derives a stable RGW account ID from the +// CR's Kubernetes UID. It extracts 14 hex digits from the UUID, converts +// them to a 17-digit decimal number, and prefixes it with "RGW". The same +// UID always produces the same account ID. +func generateDeterministicAccountID(uid types.UID) (string, error) { + uidStr := string(uid) + l := len(uidStr) + if l < 14 { + return "", fmt.Errorf("UID too short to generate account ID: %q", uidStr) + } + last12 := uidStr[l-12:] + first2 := uidStr[:2] + value, err := strconv.ParseUint(last12+first2, 16, 64) + if err != nil { + return "", fmt.Errorf("failed to parse UID hex digits: %w", err) + } + return fmt.Sprintf("RGW%017d", value), nil +} + +func (r *ReconcileObjectStoreAccount) reconcileAccount(cephObjectStoreAccount *cephv1.CephObjectStoreAccount) (string, error) { + accountID, err := getOrGenerateAccountID(cephObjectStoreAccount) + if err != nil { + return "", errors.Wrap(err, "failed to get or generate account ID") + } + desiredAccount := admin.Account{ + ID: accountID, + Name: getAccountName(cephObjectStoreAccount), + } + nsName := types.NamespacedName{Namespace: cephObjectStoreAccount.Namespace, Name: cephObjectStoreAccount.Name} + + // Try to fetch the existing account + liveAccountExists := true + _, err = object.GetAccount(r.opManagerContext, r.objContext, desiredAccount.ID) + if err != nil { + if errors.Is(err, admin.ErrNoSuchKey) { + log.NamedInfo(nsName, logger, "account %q does not exist", desiredAccount.ID) + liveAccountExists = false + } else { + return "", errors.Wrapf(err, "failed to check if account exists") + } + } + + // If Account exists then validate ownership and check if it needs to be updated + if liveAccountExists { + // Verify ownership before modifying an existing account. Ownership is confirmed if + // the status already records the account ID (from a previous reconcile or from + // the pre-creation bookmark persisted to status before account creation). + hasOwnershipProof := cephObjectStoreAccount.Status != nil && cephObjectStoreAccount.Status.AccountID == desiredAccount.ID + if !hasOwnershipProof { + return "", fmt.Errorf("account ID %q already exists in RGW but is not managed by this CR; refusing to adopt a foreign account", desiredAccount.ID) + } + + log.NamedInfo(nsName, logger, "ensuring account %q is in sync", desiredAccount.ID) + updatedAccount, err := object.ModifyAccount(r.opManagerContext, r.objContext, desiredAccount) + if err != nil { + return "", errors.Wrapf(err, "failed to modify account %q", desiredAccount.ID) + } + log.NamedInfo(nsName, logger, "successfully modified account %q", desiredAccount.ID) + return updatedAccount.ID, nil + } + + // Persist the account ID to status before creating the account. This serves as a + // creation bookmark so that if the operator crashes after creating the account but + // before the final status update, the next reconcile can identify the account as + // owned by this CR. The phase is not set to Ready here — that happens after + // successful creation. + if err := r.persistAccountIDToStatus(cephObjectStoreAccount, desiredAccount.ID); err != nil { + return "", errors.Wrapf(err, "failed to persist account ID to status before creating account %q", desiredAccount.Name) + } + + // Account doesn't exist, create it + log.NamedInfo(nsName, logger, "creating account %q", desiredAccount.Name) + createdAccount, err := object.CreateAccount(r.opManagerContext, r.objContext, desiredAccount) + if err != nil { + if errors.Is(err, admin.ErrAccountAlreadyExists) { + return "", fmt.Errorf("failed to create account with ID %q and name %q: an account with this ID or name already exists in RGW; ensure no conflicting account exists", desiredAccount.ID, desiredAccount.Name) + } + return "", errors.Wrapf(err, "failed to create account %q", desiredAccount.Name) + } + + log.NamedInfo(nsName, logger, "successfully created account %q with ID %q", desiredAccount.Name, createdAccount.ID) + return createdAccount.ID, nil +} + +// persistAccountIDToStatus persists the account ID to the CR status before +// creating the account in the backend. This is a no-op if the status already +// contains the desired account ID. The phase is not modified here; it will be +// set to Ready after the account is successfully created. +func (r *ReconcileObjectStoreAccount) persistAccountIDToStatus(cephObjectStoreAccount *cephv1.CephObjectStoreAccount, accountID string) error { + if cephObjectStoreAccount.Status != nil && cephObjectStoreAccount.Status.AccountID == accountID { + return nil + } + // Re-fetch the latest version to avoid resourceVersion conflicts, since earlier + // reconcile steps (e.g. updateStatus for initial empty status) may have updated + // the object on the server, making our in-memory copy stale. + nsName := types.NamespacedName{Namespace: cephObjectStoreAccount.Namespace, Name: cephObjectStoreAccount.Name} + latest := &cephv1.CephObjectStoreAccount{} + if err := r.client.Get(r.opManagerContext, nsName, latest); err != nil { + return errors.Wrapf(err, "failed to get latest version of object %q", nsName) + } + if latest.Status == nil { + latest.Status = &cephv1.ObjectStoreAccountStatus{} + } + latest.Status.AccountID = accountID + if err := reporting.UpdateStatus(r.client, latest); err != nil { + return errors.Wrapf(err, "failed to update object %q status", nsName) + } + // Update the caller's copy so subsequent checks see the persisted account ID + cephObjectStoreAccount.Status = latest.Status + return nil +} + +func (r *ReconcileObjectStoreAccount) deleteAccount(cephObjectStoreAccount *cephv1.CephObjectStoreAccount) error { + nsName := types.NamespacedName{Namespace: cephObjectStoreAccount.Namespace, Name: cephObjectStoreAccount.Name} + accountID := getAccountID(cephObjectStoreAccount) + if accountID == "" { + log.NamedInfo(nsName, logger, "no account ID found, skipping deletion") + return nil + } + + // Only delete the account if we have ownership proof (status contains the account ID). + // If the account ID is only in spec but was never successfully reconciled, + // it means the controller never created/adopted this account, so we must + // not delete what could be a foreign account. + hasOwnershipProof := cephObjectStoreAccount.Status != nil && cephObjectStoreAccount.Status.AccountID == accountID + if !hasOwnershipProof { + log.NamedInfo(nsName, logger, "account %q was never successfully managed by this CR, skipping deletion to avoid removing a foreign account", accountID) + return nil + } + + log.NamedInfo(nsName, logger, "deleting account %q", accountID) + + err := object.DeleteAccount(r.opManagerContext, r.objContext, accountID) + if err != nil { + // If account doesn't exist, consider it successful (idempotent) + if errors.Is(err, admin.ErrNoSuchKey) { + log.NamedInfo(nsName, logger, "account %q not found, considering deletion successful", accountID) + return nil + } + return errors.Wrapf(err, "failed to delete account %q", accountID) + } + + log.NamedInfo(nsName, logger, "successfully deleted account %q", accountID) + return nil +} + +func (r *ReconcileObjectStoreAccount) updateStatus(observedGeneration int64, name types.NamespacedName, status string) { + account := &cephv1.CephObjectStoreAccount{} + if err := r.client.Get(r.opManagerContext, name, account); err != nil { + if kerrors.IsNotFound(err) { + log.NamedDebug(name, logger, "CephObjectStoreAccount resource not found. Ignoring since object must be deleted.") + return + } + log.NamedWarning(name, logger, "failed to retrieve object store account %q to update status. %v", name, err) + return + } + if account.Status == nil { + account.Status = &cephv1.ObjectStoreAccountStatus{} + } + + account.Status.Phase = status + if observedGeneration != k8sutil.ObservedGenerationNotAvailable { + account.Status.ObservedGeneration = &observedGeneration + } + if err := reporting.UpdateStatus(r.client, account); err != nil { + log.NamedError(name, logger, "failed to set object store account %q status to %q. %v", name, status, err) + return + } + log.NamedDebug(name, logger, "object store account %q status updated to %q", name, status) +} + +func (r *ReconcileObjectStoreAccount) updateStatusWithAccountID(observedGeneration int64, name types.NamespacedName, accountID string) { + account := &cephv1.CephObjectStoreAccount{} + if err := r.client.Get(r.opManagerContext, name, account); err != nil { + if kerrors.IsNotFound(err) { + log.NamedDebug(name, logger, "CephObjectStoreAccount resource not found. Ignoring since object must be deleted.") + return + } + log.NamedWarning(name, logger, "failed to retrieve object store account %q to update status. %v", name, err) + return + } + if account.Status == nil { + account.Status = &cephv1.ObjectStoreAccountStatus{} + } + + account.Status.Phase = k8sutil.ReadyStatus + account.Status.AccountID = accountID + if observedGeneration != k8sutil.ObservedGenerationNotAvailable { + account.Status.ObservedGeneration = &observedGeneration + } + if err := reporting.UpdateStatus(r.client, account); err != nil { + log.NamedError(name, logger, "failed to update object store account %q status. %v", name, err) + return + } + log.NamedDebug(name, logger, "object store account %q status updated with account ID %q", name, accountID) +} diff --git a/pkg/operator/ceph/object/account/controller_test.go b/pkg/operator/ceph/object/account/controller_test.go new file mode 100644 index 000000000..b423120a3 --- /dev/null +++ b/pkg/operator/ceph/object/account/controller_test.go @@ -0,0 +1,1298 @@ +/* +Copyright 2026 The Rook Authors. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package account + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/ceph/go-ceph/rgw/admin" + "github.com/coreos/pkg/capnslog" + cephv1 "github.com/rook/rook/pkg/apis/ceph.rook.io/v1" + rookclient "github.com/rook/rook/pkg/client/clientset/versioned/fake" + "github.com/rook/rook/pkg/clusterd" + cephobject "github.com/rook/rook/pkg/operator/ceph/object" + "github.com/rook/rook/pkg/operator/k8sutil" + "github.com/rook/rook/pkg/operator/test" + exectest "github.com/rook/rook/pkg/util/exec/test" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +const ( + accountCreateJSON = `{ + "id": "RGW12345678901234567", + "name": "my-account" +}` + accountModifyJSON = `{ + "id": "RGW12345678901234567", + "name": "updated-account" +}` +) + +var ( + name = "my-account" + namespace = "rook-ceph" + store = "my-store" +) + +func TestCephObjectStoreAccountController(t *testing.T) { + ctx := context.TODO() + capnslog.SetGlobalLogLevel(capnslog.DEBUG) + + objectAccount := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Finalizers: []string{"cephobjectstoreaccount.ceph.rook.io"}, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + }, + TypeMeta: metav1.TypeMeta{ + Kind: "CephObjectStoreAccount", + }, + } + cephCluster := &cephv1.CephCluster{} + + object := []runtime.Object{ + objectAccount, + cephCluster, + } + + executor := &exectest.MockExecutor{ + MockExecuteCommandWithOutput: func(command string, args ...string) (string, error) { + if args[0] == "status" { + return `{"fsid":"c47cac40-9bee-4d52-823b-ccd803ba5bfe","health":{"checks":{},"status":"HEALTH_ERR"},"pgmap":{"num_pgs":100,"pgs_by_state":[{"state_name":"active+clean","count":100}]}}`, nil + } + return "", nil + }, + } + clientset := test.New(t, 3) + c := &clusterd.Context{ + Executor: executor, + RookClientset: rookclient.NewSimpleClientset(), + Clientset: clientset, + } + + // Register operator types with the runtime scheme. + s := scheme.Scheme + s.AddKnownTypes(cephv1.SchemeGroupVersion, + &cephv1.CephObjectStoreAccount{}, &cephv1.CephObjectStoreAccountList{}, + &cephv1.CephCluster{}, &cephv1.CephClusterList{}, + ) + + cl := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(object...).Build() + r := &ReconcileObjectStoreAccount{client: cl, scheme: s, context: c, opManagerContext: ctx, recorder: events.NewFakeRecorder(50)} + + req := reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: name, + Namespace: namespace, + }, + } + + t.Run("failure because no CephCluster", func(t *testing.T) { + res, err := r.Reconcile(ctx, req) + assert.NoError(t, err) + assert.True(t, res.Requeue) + }) + + t.Run("failure CephCluster not ready", func(t *testing.T) { + cephCluster = &cephv1.CephCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + Namespace: namespace, + }, + Status: cephv1.ClusterStatus{ + Phase: "", + CephStatus: &cephv1.CephStatus{ + Health: "", + }, + }, + } + + object = append(object, cephCluster) + cl = fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(object...).Build() + r = &ReconcileObjectStoreAccount{client: cl, scheme: s, context: c, opManagerContext: ctx, recorder: events.NewFakeRecorder(50)} + res, err := r.Reconcile(ctx, req) + assert.NoError(t, err) + assert.True(t, res.Requeue) + }) + + t.Run("failure CephCluster ready but no RGW object", func(t *testing.T) { + secrets := map[string][]byte{ + "fsid": []byte(name), + "mon-secret": []byte("monsecret"), + "admin-secret": []byte("adminsecret"), + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rook-ceph-mon", + Namespace: namespace, + }, + Data: secrets, + Type: k8sutil.RookType, + } + _, err := c.Clientset.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{}) + assert.NoError(t, err) + + cephCluster.Status.Phase = k8sutil.ReadyStatus + cephCluster.Status.CephStatus.Health = "HEALTH_OK" + + cl = fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(object...).Build() + + executor = &exectest.MockExecutor{ + MockExecuteCommandWithOutput: func(command string, args ...string) (string, error) { + if args[0] == "status" { + return `{"fsid":"c47cac40-9bee-4d52-823b-ccd803ba5bfe","health":{"checks":{},"status":"HEALTH_OK"},"pgmap":{"num_pgs":100,"pgs_by_state":[{"state_name":"active+clean","count":100}]}}`, nil + } + return "", nil + }, + MockExecuteCommandWithTimeout: func(timeout time.Duration, command string, args ...string) (string, error) { + return "", nil + }, + } + c.Executor = executor + + r = &ReconcileObjectStoreAccount{client: cl, scheme: s, context: c, opManagerContext: ctx, recorder: events.NewFakeRecorder(50)} + res, err := r.Reconcile(ctx, req) + assert.NoError(t, err) + assert.True(t, res.Requeue) + }) + + t.Run("failure RGW object exists but no pods running", func(t *testing.T) { + cephObjectStore := &cephv1.CephObjectStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: store, + Namespace: namespace, + }, + TypeMeta: metav1.TypeMeta{ + Kind: "CephObjectStore", + }, + Spec: cephv1.ObjectStoreSpec{ + Gateway: cephv1.GatewaySpec{ + Port: 80, + }, + }, + Status: &cephv1.ObjectStoreStatus{ + Info: map[string]string{"endpoint": "http://rook-ceph-rgw-my-store.rook-ceph:80"}, + }, + } + s.AddKnownTypes(cephv1.SchemeGroupVersion, &cephv1.CephObjectStore{}, &cephv1.CephObjectStoreList{}) + object = append(object, cephObjectStore) + + cl = fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(object...).Build() + r = &ReconcileObjectStoreAccount{client: cl, scheme: s, context: c, opManagerContext: ctx, recorder: events.NewFakeRecorder(50)} + + res, err := r.Reconcile(ctx, req) + assert.NoError(t, err) + assert.True(t, res.Requeue) + }) + + t.Run("success RGW object exists and pods running", func(t *testing.T) { + // Build a fresh client with all required objects to avoid state issues from prior subtests + freshAccount := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: "c1a2b3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + Finalizers: []string{"cephobjectstoreaccount.ceph.rook.io"}, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + }, + TypeMeta: metav1.TypeMeta{ + Kind: "CephObjectStoreAccount", + }, + Status: &cephv1.ObjectStoreAccountStatus{}, + } + deterministicID, err := generateDeterministicAccountID("c1a2b3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d") + assert.NoError(t, err) + readyCluster := &cephv1.CephCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + Namespace: namespace, + }, + Status: cephv1.ClusterStatus{ + Phase: k8sutil.ReadyStatus, + CephStatus: &cephv1.CephStatus{ + Health: "HEALTH_OK", + }, + }, + } + cephObjectStore := &cephv1.CephObjectStore{ + ObjectMeta: metav1.ObjectMeta{ + Name: store, + Namespace: namespace, + }, + TypeMeta: metav1.TypeMeta{ + Kind: "CephObjectStore", + }, + Spec: cephv1.ObjectStoreSpec{ + Gateway: cephv1.GatewaySpec{ + Port: 80, + }, + }, + Status: &cephv1.ObjectStoreStatus{ + Info: map[string]string{"endpoint": "http://rook-ceph-rgw-my-store.rook-ceph:80"}, + }, + } + rgwPod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{ + Name: "rook-ceph-rgw-my-store-a-5fd6fb4489-xv65v", + Namespace: namespace, + Labels: map[string]string{k8sutil.AppAttr: cephobject.AppName, "rgw": store}, + }} + + freshCl := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(freshAccount, readyCluster, cephObjectStore, rgwPod).Build() + + freshExecutor := &exectest.MockExecutor{ + MockExecuteCommandWithOutput: func(command string, args ...string) (string, error) { + if args[0] == "status" { + return `{"fsid":"c47cac40-9bee-4d52-823b-ccd803ba5bfe","health":{"checks":{},"status":"HEALTH_OK"},"pgmap":{"num_pgs":100,"pgs_by_state":[{"state_name":"active+clean","count":100}]}}`, nil + } + return "", nil + }, + } + freshClientset := test.New(t, 3) + freshC := &clusterd.Context{ + Executor: freshExecutor, + RookClientset: rookclient.NewSimpleClientset(), + Clientset: freshClientset, + } + + // Create the mon secret required by LoadClusterInfo + secrets := map[string][]byte{ + "fsid": []byte(name), + "mon-secret": []byte("monsecret"), + "admin-secret": []byte("adminsecret"), + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rook-ceph-mon", + Namespace: namespace, + }, + Data: secrets, + Type: k8sutil.RookType, + } + _, err = freshC.Clientset.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{}) + assert.NoError(t, err) + + freshR := &ReconcileObjectStoreAccount{client: freshCl, scheme: s, context: freshC, opManagerContext: ctx, recorder: events.NewFakeRecorder(50)} + + newAccountJSON := fmt.Sprintf(`{"id": "%s", "name": "my-account"}`, deterministicID) + newMultisiteAdminOpsCtxFunc = func(objContext *cephobject.Context, spec *cephv1.ObjectStoreSpec) (*cephobject.AdminOpsContext, error) { + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + // Handle account retrieval: 404 on first reconcile (account not yet created) + if req.Method == http.MethodGet { + return &http.Response{ + StatusCode: 404, + Body: io.NopCloser(bytes.NewReader([]byte(`{"Code":"NoSuchKey","RequestId":"tx000","HostId":""}`))), + }, nil + } + // Handle account creation with deterministic ID + if req.Method == http.MethodPost { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(newAccountJSON))), + }, nil + } + return nil, fmt.Errorf("unexpected request: %q. method %q. path %q", req.URL.RawQuery, req.Method, req.URL.Path) + }, + } + + msContext, err := cephobject.NewMultisiteContext(freshR.context, freshR.clusterInfo, cephObjectStore) + if err != nil { + return nil, err + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "53S6B9S809NUP19IJ2K3", "1bXPegzsGClvoGAiJdHQD1uOW2sQBLAZM9j9VtXR", mockClient) + if err != nil { + return nil, err + } + + return &cephobject.AdminOpsContext{ + Context: *msContext, + AdminOpsUserAccessKey: "53S6B9S809NUP19IJ2K3", + AdminOpsUserSecretKey: "1bXPegzsGClvoGAiJdHQD1uOW2sQBLAZM9j9VtXR", + AdminOpsClient: adminClient, + }, nil + } + defer func() { + newMultisiteAdminOpsCtxFunc = cephobject.NewMultisiteAdminOpsContext + }() + + res, err := freshR.Reconcile(ctx, req) + assert.NoError(t, err) + assert.False(t, res.Requeue) + err = freshR.client.Get(ctx, req.NamespacedName, freshAccount) + assert.NoError(t, err) + assert.Equal(t, k8sutil.ReadyStatus, freshAccount.Status.Phase) + assert.Equal(t, deterministicID, freshAccount.Status.AccountID) + }) +} + +func TestGetAccountName(t *testing.T) { + t.Run("returns spec name when set", func(t *testing.T) { + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "cr-name"}, + Spec: cephv1.ObjectStoreAccountSpec{Name: "spec-name"}, + } + assert.Equal(t, "spec-name", getAccountName(account)) + }) + + t.Run("falls back to CR name when spec name is empty", func(t *testing.T) { + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{Name: "cr-name"}, + Spec: cephv1.ObjectStoreAccountSpec{}, + } + assert.Equal(t, "cr-name", getAccountName(account)) + }) +} + +func TestGenerateDeterministicAccountID(t *testing.T) { + t.Run("produces valid RGW account ID format", func(t *testing.T) { + id, err := generateDeterministicAccountID("a675dfa7-a785-40a3-b690-eb1e33c1dbdb") + assert.NoError(t, err) + assert.Equal(t, "RGW", id[:3]) + assert.Len(t, id, 20) // "RGW" + 17 digits + for _, c := range id[3:] { + assert.True(t, c >= '0' && c <= '9', "expected digit, got %c", c) + } + }) + + t.Run("is deterministic for same UID", func(t *testing.T) { + id1, err := generateDeterministicAccountID("a675dfa7-a785-40a3-b690-eb1e33c1dbdb") + assert.NoError(t, err) + id2, err := generateDeterministicAccountID("a675dfa7-a785-40a3-b690-eb1e33c1dbdb") + assert.NoError(t, err) + assert.Equal(t, id1, id2) + }) + + t.Run("produces different IDs for different UIDs", func(t *testing.T) { + id1, err := generateDeterministicAccountID("a675dfa7-a785-40a3-b690-eb1e33c1dbdb") + assert.NoError(t, err) + id2, err := generateDeterministicAccountID("f47ac10b-58cc-4372-a567-0e02b2c3d479") + assert.NoError(t, err) + assert.NotEqual(t, id1, id2) + }) + + t.Run("returns error for UID too short", func(t *testing.T) { + _, err := generateDeterministicAccountID("short") + assert.Error(t, err) + }) + + t.Run("returns error for non-hex UID", func(t *testing.T) { + _, err := generateDeterministicAccountID("zzzzzzzzzzzzzz") + assert.Error(t, err) + }) +} + +func TestGetAccountID(t *testing.T) { + t.Run("returns spec account ID when set", func(t *testing.T) { + account := &cephv1.CephObjectStoreAccount{ + Spec: cephv1.ObjectStoreAccountSpec{AccountID: "RGW11111111111111111"}, + Status: &cephv1.ObjectStoreAccountStatus{ + AccountID: "RGW22222222222222222", + }, + } + assert.Equal(t, "RGW11111111111111111", getAccountID(account)) + }) + + t.Run("returns status account ID when spec is empty", func(t *testing.T) { + account := &cephv1.CephObjectStoreAccount{ + Spec: cephv1.ObjectStoreAccountSpec{}, + Status: &cephv1.ObjectStoreAccountStatus{ + AccountID: "RGW22222222222222222", + }, + } + assert.Equal(t, "RGW22222222222222222", getAccountID(account)) + }) + + t.Run("does not generate ID from UID", func(t *testing.T) { + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{UID: "d4e5f6a7-b8c9-4d0e-a1f2-3b4c5d6e7f80"}, + Spec: cephv1.ObjectStoreAccountSpec{}, + } + assert.Equal(t, "", getAccountID(account)) + }) + + t.Run("returns empty when no spec or status", func(t *testing.T) { + account := &cephv1.CephObjectStoreAccount{ + Spec: cephv1.ObjectStoreAccountSpec{}, + } + assert.Equal(t, "", getAccountID(account)) + }) + + t.Run("returns empty when status is nil", func(t *testing.T) { + account := &cephv1.CephObjectStoreAccount{ + Spec: cephv1.ObjectStoreAccountSpec{}, + Status: nil, + } + assert.Equal(t, "", getAccountID(account)) + }) +} + +func TestGetOrGenerateAccountID(t *testing.T) { + t.Run("returns existing ID when present", func(t *testing.T) { + account := &cephv1.CephObjectStoreAccount{ + Spec: cephv1.ObjectStoreAccountSpec{AccountID: "RGW11111111111111111"}, + } + id, err := getOrGenerateAccountID(account) + assert.NoError(t, err) + assert.Equal(t, "RGW11111111111111111", id) + }) + + t.Run("generates deterministic ID from UID when no existing ID", func(t *testing.T) { + uid := types.UID("d4e5f6a7-b8c9-4d0e-a1f2-3b4c5d6e7f80") + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{UID: uid}, + Spec: cephv1.ObjectStoreAccountSpec{}, + } + expected, err := generateDeterministicAccountID(uid) + assert.NoError(t, err) + id, err := getOrGenerateAccountID(account) + assert.NoError(t, err) + assert.Equal(t, expected, id) + }) + + t.Run("spec ID takes priority over UID-derived ID", func(t *testing.T) { + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + UID: "d4e5f6a7-b8c9-4d0e-a1f2-3b4c5d6e7f80", + }, + Spec: cephv1.ObjectStoreAccountSpec{AccountID: "RGW11111111111111111"}, + } + id, err := getOrGenerateAccountID(account) + assert.NoError(t, err) + assert.Equal(t, "RGW11111111111111111", id) + }) + + t.Run("returns empty when no spec, status, or UID", func(t *testing.T) { + account := &cephv1.CephObjectStoreAccount{ + Spec: cephv1.ObjectStoreAccountSpec{}, + } + id, err := getOrGenerateAccountID(account) + assert.NoError(t, err) + assert.Equal(t, "", id) + }) +} + +func TestReconcileAccount(t *testing.T) { + capnslog.SetGlobalLogLevel(capnslog.DEBUG) + ctx := context.TODO() + + t.Run("create account with UID-derived ID", func(t *testing.T) { + uid := types.UID("a675dfa7-a785-40a3-b690-eb1e33c1dbdb") + expectedID, err := generateDeterministicAccountID(uid) + assert.NoError(t, err) + accountJSON := fmt.Sprintf(`{"id": "%s", "name": "my-account"}`, expectedID) + + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet { + return &http.Response{ + StatusCode: 404, + Body: io.NopCloser(bytes.NewReader([]byte(`{"Code":"NoSuchKey","RequestId":"tx000","HostId":""}`))), + }, nil + } + if req.Method == http.MethodPost { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(accountJSON))), + }, nil + } + return nil, fmt.Errorf("unexpected request: method %q path %q", req.Method, req.URL.Path) + }, + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "access", "secret", mockClient) + assert.NoError(t, err) + + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: uid, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + }, + } + + s := scheme.Scheme + s.AddKnownTypes(cephv1.SchemeGroupVersion, &cephv1.CephObjectStoreAccount{}, &cephv1.CephObjectStoreAccountList{}) + cl := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(account).WithStatusSubresource(account).Build() + r := &ReconcileObjectStoreAccount{ + client: cl, + objContext: &cephobject.AdminOpsContext{ + AdminOpsClient: adminClient, + }, + opManagerContext: ctx, + } + + accountID, err := r.reconcileAccount(account) + assert.NoError(t, err) + assert.Equal(t, expectedID, accountID) + }) + + t.Run("create account with explicit ID", func(t *testing.T) { + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + // First call: GetAccount (should return not found) + if req.Method == http.MethodGet { + return &http.Response{ + StatusCode: 404, + Body: io.NopCloser(bytes.NewReader([]byte(`{"Code":"NoSuchKey","RequestId":"tx000","HostId":""}`))), + }, nil + } + // Second call: CreateAccount + if req.Method == http.MethodPost { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(accountCreateJSON))), + }, nil + } + return nil, fmt.Errorf("unexpected request: method %q path %q", req.Method, req.URL.Path) + }, + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "access", "secret", mockClient) + assert.NoError(t, err) + + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + AccountID: "RGW12345678901234567", + }, + } + + s := scheme.Scheme + s.AddKnownTypes(cephv1.SchemeGroupVersion, &cephv1.CephObjectStoreAccount{}, &cephv1.CephObjectStoreAccountList{}) + cl := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(account).WithStatusSubresource(account).Build() + r := &ReconcileObjectStoreAccount{ + client: cl, + objContext: &cephobject.AdminOpsContext{ + AdminOpsClient: adminClient, + }, + opManagerContext: ctx, + } + + accountID, err := r.reconcileAccount(account) + assert.NoError(t, err) + assert.Equal(t, "RGW12345678901234567", accountID) + // Verify account ID was persisted to status + assert.NotNil(t, account.Status) + assert.Equal(t, "RGW12345678901234567", account.Status.AccountID) + }) + + t.Run("account already exists with status confirming ownership", func(t *testing.T) { + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(accountCreateJSON))), + }, nil + } + if req.Method == http.MethodPut { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(accountCreateJSON))), + }, nil + } + return nil, fmt.Errorf("unexpected request: method %q path %q", req.Method, req.URL.Path) + }, + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "access", "secret", mockClient) + assert.NoError(t, err) + + r := &ReconcileObjectStoreAccount{ + objContext: &cephobject.AdminOpsContext{ + AdminOpsClient: adminClient, + }, + opManagerContext: ctx, + } + + // Status contains the account ID, confirming this CR created the account. + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + AccountID: "RGW12345678901234567", + }, + Status: &cephv1.ObjectStoreAccountStatus{ + AccountID: "RGW12345678901234567", + }, + } + + accountID, err := r.reconcileAccount(account) + assert.NoError(t, err) + assert.Equal(t, "RGW12345678901234567", accountID) + }) + + t.Run("account exists with different name triggers update when ownership confirmed", func(t *testing.T) { + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(`{"id":"RGW12345678901234567","name":"old-name"}`))), + }, nil + } + if req.Method == http.MethodPut { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(accountModifyJSON))), + }, nil + } + return nil, fmt.Errorf("unexpected request: method %q path %q", req.Method, req.URL.Path) + }, + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "access", "secret", mockClient) + assert.NoError(t, err) + + r := &ReconcileObjectStoreAccount{ + objContext: &cephobject.AdminOpsContext{ + AdminOpsClient: adminClient, + }, + opManagerContext: ctx, + } + + // status.AccountID is set, confirming ownership from a previous reconcile. + // The user has since changed the CR name, so the account name needs updating. + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "updated-account", + Namespace: namespace, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + AccountID: "RGW12345678901234567", + }, + Status: &cephv1.ObjectStoreAccountStatus{ + AccountID: "RGW12345678901234567", + }, + } + + accountID, err := r.reconcileAccount(account) + assert.NoError(t, err) + assert.Equal(t, "RGW12345678901234567", accountID) + }) + + t.Run("account uses spec name over CR name", func(t *testing.T) { + uid := types.UID("b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e") + expectedID, err := generateDeterministicAccountID(uid) + assert.NoError(t, err) + accountJSON := fmt.Sprintf(`{"id": "%s", "name": "custom-name"}`, expectedID) + + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet { + return &http.Response{ + StatusCode: 404, + Body: io.NopCloser(bytes.NewReader([]byte(`{"Code":"NoSuchKey","RequestId":"tx000","HostId":""}`))), + }, nil + } + if req.Method == http.MethodPost { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(accountJSON))), + }, nil + } + return nil, fmt.Errorf("unexpected request: method %q path %q", req.Method, req.URL.Path) + }, + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "access", "secret", mockClient) + assert.NoError(t, err) + + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "cr-name", + Namespace: namespace, + UID: uid, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + Name: "custom-name", + }, + } + + s := scheme.Scheme + s.AddKnownTypes(cephv1.SchemeGroupVersion, &cephv1.CephObjectStoreAccount{}, &cephv1.CephObjectStoreAccountList{}) + cl := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(account).WithStatusSubresource(account).Build() + r := &ReconcileObjectStoreAccount{ + client: cl, + objContext: &cephobject.AdminOpsContext{ + AdminOpsClient: adminClient, + }, + opManagerContext: ctx, + } + + accountID, err := r.reconcileAccount(account) + assert.NoError(t, err) + assert.NotEmpty(t, accountID) + }) + + t.Run("account uses ID from status when spec has no ID", func(t *testing.T) { + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(accountCreateJSON))), + }, nil + } + if req.Method == http.MethodPut { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(accountCreateJSON))), + }, nil + } + return nil, fmt.Errorf("unexpected request: method %q path %q", req.Method, req.URL.Path) + }, + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "access", "secret", mockClient) + assert.NoError(t, err) + + r := &ReconcileObjectStoreAccount{ + objContext: &cephobject.AdminOpsContext{ + AdminOpsClient: adminClient, + }, + opManagerContext: ctx, + } + + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + }, + Status: &cephv1.ObjectStoreAccountStatus{ + AccountID: "RGW12345678901234567", + }, + } + + accountID, err := r.reconcileAccount(account) + assert.NoError(t, err) + assert.Equal(t, "RGW12345678901234567", accountID) + }) + + t.Run("crash recovery: finds existing account via status bookmark", func(t *testing.T) { + // Simulate crash recovery: operator persisted the account ID to status, + // created the account, but crashed before setting the phase to Ready. + // On retry, the status provides ownership proof and the ID allows + // finding the existing account. + uid := types.UID("a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d") + expectedID, err := generateDeterministicAccountID(uid) + assert.NoError(t, err) + accountJSON := fmt.Sprintf(`{"id": "%s", "name": "my-account"}`, expectedID) + + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + // GET returns the account that was created before the "crash" + if req.Method == http.MethodGet { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(accountJSON))), + }, nil + } + if req.Method == http.MethodPut { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(accountJSON))), + }, nil + } + return nil, fmt.Errorf("unexpected request: method %q path %q", req.Method, req.URL.Path) + }, + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "access", "secret", mockClient) + assert.NoError(t, err) + + r := &ReconcileObjectStoreAccount{ + objContext: &cephobject.AdminOpsContext{ + AdminOpsClient: adminClient, + }, + opManagerContext: ctx, + } + + // Status has the account ID (persisted before creation), but phase is not Ready + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: uid, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + }, + Status: &cephv1.ObjectStoreAccountStatus{ + AccountID: expectedID, + }, + } + + accountID, err := r.reconcileAccount(account) + assert.NoError(t, err) + assert.Equal(t, expectedID, accountID) + }) + + t.Run("refuses to adopt foreign account without ownership proof", func(t *testing.T) { + // spec.AccountID points to an existing account in Ceph, but there is no + // annotation or status confirming ownership. The controller must refuse + // regardless of whether the account name matches. + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(`{"id":"RGW12345678901234567","name":"foreign-account"}`))), + }, nil + } + return nil, fmt.Errorf("unexpected request: method %q path %q", req.Method, req.URL.Path) + }, + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "access", "secret", mockClient) + assert.NoError(t, err) + + r := &ReconcileObjectStoreAccount{ + objContext: &cephobject.AdminOpsContext{ + AdminOpsClient: adminClient, + }, + opManagerContext: ctx, + } + + // No annotation, no status — no ownership proof + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + AccountID: "RGW12345678901234567", + }, + } + + _, err = r.reconcileAccount(account) + assert.Error(t, err) + assert.Contains(t, err.Error(), "refusing to adopt a foreign account") + }) + + t.Run("refuses to create account when already exists in RGW", func(t *testing.T) { + // Our ID doesn't exist (GET returns 404), but CreateAccount returns + // AccountAlreadyExists — indicating a conflict with a different account + // (e.g., same name). The controller should fail with a clear error + // instead of silently adopting the conflicting account. + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet { + return &http.Response{ + StatusCode: 404, + Body: io.NopCloser(bytes.NewReader([]byte(`{"Code":"NoSuchKey","RequestId":"tx000","HostId":""}`))), + }, nil + } + if req.Method == http.MethodPost { + return &http.Response{ + StatusCode: 409, + Body: io.NopCloser(bytes.NewReader([]byte(`{"Code":"AccountAlreadyExists","RequestId":"tx000","HostId":""}`))), + }, nil + } + return nil, fmt.Errorf("unexpected request: method %q path %q", req.Method, req.URL.Path) + }, + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "access", "secret", mockClient) + assert.NoError(t, err) + + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + AccountID: "RGW12345678901234567", + }, + } + + s := scheme.Scheme + s.AddKnownTypes(cephv1.SchemeGroupVersion, &cephv1.CephObjectStoreAccount{}, &cephv1.CephObjectStoreAccountList{}) + cl := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(account).WithStatusSubresource(account).Build() + r := &ReconcileObjectStoreAccount{ + client: cl, + objContext: &cephobject.AdminOpsContext{ + AdminOpsClient: adminClient, + }, + opManagerContext: ctx, + } + + _, err = r.reconcileAccount(account) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists in RGW") + }) + + t.Run("first reconcile with UID creates account with deterministic ID", func(t *testing.T) { + // First reconcile: no account exists yet, but the deterministic ID + // ensures the same ID is used if creation must be retried. + uid := types.UID("b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e") + expectedID, err := generateDeterministicAccountID(uid) + assert.NoError(t, err) + accountJSON := fmt.Sprintf(`{"id": "%s", "name": "my-account"}`, expectedID) + + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + // GET returns 404 — account does not exist yet + if req.Method == http.MethodGet { + return &http.Response{ + StatusCode: 404, + Body: io.NopCloser(bytes.NewReader([]byte(`{"Code":"NoSuchKey","RequestId":"tx000","HostId":""}`))), + }, nil + } + // POST creates the account + if req.Method == http.MethodPost { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(accountJSON))), + }, nil + } + return nil, fmt.Errorf("unexpected request: method %q path %q", req.Method, req.URL.Path) + }, + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "access", "secret", mockClient) + assert.NoError(t, err) + + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + UID: uid, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + }, + } + + s := scheme.Scheme + s.AddKnownTypes(cephv1.SchemeGroupVersion, &cephv1.CephObjectStoreAccount{}, &cephv1.CephObjectStoreAccountList{}) + cl := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(account).WithStatusSubresource(account).Build() + r := &ReconcileObjectStoreAccount{ + client: cl, + objContext: &cephobject.AdminOpsContext{ + AdminOpsClient: adminClient, + }, + opManagerContext: ctx, + } + + accountID, err := r.reconcileAccount(account) + assert.NoError(t, err) + assert.Equal(t, expectedID, accountID) + // Verify account ID was persisted to status as creation bookmark + assert.NotNil(t, account.Status) + assert.Equal(t, expectedID, account.Status.AccountID) + }) +} + +func TestDeleteAccount(t *testing.T) { + capnslog.SetGlobalLogLevel(capnslog.DEBUG) + ctx := context.TODO() + + t.Run("delete account successfully", func(t *testing.T) { + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodDelete { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(""))), + }, nil + } + return nil, fmt.Errorf("unexpected request: method %q path %q", req.Method, req.URL.Path) + }, + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "access", "secret", mockClient) + assert.NoError(t, err) + + r := &ReconcileObjectStoreAccount{ + objContext: &cephobject.AdminOpsContext{ + AdminOpsClient: adminClient, + }, + opManagerContext: ctx, + } + + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Status: &cephv1.ObjectStoreAccountStatus{ + AccountID: "RGW12345678901234567", + }, + } + + err = r.deleteAccount(account) + assert.NoError(t, err) + }) + + t.Run("delete account not found is idempotent", func(t *testing.T) { + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodDelete { + return &http.Response{ + StatusCode: 404, + Body: io.NopCloser(bytes.NewReader([]byte(`{"Code":"NoSuchKey","RequestId":"tx000","HostId":""}`))), + }, nil + } + return nil, fmt.Errorf("unexpected request: method %q path %q", req.Method, req.URL.Path) + }, + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "access", "secret", mockClient) + assert.NoError(t, err) + + r := &ReconcileObjectStoreAccount{ + objContext: &cephobject.AdminOpsContext{ + AdminOpsClient: adminClient, + }, + opManagerContext: ctx, + } + + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Status: &cephv1.ObjectStoreAccountStatus{ + AccountID: "RGW12345678901234567", + }, + } + + err = r.deleteAccount(account) + assert.NoError(t, err) + }) + + t.Run("skip deletion when no account ID", func(t *testing.T) { + r := &ReconcileObjectStoreAccount{ + opManagerContext: ctx, + } + + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } + + err := r.deleteAccount(account) + assert.NoError(t, err) + }) + + t.Run("skip deletion of foreign account with spec ID but no ownership proof", func(t *testing.T) { + deleteCalled := false + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodDelete { + deleteCalled = true + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(""))), + }, nil + } + return nil, fmt.Errorf("unexpected request: method %q path %q", req.Method, req.URL.Path) + }, + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "access", "secret", mockClient) + assert.NoError(t, err) + + r := &ReconcileObjectStoreAccount{ + objContext: &cephobject.AdminOpsContext{ + AdminOpsClient: adminClient, + }, + opManagerContext: ctx, + } + + // spec.AccountID is set but no status or annotation — no ownership proof + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + AccountID: "RGW12345678901234567", + }, + } + + err = r.deleteAccount(account) + assert.NoError(t, err) + assert.False(t, deleteCalled, "should not call RGW delete for a foreign account") + }) + + t.Run("delete account with status ownership proof", func(t *testing.T) { + deleteCalled := false + mockClient := &cephobject.MockClient{ + MockDo: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodDelete { + deleteCalled = true + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader([]byte(""))), + }, nil + } + return nil, fmt.Errorf("unexpected request: method %q path %q", req.Method, req.URL.Path) + }, + } + adminClient, err := admin.New("rook-ceph-rgw-my-store.mycluster.svc", "access", "secret", mockClient) + assert.NoError(t, err) + + r := &ReconcileObjectStoreAccount{ + objContext: &cephobject.AdminOpsContext{ + AdminOpsClient: adminClient, + }, + opManagerContext: ctx, + } + + // spec.AccountID is set and status confirms ownership + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + AccountID: "RGW12345678901234567", + }, + Status: &cephv1.ObjectStoreAccountStatus{ + AccountID: "RGW12345678901234567", + }, + } + + err = r.deleteAccount(account) + assert.NoError(t, err) + assert.True(t, deleteCalled, "should call RGW delete when status confirms ownership") + }) +} + +func TestUpdateStatus(t *testing.T) { + ctx := context.TODO() + capnslog.SetGlobalLogLevel(capnslog.DEBUG) + + s := scheme.Scheme + s.AddKnownTypes(cephv1.SchemeGroupVersion, + &cephv1.CephObjectStoreAccount{}, &cephv1.CephObjectStoreAccountList{}, + ) + + account := &cephv1.CephObjectStoreAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: cephv1.ObjectStoreAccountSpec{ + Store: store, + }, + } + + cl := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(account).WithStatusSubresource(account).Build() + + r := &ReconcileObjectStoreAccount{ + client: cl, + scheme: s, + opManagerContext: ctx, + recorder: events.NewFakeRecorder(50), + } + + nsName := types.NamespacedName{Name: name, Namespace: namespace} + + t.Run("update status to ready", func(t *testing.T) { + r.updateStatus(int64(1), nsName, k8sutil.ReadyStatus) + updated := &cephv1.CephObjectStoreAccount{} + err := r.client.Get(ctx, nsName, updated) + assert.NoError(t, err) + assert.NotNil(t, updated.Status) + assert.Equal(t, k8sutil.ReadyStatus, updated.Status.Phase) + assert.NotNil(t, updated.Status.ObservedGeneration) + assert.Equal(t, int64(1), *updated.Status.ObservedGeneration) + }) + + t.Run("update status with account ID", func(t *testing.T) { + r.updateStatusWithAccountID(int64(2), nsName, "RGW12345678901234567") + updated := &cephv1.CephObjectStoreAccount{} + err := r.client.Get(ctx, nsName, updated) + assert.NoError(t, err) + assert.NotNil(t, updated.Status) + assert.Equal(t, k8sutil.ReadyStatus, updated.Status.Phase) + assert.Equal(t, "RGW12345678901234567", updated.Status.AccountID) + assert.NotNil(t, updated.Status.ObservedGeneration) + assert.Equal(t, int64(2), *updated.Status.ObservedGeneration) + }) + + t.Run("update status for nonexistent resource does not panic", func(t *testing.T) { + missingName := types.NamespacedName{Name: "does-not-exist", Namespace: namespace} + assert.NotPanics(t, func() { + r.updateStatus(int64(1), missingName, k8sutil.ReadyStatus) + }) + assert.NotPanics(t, func() { + r.updateStatusWithAccountID(int64(1), missingName, "some-id") + }) + }) +} + +func TestReconcileObjectStoreAccountNotFound(t *testing.T) { + ctx := context.TODO() + capnslog.SetGlobalLogLevel(capnslog.DEBUG) + + s := scheme.Scheme + s.AddKnownTypes(cephv1.SchemeGroupVersion, + &cephv1.CephObjectStoreAccount{}, &cephv1.CephObjectStoreAccountList{}, + &cephv1.CephCluster{}, &cephv1.CephClusterList{}, + ) + + // No account object in the fake client + cl := fake.NewClientBuilder().WithScheme(s).Build() + r := &ReconcileObjectStoreAccount{ + client: cl, + scheme: s, + opManagerContext: ctx, + recorder: events.NewFakeRecorder(50), + } + + req := reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "nonexistent", + Namespace: namespace, + }, + } + + res, err := r.Reconcile(ctx, req) + assert.NoError(t, err) + assert.False(t, res.Requeue) +} diff --git a/pkg/operator/ceph/object/admin.go b/pkg/operator/ceph/object/admin.go index e180e7e86..6bb86d01a 100644 --- a/pkg/operator/ceph/object/admin.go +++ b/pkg/operator/ceph/object/admin.go @@ -114,7 +114,7 @@ const ( RGWAdminOpsUserSecretName = "rgw-admin-ops-user" rgwAdminOpsUserAccessKey = "accessKey" rgwAdminOpsUserSecretKey = "secretKey" - rgwAdminOpsUserCaps = "buckets=*;users=*;usage=read;metadata=read;zone=read" + rgwAdminOpsUserCaps = "accounts=*;buckets=*;users=*;usage=read;metadata=read;zone=read" ) var rgwAdminOpsUserDisplayName = "RGW Admin Ops User" diff --git a/pkg/operator/ceph/object/objectstore.go b/pkg/operator/ceph/object/objectstore.go index 61b73dac3..394cca7ee 100644 --- a/pkg/operator/ceph/object/objectstore.go +++ b/pkg/operator/ceph/object/objectstore.go @@ -43,6 +43,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" validation "k8s.io/apimachinery/pkg/util/validation" + "sigs.k8s.io/controller-runtime/pkg/client" ) const ( @@ -1280,3 +1281,71 @@ func SetDefaultRealm(objContext *Context, realmName string) error { log.NamedInfo(objContext.NsName(), logger, "successfully set realm %q as default", realmName+"/"+objContext.clusterInfo.Namespace) return nil } + +// InitializeObjectStoreContext finds the CephObjectStore by storeName, verifies it +// is initialized (has running RGW pods or is external), creates a multisite context, +// and initializes the admin ops API client. +func InitializeObjectStoreContext( + clusterdContext *clusterd.Context, + clusterInfo *cephclient.ClusterInfo, + k8sClient client.Client, + opManagerContext context.Context, + storeName string, + newAdminOpsCtxFunc func(*Context, *cephv1.ObjectStoreSpec) (*AdminOpsContext, error), +) (*AdminOpsContext, *cephv1.CephObjectStore, error) { + store, err := getObjectStore(k8sClient, opManagerContext, storeName, clusterInfo.Namespace) + if err != nil { + return nil, nil, errors.Wrapf(err, "failed to get object store %q", storeName) + } + + if !store.Spec.IsExternal() { + if err := checkRGWPodsRunning(k8sClient, opManagerContext, clusterInfo.Namespace, storeName); err != nil { + return nil, nil, errors.Wrapf(err, "failed to detect if object store %q is initialized", storeName) + } + } + + objContext, err := NewMultisiteContext(clusterdContext, clusterInfo, store) + if err != nil { + return nil, nil, errors.Wrapf(err, "failed to create multisite context for object store %q", store.Name) + } + + opsContext, err := newAdminOpsCtxFunc(objContext, &store.Spec) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to initialize rgw admin ops client api") + } + + return opsContext, store, nil +} + +func getObjectStore(k8sClient client.Client, ctx context.Context, storeName, namespace string) (*cephv1.CephObjectStore, error) { + store := &cephv1.CephObjectStore{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: storeName, Namespace: namespace}, store); err != nil { + return nil, errors.Wrapf(err, "could not find CephObjectStore %q", storeName) + } + return store, nil +} + +func checkRGWPodsRunning(k8sClient client.Client, ctx context.Context, namespace, storeName string) error { + pods := &v1.PodList{} + listOpts := []client.ListOption{ + client.InNamespace(namespace), + client.MatchingLabels(labelsForRgw(storeName)), + } + + if err := k8sClient.List(ctx, pods, listOpts...); err != nil { + if kerrors.IsNotFound(err) { + return errors.Wrap(err, "no rgw pod could not be found") + } + return errors.Wrap(err, "failed to list rgw pods") + } + + if len(pods.Items) > 0 { + return nil + } + + return errors.New("no rgw pod found") +} + +func labelsForRgw(name string) map[string]string { + return map[string]string{"rgw": name, k8sutil.AppAttr: AppName} +} diff --git a/pkg/operator/ceph/object/user/controller.go b/pkg/operator/ceph/object/user/controller.go index 2981ef09e..58543daaf 100644 --- a/pkg/operator/ceph/object/user/controller.go +++ b/pkg/operator/ceph/object/user/controller.go @@ -493,14 +493,9 @@ func (r *ReconcileObjectStoreUser) createOrUpdateCephUser(u *cephv1.CephObjectSt } func (r *ReconcileObjectStoreUser) initializeObjectStoreContext(u *cephv1.CephObjectStoreUser) error { - err := r.objectStoreInitialized(u) + opsContext, store, err := object.InitializeObjectStoreContext(r.context, r.clusterInfo, r.client, r.opManagerContext, u.Spec.Store, newMultisiteAdminOpsCtxFunc) if err != nil { - return errors.Wrapf(err, "failed to detect if object store %q is initialized", u.Spec.Store) - } - - store, err := r.getObjectStore(u) - if err != nil { - return errors.Wrapf(err, "failed to get object store %q", u.Spec.Store) + return err } // Check if the object store allows users to be created in any namespace @@ -518,16 +513,6 @@ func (r *ReconcileObjectStoreUser) initializeObjectStoreContext(u *cephv1.CephOb return errors.Wrapf(err, "failed to get CephObjectStore %q advertise endpoint for object store user", u.Spec.Store) } r.advertiseEndpoint = advertiseEndpoint - - objContext, err := object.NewMultisiteContext(r.context, r.clusterInfo, store) - if err != nil { - return errors.Wrapf(err, "Multisite failed to set on object context for object store user") - } - - opsContext, err := newMultisiteAdminOpsCtxFunc(objContext, &store.Spec) - if err != nil { - return errors.Wrap(err, "failed to initialized rgw admin ops client api") - } r.objContext = opsContext return nil @@ -705,36 +690,6 @@ func (r *ReconcileObjectStoreUser) reconcileCephUserSecret(cephObjectStoreUser * return reconcile.Result{}, nil } -func (r *ReconcileObjectStoreUser) objectStoreInitialized(cephObjectStoreUser *cephv1.CephObjectStoreUser) error { - nsName := opcontroller.NsName(cephObjectStoreUser.Namespace, cephObjectStoreUser.Name) - cephObjectStore, err := r.getObjectStore(cephObjectStoreUser) - if err != nil { - return err - } - log.NamedDebug(nsName, logger, "CephObjectStore exists") - - // If the rgw is external just return - // since there are no pods running - if cephObjectStore.Spec.IsExternal() { - return nil - } - - // There are no pods running when the cluster is external - // Unless you pass the admin key... - pods, err := r.getRgwPodList(cephObjectStoreUser) - if err != nil { - return err - } - - // check if at least one pod is running - if len(pods.Items) > 0 { - log.NamedDebug(nsName, logger, "%d RGW pods found where object store user %q is created", len(pods.Items), cephObjectStoreUser.Name) - return nil - } - - return errors.New("no rgw pod found") -} - func (r *ReconcileObjectStoreUser) getObjectStore(user *cephv1.CephObjectStoreUser) (*cephv1.CephObjectStore, error) { storeName := user.Spec.Store nsName := client.ObjectKeyFromObject(user) @@ -759,27 +714,6 @@ func (r *ReconcileObjectStoreUser) getObjectStore(user *cephv1.CephObjectStoreUs return nil, errors.Errorf("could not find CephObjectStore %q referenced by CephObjectStoreUser %q", storeName, nsName) } -func (r *ReconcileObjectStoreUser) getRgwPodList(cephObjectStoreUser *cephv1.CephObjectStoreUser) (*corev1.PodList, error) { - pods := &corev1.PodList{} - - // check if ObjectStore is initialized - // rook does this by starting the RGW pod(s) - listOpts := []client.ListOption{ - client.InNamespace(clusterStoreNamespace(cephObjectStoreUser)), - client.MatchingLabels(labelsForRgw(cephObjectStoreUser.Spec.Store)), - } - - err := r.client.List(r.opManagerContext, pods, listOpts...) - if err != nil { - if kerrors.IsNotFound(err) { - return pods, errors.Wrap(err, "no rgw pod could not be found") - } - return pods, errors.Wrap(err, "failed to list rgw pods") - } - - return pods, nil -} - // Namespace where the object store and cluster are expected to be found func clusterStoreNamespace(user *cephv1.CephObjectStoreUser) string { if user.Spec.ClusterNamespace != "" { @@ -818,10 +752,6 @@ func (r *ReconcileObjectStoreUser) validateUser(u *cephv1.CephObjectStoreUser) e return nil } -func labelsForRgw(name string) map[string]string { - return map[string]string{"rgw": name, k8sutil.AppAttr: appName} -} - // updateStatus updates an object with a given status func (r *ReconcileObjectStoreUser) updateStatus(observedGeneration int64, name types.NamespacedName, status string) { user := &cephv1.CephObjectStoreUser{}