forked from rook/rook
1797 lines
60 KiB
Go
1797 lines
60 KiB
Go
/*
|
|
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"
|
|
"strings"
|
|
"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"
|
|
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/kubernetes/scheme"
|
|
"k8s.io/client-go/tools/events"
|
|
"k8s.io/utils/ptr"
|
|
"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)
|
|
rootUserJSON := fmt.Sprintf(`{"user_id": "c1a2b3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "display_name": "root-my-account", "account_id": "%s", "keys": [{"access_key": "ROOT_ACCESS_KEY", "secret_key": "ROOT_SECRET_KEY"}]}`, deterministicID)
|
|
newMultisiteAdminOpsCtxFunc = func(objContext *cephobject.Context, spec *cephv1.ObjectStoreSpec) (*cephobject.AdminOpsContext, error) {
|
|
mockClient := &cephobject.MockClient{
|
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
|
isUserAPI := strings.HasSuffix(req.URL.Path, "/admin/user")
|
|
isAccountAPI := strings.HasSuffix(req.URL.Path, "/admin/account")
|
|
|
|
if isAccountAPI {
|
|
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(newAccountJSON))),
|
|
}, nil
|
|
}
|
|
}
|
|
if isUserAPI {
|
|
if req.Method == http.MethodGet {
|
|
return &http.Response{
|
|
StatusCode: 404,
|
|
Body: io.NopCloser(bytes.NewReader([]byte(`{"Code":"NoSuchUser","RequestId":"tx000","HostId":""}`))),
|
|
}, nil
|
|
}
|
|
if req.Method == http.MethodPut {
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewReader([]byte(rootUserJSON))),
|
|
}, 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",
|
|
// #nosec G101 -- fake test credential
|
|
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)
|
|
assert.Equal(t, "rook-ceph-object-root-user-my-account", freshAccount.Status.RootAccountSecretName)
|
|
|
|
// Verify the root user secret was created
|
|
rootSecret := &corev1.Secret{}
|
|
err = freshR.client.Get(ctx, types.NamespacedName{Name: "rook-ceph-object-root-user-my-account", Namespace: namespace}, rootSecret)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "ROOT_ACCESS_KEY", rootSecret.StringData["AccessKey"])
|
|
assert.Equal(t, "ROOT_SECRET_KEY", rootSecret.StringData["SecretKey"])
|
|
})
|
|
}
|
|
|
|
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 and root user successfully", func(t *testing.T) {
|
|
userDeleteCalled := false
|
|
accountDeleteCalled := false
|
|
mockClient := &cephobject.MockClient{
|
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
|
if req.Method == http.MethodDelete && strings.HasSuffix(req.URL.Path, "/admin/user") {
|
|
userDeleteCalled = true
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewReader([]byte(""))),
|
|
}, nil
|
|
}
|
|
if req.Method == http.MethodDelete && strings.HasSuffix(req.URL.Path, "/admin/account") {
|
|
accountDeleteCalled = 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,
|
|
}
|
|
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: name,
|
|
Namespace: namespace,
|
|
UID: "test-uid",
|
|
},
|
|
Status: &cephv1.ObjectStoreAccountStatus{
|
|
AccountID: "RGW12345678901234567",
|
|
},
|
|
}
|
|
|
|
err = r.deleteAccount(account)
|
|
assert.NoError(t, err)
|
|
assert.True(t, userDeleteCalled, "should delete root user")
|
|
assert.True(t, accountDeleteCalled, "should delete account")
|
|
})
|
|
|
|
t.Run("delete account with skipCreate root user still attempts root user deletion", func(t *testing.T) {
|
|
userDeleteCalled := false
|
|
accountDeleteCalled := false
|
|
mockClient := &cephobject.MockClient{
|
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
|
if req.Method == http.MethodDelete && strings.HasSuffix(req.URL.Path, "/admin/user") {
|
|
userDeleteCalled = true
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewReader([]byte(""))),
|
|
}, nil
|
|
}
|
|
if req.Method == http.MethodDelete && strings.HasSuffix(req.URL.Path, "/admin/account") {
|
|
accountDeleteCalled = 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,
|
|
}
|
|
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: name,
|
|
Namespace: namespace,
|
|
UID: "test-uid",
|
|
},
|
|
Spec: cephv1.ObjectStoreAccountSpec{
|
|
Store: store,
|
|
RootUser: &cephv1.AccountRootUserSpec{
|
|
SkipCreate: ptr.To(true),
|
|
},
|
|
},
|
|
Status: &cephv1.ObjectStoreAccountStatus{
|
|
AccountID: "RGW12345678901234567",
|
|
},
|
|
}
|
|
|
|
err = r.deleteAccount(account)
|
|
assert.NoError(t, err)
|
|
assert.True(t, userDeleteCalled, "should always attempt to delete root user even when skipCreate is true")
|
|
assert.True(t, accountDeleteCalled, "should delete account")
|
|
})
|
|
|
|
t.Run("delete account when root user already gone is idempotent", func(t *testing.T) {
|
|
accountDeleteCalled := false
|
|
mockClient := &cephobject.MockClient{
|
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
|
if req.Method == http.MethodDelete && strings.HasSuffix(req.URL.Path, "/admin/user") {
|
|
return &http.Response{
|
|
StatusCode: 404,
|
|
Body: io.NopCloser(bytes.NewReader([]byte(`{"Code":"NoSuchUser","RequestId":"tx000","HostId":""}`))),
|
|
}, nil
|
|
}
|
|
if req.Method == http.MethodDelete && strings.HasSuffix(req.URL.Path, "/admin/account") {
|
|
accountDeleteCalled = 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,
|
|
}
|
|
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: name,
|
|
Namespace: namespace,
|
|
UID: "test-uid",
|
|
},
|
|
Status: &cephv1.ObjectStoreAccountStatus{
|
|
AccountID: "RGW12345678901234567",
|
|
},
|
|
}
|
|
|
|
err = r.deleteAccount(account)
|
|
assert.NoError(t, err)
|
|
assert.True(t, accountDeleteCalled, "should still delete account even if root user is gone")
|
|
})
|
|
|
|
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 && strings.HasSuffix(req.URL.Path, "/admin/user") {
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewReader([]byte(""))),
|
|
}, nil
|
|
}
|
|
if req.Method == http.MethodDelete && strings.HasSuffix(req.URL.Path, "/admin/account") {
|
|
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,
|
|
UID: "test-uid",
|
|
},
|
|
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")
|
|
})
|
|
}
|
|
|
|
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 and secret name", func(t *testing.T) {
|
|
r.updateStatusWithAccountID(int64(2), nsName, "RGW12345678901234567", "rook-ceph-object-root-user-my-account")
|
|
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.Equal(t, "rook-ceph-object-root-user-my-account", updated.Status.RootAccountSecretName)
|
|
assert.NotNil(t, updated.Status.ObservedGeneration)
|
|
assert.Equal(t, int64(2), *updated.Status.ObservedGeneration)
|
|
})
|
|
|
|
t.Run("update status with empty secret name when root user skipped", func(t *testing.T) {
|
|
r.updateStatusWithAccountID(int64(3), 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, "", updated.Status.RootAccountSecretName)
|
|
})
|
|
|
|
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", "some-secret")
|
|
})
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func TestSkipRootUserCreation(t *testing.T) {
|
|
t.Run("skip when skipCreate is true", func(t *testing.T) {
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
Spec: cephv1.ObjectStoreAccountSpec{
|
|
RootUser: &cephv1.AccountRootUserSpec{SkipCreate: ptr.To(true)},
|
|
},
|
|
}
|
|
assert.True(t, skipRootUserCreation(account))
|
|
})
|
|
|
|
t.Run("do not skip when skipCreate is false", func(t *testing.T) {
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
Spec: cephv1.ObjectStoreAccountSpec{
|
|
RootUser: &cephv1.AccountRootUserSpec{SkipCreate: ptr.To(false)},
|
|
},
|
|
}
|
|
assert.False(t, skipRootUserCreation(account))
|
|
})
|
|
|
|
t.Run("do not skip when rootUser is nil", func(t *testing.T) {
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
Spec: cephv1.ObjectStoreAccountSpec{},
|
|
}
|
|
assert.False(t, skipRootUserCreation(account))
|
|
})
|
|
}
|
|
|
|
func TestGetRootUserID(t *testing.T) {
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
UID: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
|
},
|
|
}
|
|
assert.Equal(t, "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", getRootUserID(account))
|
|
}
|
|
|
|
func TestGetRootUserDisplayName(t *testing.T) {
|
|
t.Run("returns custom display name when set", func(t *testing.T) {
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "my-account", Namespace: "rook-ceph"},
|
|
Spec: cephv1.ObjectStoreAccountSpec{
|
|
RootUser: &cephv1.AccountRootUserSpec{DisplayName: "Custom Root"},
|
|
},
|
|
}
|
|
assert.Equal(t, "Custom Root", getRootUserDisplayName(account))
|
|
})
|
|
|
|
t.Run("returns default display name when not set", func(t *testing.T) {
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "my-account", Namespace: "rook-ceph"},
|
|
Spec: cephv1.ObjectStoreAccountSpec{},
|
|
}
|
|
assert.Equal(t, "root-my-account", getRootUserDisplayName(account))
|
|
})
|
|
|
|
t.Run("returns default display name when rootUser is nil", func(t *testing.T) {
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: "ns"},
|
|
Spec: cephv1.ObjectStoreAccountSpec{},
|
|
}
|
|
assert.Equal(t, "root-test", getRootUserDisplayName(account))
|
|
})
|
|
|
|
t.Run("truncates generated display name to 64 characters", func(t *testing.T) {
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "a-very-long-account-name-that-exceeds-the-sixty-four-character-limit-for-rgw-display-names"},
|
|
Spec: cephv1.ObjectStoreAccountSpec{},
|
|
}
|
|
displayName := getRootUserDisplayName(account)
|
|
assert.LessOrEqual(t, len(displayName), 64)
|
|
assert.Equal(t, "root-a-very-long-account-name-that-exceeds-the-sixty-four-charac", displayName)
|
|
})
|
|
|
|
t.Run("does not truncate user-provided display name", func(t *testing.T) {
|
|
longName := "This is a user-provided display name that is longer than sixty-four characters in total"
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "my-account", Namespace: "rook-ceph"},
|
|
Spec: cephv1.ObjectStoreAccountSpec{
|
|
RootUser: &cephv1.AccountRootUserSpec{DisplayName: longName},
|
|
},
|
|
}
|
|
assert.Equal(t, longName, getRootUserDisplayName(account))
|
|
})
|
|
}
|
|
|
|
func TestGenerateRootUserSecretName(t *testing.T) {
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "my-account"},
|
|
Spec: cephv1.ObjectStoreAccountSpec{Store: "my-store"},
|
|
}
|
|
assert.Equal(t, "rook-ceph-object-root-user-my-account", generateRootUserSecretName(account))
|
|
}
|
|
|
|
func TestReconcileRootUser(t *testing.T) {
|
|
capnslog.SetGlobalLogLevel(capnslog.DEBUG)
|
|
ctx := context.TODO()
|
|
|
|
objectStore := &cephv1.CephObjectStore{
|
|
ObjectMeta: metav1.ObjectMeta{Name: store, Namespace: namespace},
|
|
Spec: cephv1.ObjectStoreSpec{
|
|
Gateway: cephv1.GatewaySpec{Port: 80},
|
|
},
|
|
}
|
|
|
|
t.Run("create root user successfully", func(t *testing.T) {
|
|
uid := types.UID("a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d")
|
|
rootUserJSON := `{"user_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", "display_name": "root-my-account", "keys": [{"access_key": "AK123", "secret_key": "SK456"}]}`
|
|
|
|
mockClient := &cephobject.MockClient{
|
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
|
if req.Method == http.MethodGet && strings.HasSuffix(req.URL.Path, "/admin/user") {
|
|
return &http.Response{
|
|
StatusCode: 404,
|
|
Body: io.NopCloser(bytes.NewReader([]byte(`{"Code":"NoSuchUser","RequestId":"tx000","HostId":""}`))),
|
|
}, nil
|
|
}
|
|
if req.Method == http.MethodPut && strings.HasSuffix(req.URL.Path, "/admin/user") {
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewReader([]byte(rootUserJSON))),
|
|
}, 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).Build()
|
|
r := &ReconcileObjectStoreAccount{
|
|
client: cl,
|
|
scheme: s,
|
|
objContext: &cephobject.AdminOpsContext{
|
|
AdminOpsClient: adminClient,
|
|
Context: cephobject.Context{Endpoint: "http://rook-ceph-rgw-my-store.rook-ceph:80"},
|
|
},
|
|
opManagerContext: ctx,
|
|
}
|
|
|
|
secretName, err := r.reconcileRootUser(account, "RGW12345678901234567", objectStore)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "rook-ceph-object-root-user-my-account", secretName)
|
|
|
|
// Verify the secret was created with correct data
|
|
secret := &corev1.Secret{}
|
|
err = r.client.Get(ctx, types.NamespacedName{Name: secretName, Namespace: namespace}, secret)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "AK123", secret.StringData["AccessKey"])
|
|
assert.Equal(t, "SK456", secret.StringData["SecretKey"])
|
|
assert.Equal(t, "http://rook-ceph-rgw-my-store.rook-ceph:80", secret.StringData["Endpoint"])
|
|
})
|
|
|
|
t.Run("skip root user when skipCreate is true and delete existing root user and secret", func(t *testing.T) {
|
|
deleteCalled := false
|
|
mockClient := &cephobject.MockClient{
|
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
|
if req.Method == http.MethodDelete && strings.HasSuffix(req.URL.Path, "/admin/user") {
|
|
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)
|
|
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: name,
|
|
Namespace: namespace,
|
|
UID: "some-uid",
|
|
},
|
|
Spec: cephv1.ObjectStoreAccountSpec{
|
|
Store: store,
|
|
RootUser: &cephv1.AccountRootUserSpec{SkipCreate: ptr.To(true)},
|
|
},
|
|
}
|
|
|
|
// Pre-create the root user secret so we can verify it gets deleted
|
|
existingSecret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: generateRootUserSecretName(account),
|
|
Namespace: namespace,
|
|
},
|
|
StringData: map[string]string{
|
|
"AccessKey": "AK123",
|
|
"SecretKey": "SK456",
|
|
},
|
|
}
|
|
s := scheme.Scheme
|
|
s.AddKnownTypes(cephv1.SchemeGroupVersion, &cephv1.CephObjectStoreAccount{}, &cephv1.CephObjectStoreAccountList{})
|
|
cl := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(account, existingSecret).Build()
|
|
|
|
r := &ReconcileObjectStoreAccount{
|
|
client: cl,
|
|
objContext: &cephobject.AdminOpsContext{
|
|
AdminOpsClient: adminClient,
|
|
},
|
|
opManagerContext: ctx,
|
|
}
|
|
secretName, err := r.reconcileRootUser(account, "RGW12345678901234567", objectStore)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "", secretName)
|
|
assert.True(t, deleteCalled, "should attempt to delete root user when skipCreate is true")
|
|
|
|
// Verify the secret was deleted
|
|
secret := &corev1.Secret{}
|
|
err = cl.Get(ctx, types.NamespacedName{Name: generateRootUserSecretName(account), Namespace: namespace}, secret)
|
|
assert.Error(t, err)
|
|
assert.True(t, kerrors.IsNotFound(err), "root user secret should be deleted when skipCreate is true")
|
|
})
|
|
|
|
t.Run("skip root user when skipCreate is true and secret does not exist", func(t *testing.T) {
|
|
mockClient := &cephobject.MockClient{
|
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
|
if req.Method == http.MethodDelete && strings.HasSuffix(req.URL.Path, "/admin/user") {
|
|
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)
|
|
|
|
account := &cephv1.CephObjectStoreAccount{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: name,
|
|
Namespace: namespace,
|
|
UID: "some-uid",
|
|
},
|
|
Spec: cephv1.ObjectStoreAccountSpec{
|
|
Store: store,
|
|
RootUser: &cephv1.AccountRootUserSpec{SkipCreate: ptr.To(true)},
|
|
},
|
|
}
|
|
|
|
s := scheme.Scheme
|
|
s.AddKnownTypes(cephv1.SchemeGroupVersion, &cephv1.CephObjectStoreAccount{}, &cephv1.CephObjectStoreAccountList{})
|
|
cl := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(account).Build()
|
|
|
|
r := &ReconcileObjectStoreAccount{
|
|
client: cl,
|
|
objContext: &cephobject.AdminOpsContext{
|
|
AdminOpsClient: adminClient,
|
|
},
|
|
opManagerContext: ctx,
|
|
}
|
|
secretName, err := r.reconcileRootUser(account, "RGW12345678901234567", objectStore)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "", secretName, "should succeed even when secret does not exist")
|
|
})
|
|
|
|
t.Run("update root user display name", func(t *testing.T) {
|
|
uid := types.UID("b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e")
|
|
existingUserJSON := fmt.Sprintf(`{"user_id": "%s", "display_name": "Old Name", "keys": [{"access_key": "AK789", "secret_key": "SK012"}]}`, string(uid))
|
|
updatedUserJSON := fmt.Sprintf(`{"user_id": "%s", "display_name": "New Custom Name", "keys": [{"access_key": "AK789", "secret_key": "SK012"}]}`, string(uid))
|
|
|
|
mockClient := &cephobject.MockClient{
|
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
|
if req.Method == http.MethodGet && strings.HasSuffix(req.URL.Path, "/admin/user") {
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewReader([]byte(existingUserJSON))),
|
|
}, nil
|
|
}
|
|
if req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/admin/user") {
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewReader([]byte(updatedUserJSON))),
|
|
}, 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,
|
|
RootUser: &cephv1.AccountRootUserSpec{DisplayName: "New Custom Name"},
|
|
},
|
|
}
|
|
|
|
s := scheme.Scheme
|
|
s.AddKnownTypes(cephv1.SchemeGroupVersion, &cephv1.CephObjectStoreAccount{}, &cephv1.CephObjectStoreAccountList{})
|
|
cl := fake.NewClientBuilder().WithScheme(s).WithRuntimeObjects(account).Build()
|
|
r := &ReconcileObjectStoreAccount{
|
|
client: cl,
|
|
scheme: s,
|
|
objContext: &cephobject.AdminOpsContext{
|
|
AdminOpsClient: adminClient,
|
|
Context: cephobject.Context{Endpoint: "http://rook-ceph-rgw-my-store.rook-ceph:80"},
|
|
},
|
|
opManagerContext: ctx,
|
|
}
|
|
|
|
secretName, err := r.reconcileRootUser(account, "RGW12345678901234567", objectStore)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "rook-ceph-object-root-user-my-account", secretName)
|
|
})
|
|
|
|
t.Run("always updates even when display name matches", func(t *testing.T) {
|
|
uid := types.UID("c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f")
|
|
expectedDisplayName := fmt.Sprintf("root-%s", name)
|
|
existingUserJSON := fmt.Sprintf(`{"user_id": "%s", "display_name": "%s", "keys": [{"access_key": "AK111", "secret_key": "SK222"}]}`, string(uid), expectedDisplayName)
|
|
|
|
modifyCalled := false
|
|
mockClient := &cephobject.MockClient{
|
|
MockDo: func(req *http.Request) (*http.Response, error) {
|
|
if req.Method == http.MethodGet && strings.HasSuffix(req.URL.Path, "/admin/user") {
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewReader([]byte(existingUserJSON))),
|
|
}, nil
|
|
}
|
|
if req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/admin/user") {
|
|
modifyCalled = true
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(bytes.NewReader([]byte(existingUserJSON))),
|
|
}, 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).Build()
|
|
r := &ReconcileObjectStoreAccount{
|
|
client: cl,
|
|
scheme: s,
|
|
objContext: &cephobject.AdminOpsContext{
|
|
AdminOpsClient: adminClient,
|
|
Context: cephobject.Context{Endpoint: "http://rook-ceph-rgw-my-store.rook-ceph:80"},
|
|
},
|
|
opManagerContext: ctx,
|
|
}
|
|
|
|
secretName, err := r.reconcileRootUser(account, "RGW12345678901234567", objectStore)
|
|
assert.NoError(t, err)
|
|
assert.NotEmpty(t, secretName)
|
|
assert.True(t, modifyCalled, "should always call modify to ensure desired state")
|
|
})
|
|
}
|