Skip to content

Commit 84900d7

Browse files
committed
refactor(clickhouseuser): introduce generic reconciler for ClickhouseUser
1 parent 0e634b0 commit 84900d7

File tree

12 files changed

+8703
-92
lines changed

12 files changed

+8703
-92
lines changed

.mockery.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
dir: controllers
2+
filename: mocks_test.go
3+
formatter: goimports
4+
structname: "{{.Mock}}{{.InterfaceName}}"
5+
pkgname: "{{.SrcPackageName}}"
6+
recursive: false
7+
require-template-schema-exists: true
8+
template: testify
9+
template-schema: "{{.Template}}.schema.json"
10+
packages:
11+
github.com/aiven/aiven-operator/controllers:
12+
config:
13+
all: true

controllers/basic_controller.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ type (
4949
Scheme *runtime.Scheme
5050
Recorder record.EventRecorder
5151
DefaultToken string
52-
AvGenClient avngen.Client
5352
KubeVersion string
5453
OperatorVersion string
5554
}
@@ -104,6 +103,7 @@ const (
104103
eventWaitingForTheInstanceToBeRunning = "WaitingForInstanceToBeRunning"
105104
eventUnableToWaitForInstanceToBeRunning = "UnableToWaitForInstanceToBeRunning"
106105
eventInstanceIsRunning = "InstanceIsRunning"
106+
eventUnableToSyncConnectionSecret = "UnableToSyncConnectionSecret"
107107
eventConnInfoSecretCreationDisabled = "ConnInfoSecretCreationDisabled"
108108
)
109109

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
// Copyright (c) 2024 Aiven, Helsinki, Finland. https://aiven.io/
2+
3+
package controllers
4+
5+
import (
6+
"context"
7+
"errors"
8+
"fmt"
9+
"strconv"
10+
11+
avngen "github.com/aiven/go-client-codegen"
12+
"github.com/aiven/go-client-codegen/handler/clickhouse"
13+
"github.com/aiven/go-client-codegen/handler/service"
14+
"github.com/go-logr/logr"
15+
"k8s.io/apimachinery/pkg/api/meta"
16+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
17+
"sigs.k8s.io/controller-runtime/pkg/client"
18+
19+
"github.com/aiven/aiven-operator/api/v1alpha1"
20+
)
21+
22+
//+kubebuilder:rbac:groups=aiven.io,resources=clickhouseusers,verbs=get;list;watch;create;update;patch;delete
23+
//+kubebuilder:rbac:groups=aiven.io,resources=clickhouseusers/status,verbs=get;update;patch
24+
//+kubebuilder:rbac:groups=aiven.io,resources=clickhouseusers/finalizers,verbs=get;create;update
25+
26+
// ClickhouseUserControllerV2 reconciles a ClickhouseUser object
27+
type ClickhouseUserControllerV2 struct {
28+
client.Client
29+
avnGen avngen.Client
30+
}
31+
32+
func newClickhouseUserReconcilerV2(c Controller) reconcilerType {
33+
return &Reconciler[*v1alpha1.ClickhouseUser]{
34+
Controller: c,
35+
newAivenGeneratedClient: NewAivenGeneratedClient,
36+
newObj: func() *v1alpha1.ClickhouseUser {
37+
return &v1alpha1.ClickhouseUser{}
38+
},
39+
newController: func(avnGen avngen.Client) AivenController[*v1alpha1.ClickhouseUser] {
40+
return &ClickhouseUserControllerV2{
41+
Client: c.Client,
42+
avnGen: avnGen,
43+
}
44+
},
45+
}
46+
}
47+
48+
func (r *ClickhouseUserControllerV2) Observe(ctx context.Context, user *v1alpha1.ClickhouseUser) (Observation, error) {
49+
obs := Observation{
50+
ResourceExists: false,
51+
ResourceUpToDate: false,
52+
SecretDetails: nil,
53+
}
54+
55+
check, err := checkServiceIsOperational(ctx, r.avnGen, user.Spec.Project, user.Spec.ServiceName)
56+
if err != nil {
57+
if errors.Is(err, errServicePoweredOff) {
58+
return obs, fmt.Errorf("%w: %w", errPreconditionNotMet, err)
59+
}
60+
return obs, err
61+
}
62+
if !check {
63+
return obs, errPreconditionNotMet
64+
}
65+
66+
list, err := r.avnGen.ServiceClickHouseUserList(ctx, user.Spec.Project, user.Spec.ServiceName)
67+
if err != nil {
68+
return obs, err
69+
}
70+
for _, u := range list {
71+
if u.Name == user.GetUsername() {
72+
obs.ResourceExists = true
73+
user.Status.UUID = u.Uuid
74+
break
75+
}
76+
}
77+
78+
if obs.ResourceExists {
79+
// TODO: extend the logic with more checks if needed
80+
obs.ResourceUpToDate = IsReadyToUse(user)
81+
}
82+
83+
return obs, nil
84+
}
85+
86+
// Create implements AivenClient for ClickhouseUser.
87+
func (r *ClickhouseUserControllerV2) Create(ctx context.Context, user *v1alpha1.ClickhouseUser) (CreateResult, error) {
88+
logr.FromContextOrDiscard(ctx).Info("generation wasn't processed, creation or updating instance on aiven side")
89+
a := user.GetAnnotations()
90+
delete(a, processedGenerationAnnotation)
91+
delete(a, instanceIsRunningAnnotation)
92+
93+
// Validates the secret password if it exists
94+
_, err := GetPasswordFromSecret(ctx, r.Client, user)
95+
if err != nil {
96+
return CreateResult{}, fmt.Errorf("failed to get password from secret: %w", err)
97+
}
98+
99+
if user.Status.UUID == "" {
100+
req := clickhouse.ServiceClickHouseUserCreateIn{
101+
Name: user.GetUsername(),
102+
}
103+
rsp, err := r.avnGen.ServiceClickHouseUserCreate(ctx, user.Spec.Project, user.Spec.ServiceName, &req)
104+
if err != nil {
105+
logr.FromContextOrDiscard(ctx).Info(
106+
"unable to create or update %s: %s/%s, retrying: %s",
107+
user.GetObjectKind().GroupVersionKind().Kind,
108+
user.GetNamespace(),
109+
user.GetName(),
110+
err,
111+
)
112+
return CreateResult{}, err
113+
}
114+
user.Status.UUID = rsp.Uuid
115+
}
116+
117+
logr.FromContextOrDiscard(ctx).Info(
118+
"processed instance, updating annotations",
119+
"generation", user.GetGeneration(),
120+
"annotations", user.GetAnnotations(),
121+
)
122+
123+
// We don't really know if an instance is created or updated because:
124+
// 1. The go client's retry mechanism may create an instance successfully but receive a 5xx error,
125+
// causing the next attempt to get a conflict error
126+
// 2. Remote state may be temporarily inconsistent, so GET checks can return false positives/negatives
127+
// 3. Some handlers implement their own retry logic and keep trying to create instances until success
128+
// Therefore, we can't rely on "exists" checks to determine the operation type
129+
const reason = "CreatedOrUpdated"
130+
meta.SetStatusCondition(
131+
user.Conditions(),
132+
getInitializedCondition(
133+
reason,
134+
"Successfully created or updated the instance in Aiven",
135+
),
136+
)
137+
meta.SetStatusCondition(
138+
user.Conditions(),
139+
getRunningCondition(
140+
metav1.ConditionUnknown,
141+
reason,
142+
"Successfully created or updated the instance in Aiven, status remains unknown",
143+
),
144+
)
145+
metav1.SetMetaDataAnnotation(
146+
user.GetObjectMeta(),
147+
processedGenerationAnnotation,
148+
strconv.FormatInt(user.GetGeneration(), formatIntBaseDecimal),
149+
)
150+
151+
return CreateResult{}, nil
152+
}
153+
154+
// Update implements AivenClient for ClickhouseUser.
155+
func (r *ClickhouseUserControllerV2) Update(ctx context.Context, user *v1alpha1.ClickhouseUser) (UpdateResult, error) {
156+
logr.FromContextOrDiscard(ctx).Info("checking if instance is ready")
157+
158+
// Needs to be before o.NoSecret() check because `get` mutates the object's metadata annotations.
159+
// It set the instanceIsRunningAnnotation annotation when the instance is running on Aiven's side.
160+
s, err := r.avnGen.ServiceGet(ctx, user.Spec.Project, user.Spec.ServiceName, service.ServiceGetIncludeSecrets(true))
161+
if err != nil {
162+
return UpdateResult{}, err
163+
}
164+
165+
// User can set password in the secret
166+
secretPassword, err := GetPasswordFromSecret(ctx, r.Client, user)
167+
if err != nil {
168+
return UpdateResult{}, fmt.Errorf("failed to get password from secret: %w", err)
169+
}
170+
171+
// By design, this handler can't create secret in createOrUpdate method, while the password is returned on create only.
172+
// The only way to have a secret here is to reset it manually
173+
req := clickhouse.ServiceClickHousePasswordResetIn{}
174+
if secretPassword != "" {
175+
req.Password = &secretPassword
176+
}
177+
178+
password, err := r.avnGen.ServiceClickHousePasswordReset(ctx, user.Spec.Project, user.Spec.ServiceName, user.Status.UUID, &req)
179+
if err != nil {
180+
return UpdateResult{}, err
181+
}
182+
183+
prefix := getSecretPrefix(user)
184+
stringData := map[string]string{
185+
prefix + "HOST": s.ServiceUriParams["host"],
186+
prefix + "PORT": s.ServiceUriParams["port"],
187+
prefix + "PASSWORD": password,
188+
prefix + "USERNAME": user.GetUsername(),
189+
// todo: remove in future releases
190+
"HOST": s.ServiceUriParams["host"],
191+
"PORT": s.ServiceUriParams["port"],
192+
"PASSWORD": password,
193+
"USERNAME": user.GetUsername(),
194+
}
195+
196+
meta.SetStatusCondition(&user.Status.Conditions,
197+
getRunningCondition(metav1.ConditionTrue, "CheckRunning",
198+
"Instance is running on Aiven side"))
199+
metav1.SetMetaDataAnnotation(&user.ObjectMeta, instanceIsRunningAnnotation, "true")
200+
201+
result := UpdateResult{
202+
SecretDetails: make(map[string][]byte, len(stringData)),
203+
}
204+
for k, v := range stringData {
205+
result.SecretDetails[k] = []byte(v)
206+
}
207+
208+
return result, nil
209+
}
210+
211+
// Delete implements AivenClient for ClickhouseUser.
212+
// It mirrors the current delete behaviour used in reconcile2.
213+
func (r *ClickhouseUserControllerV2) Delete(ctx context.Context, user *v1alpha1.ClickhouseUser) error {
214+
// Not processed yet
215+
if user.Status.UUID == "" {
216+
return nil
217+
}
218+
219+
// skip deletion for built-in users that cannot be deleted
220+
if isBuiltInUser(user.Name) {
221+
// built-in users like 'default' cannot be deleted, this is expected behavior
222+
// we consider this a successful deletion since we can't and shouldn't delete built-in users
223+
return nil
224+
}
225+
226+
err := r.avnGen.ServiceClickHouseUserDelete(ctx, user.Spec.Project, user.Spec.ServiceName, user.Status.UUID)
227+
if !isNotFound(err) {
228+
return err
229+
}
230+
231+
return nil
232+
}
Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
package managed
1+
package controllers
22

33
import (
44
"context"
5+
"errors"
56

67
"github.com/aiven/aiven-operator/api/v1alpha1"
78
)
89

9-
// AivenClient manages the lifecycle of a resource.
10+
// AivenController manages the lifecycle of a resource.
1011
// Controllers implement this interface to define how to process their specific resource type.
11-
type AivenClient[T v1alpha1.AivenManagedObject] interface {
12+
// Implementations are expected to update status fields and annotations directly on obj.
13+
type AivenController[T v1alpha1.AivenManagedObject] interface {
1214
// Observe the external resource and return its current state.
1315
// This method should:
1416
// - Check if the resource exists on Aiven side
@@ -22,18 +24,33 @@ type AivenClient[T v1alpha1.AivenManagedObject] interface {
2224

2325
// Create a new resource.
2426
// This is called when Observe indicates the resource doesn't exist.
25-
Create(ctx context.Context, obj T) error
27+
// It may return optional information about the created external resource (for example, connection details).
28+
Create(ctx context.Context, obj T) (CreateResult, error)
2629

2730
// Update an existing resource.
2831
// This is called when Observe indicates the resource exists but is not up-to-date.
29-
Update(ctx context.Context, obj T) error
32+
Update(ctx context.Context, obj T) (UpdateResult, error)
3033

3134
// Delete the resource.
3235
// This is called when the Kubernetes object is being deleted.
3336
// If the resource is already deleted (not found), should return nil.
3437
Delete(ctx context.Context, obj T) error
3538
}
3639

40+
// CreateResult is returned from Create and carries optional information about the created external resource (for example, connection details).
41+
type CreateResult struct {
42+
// SecretDetails contains secret data for the resource (credentials, endpoints, CA certs, etc.).
43+
// Will be written to the connInfoSecretTarget if not nil and not empty.
44+
SecretDetails map[string][]byte
45+
}
46+
47+
// UpdateResult is returned from Update and carries optional information about the external resource (for example, connection details).
48+
type UpdateResult struct {
49+
// SecretDetails contains secret data for the resource (credentials, endpoints, CA certs, etc.).
50+
// Will be written to the connInfoSecretTarget if not nil and not empty.
51+
SecretDetails map[string][]byte
52+
}
53+
3754
// Observation is the result of observing the resource.
3855
// Can be extended with additional fields as needed.
3956
type Observation struct {
@@ -44,28 +61,11 @@ type Observation struct {
4461
// Only meaningful when ResourceExists is true.
4562
ResourceUpToDate bool
4663

47-
// ResourceReady indicates whether the resource is in a ready/running state.
48-
// This is used to determine when to create the connection secret and mark the resource as ready.
49-
ResourceReady bool
50-
51-
// IsPoweredOff indicates whether the resource is currently powered off.
52-
IsPoweredOff bool
53-
54-
// PreconditionsMet indicates whether all preconditions for the resource are satisfied.
55-
PreconditionsMet bool
56-
57-
// PreconditionError contains the error if preconditions are not met.
58-
// This error will be set in status conditions.
59-
// Should be nil if PreconditionsMet is true.
60-
PreconditionError error
61-
6264
// SecretDetails contains secret data for the resource (credentials, endpoints, CA certs, etc.).
6365
// Will be written to the connInfoSecretTarget if not nil and not empty.
6466
// Keys should NOT include prefixes - the reconciler will apply the appropriate prefix.
6567
// Example keys: "HOST", "PORT", "USERNAME", "PASSWORD", "CA_CERT"
6668
SecretDetails map[string][]byte
67-
68-
// Metadata contains additional observed information that should be stored in the status.
69-
// The reconciler may update status fields based on this data.
70-
Metadata map[string]string
7169
}
70+
71+
var errPreconditionNotMet = errors.New("preconditions are not met")

controllers/common.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,3 +340,26 @@ func isServerError(err error) bool {
340340
}
341341
return false
342342
}
343+
344+
// isRetryableAivenError returns true if the error represents a transient Aiven API failure that should be retried by the reconciler.
345+
//
346+
// Current policy:
347+
// - 404: resource may not be visible yet (eventual consistency).
348+
// - 5xx: server-side issues are considered transient.
349+
// - 403: eventual consistency in IAM / permissions.
350+
func isRetryableAivenError(err error) bool {
351+
if err == nil {
352+
return false
353+
}
354+
355+
switch {
356+
case isNotFound(err):
357+
return true
358+
case isServerError(err):
359+
return true
360+
case isAivenError(err, http.StatusForbidden):
361+
return true
362+
default:
363+
return false
364+
}
365+
}

controllers/common_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ import (
1212
func TestCreateEmptyUserConfiguration(t *testing.T) {
1313
var uc *kafkaconnectuserconfig.KafkaConnectUserConfig
1414
m, err := CreateUserConfiguration(uc)
15-
assert.Nil(t, m)
15+
assert.Empty(t, m)
1616
assert.NoError(t, err)
1717
}

0 commit comments

Comments
 (0)