diff --git a/CHANGELOG.md b/CHANGELOG.md index 94aa4575..3a8ba832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ minimum ~~`1`~~ → `0` - Change `Cassandra` field `userConfig.cassandra_version`: enum remove `4` - Change `PostgreSQL` field `userConfig.pg_version`: enum remove `12` +- Add kind: `AlloyDBOmni` ## v0.25.0 - 2024-09-19 diff --git a/PROJECT b/PROJECT index 8212945a..aaef737c 100644 --- a/PROJECT +++ b/PROJECT @@ -11,6 +11,18 @@ plugins: projectName: aiven-operator repo: github.com/aiven/aiven-operator resources: +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: aiven.io + kind: AlloyDBOmni + path: github.com/aiven/aiven-operator/api/v1alpha1 + version: v1alpha1 + webhooks: + defaulting: true + validation: true + webhookVersion: v1 - api: crdVersion: v1 namespaced: true diff --git a/api/v1alpha1/alloydbomni_types.go b/api/v1alpha1/alloydbomni_types.go new file mode 100644 index 00000000..4615180f --- /dev/null +++ b/api/v1alpha1/alloydbomni_types.go @@ -0,0 +1,70 @@ +// Copyright (c) 2024 Aiven, Helsinki, Finland. https://aiven.io/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + alloydbomni "github.com/aiven/aiven-operator/api/v1alpha1/userconfig/service/alloydbomni" +) + +// AlloyDBOmniSpec defines the desired state of AlloyDB Omni instance +type AlloyDBOmniSpec struct { + ServiceCommonSpec `json:",inline"` + + // AlloyDBOmni specific user configuration options + UserConfig *alloydbomni.AlloydbomniUserConfig `json:"userConfig,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// AlloyDBOmni is the Schema for the alloydbomni API. +// Info "Exposes secret keys": `ALLOYDBOMNI_HOST`, `ALLOYDBOMNI_PORT`, `ALLOYDBOMNI_DATABASE`, `ALLOYDBOMNI_USER`, `ALLOYDBOMNI_PASSWORD`, `ALLOYDBOMNI_SSLMODE`, `ALLOYDBOMNI_DATABASE_URI` +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Project",type="string",JSONPath=".spec.project" +// +kubebuilder:printcolumn:name="Region",type="string",JSONPath=".spec.cloudName" +// +kubebuilder:printcolumn:name="Plan",type="string",JSONPath=".spec.plan" +// +kubebuilder:printcolumn:name="State",type="string",JSONPath=".status.state" +type AlloyDBOmni struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AlloyDBOmniSpec `json:"spec,omitempty"` + Status ServiceStatus `json:"status,omitempty"` +} + +var _ AivenManagedObject = &AlloyDBOmni{} + +func (in *AlloyDBOmni) NoSecret() bool { + return in.Spec.ConnInfoSecretTargetDisabled != nil && *in.Spec.ConnInfoSecretTargetDisabled +} + +func (in *AlloyDBOmni) AuthSecretRef() *AuthSecretReference { + return in.Spec.AuthSecretRef +} + +func (in *AlloyDBOmni) Conditions() *[]metav1.Condition { + return &in.Status.Conditions +} + +func (in *AlloyDBOmni) GetRefs() []*ResourceReferenceObject { + return in.Spec.GetRefs(in.GetNamespace()) +} + +func (in *AlloyDBOmni) GetConnInfoSecretTarget() ConnInfoSecretTarget { + return in.Spec.ConnInfoSecretTarget +} + +// +kubebuilder:object:root=true + +// AlloyDBOmniList contains a list of AlloyDBOmni instances +type AlloyDBOmniList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AlloyDBOmni `json:"items"` +} + +func init() { + SchemeBuilder.Register(&AlloyDBOmni{}, &AlloyDBOmniList{}) +} diff --git a/api/v1alpha1/alloydbomni_webhook.go b/api/v1alpha1/alloydbomni_webhook.go new file mode 100644 index 00000000..5e224aab --- /dev/null +++ b/api/v1alpha1/alloydbomni_webhook.go @@ -0,0 +1,62 @@ +// Copyright (c) 2024 Aiven, Helsinki, Finland. https://aiven.io/ + +package v1alpha1 + +import ( + "errors" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" +) + +// log is for logging in this package. +var alloydbomnilog = logf.Log.WithName("alloydbomni-resource") + +func (in *AlloyDBOmni) SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(in). + Complete() +} + +//+kubebuilder:webhook:path=/mutate-aiven-io-v1alpha1-alloydbomni,mutating=true,failurePolicy=fail,groups=aiven.io,resources=alloydbomnis,verbs=create;update,versions=v1alpha1,name=malloydbomni.kb.io,sideEffects=none,admissionReviewVersions=v1 + +var _ webhook.Defaulter = &AlloyDBOmni{} + +// Default implements webhook.Defaulter so a webhook will be registered for the type +func (in *AlloyDBOmni) Default() { + alloydbomnilog.Info("default", "name", in.Name) +} + +//+kubebuilder:webhook:verbs=create;update;delete,path=/validate-aiven-io-v1alpha1-alloydbomni,mutating=false,failurePolicy=fail,groups=aiven.io,resources=alloydbomnis,versions=v1alpha1,name=valloydbomni.kb.io,sideEffects=none,admissionReviewVersions=v1 + +var _ webhook.Validator = &AlloyDBOmni{} + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type +func (in *AlloyDBOmni) ValidateCreate() error { + alloydbomnilog.Info("validate create", "name", in.Name) + + return in.Spec.Validate() +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type +func (in *AlloyDBOmni) ValidateUpdate(old runtime.Object) error { + alloydbomnilog.Info("validate update", "name", in.Name) + return in.Spec.Validate() +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type +func (in *AlloyDBOmni) ValidateDelete() error { + alloydbomnilog.Info("validate delete", "name", in.Name) + + if in.Spec.TerminationProtection != nil && *in.Spec.TerminationProtection { + return errors.New("cannot delete AlloyDBOmni service, termination protection is on") + } + + if in.Spec.ProjectVPCID != "" && in.Spec.ProjectVPCRef != nil { + return errors.New("cannot use both projectVpcId and projectVPCRef") + } + + return nil +} diff --git a/api/v1alpha1/setup_webhooks.go b/api/v1alpha1/setup_webhooks.go index 040892e1..3bef33e3 100644 --- a/api/v1alpha1/setup_webhooks.go +++ b/api/v1alpha1/setup_webhooks.go @@ -10,6 +10,9 @@ func SetupWebhooks(mgr ctrl.Manager) error { if err := (&Project{}).SetupWebhookWithManager(mgr); err != nil { return fmt.Errorf("webhook Project: %w", err) } + if err := (&AlloyDBOmni{}).SetupWebhookWithManager(mgr); err != nil { + return fmt.Errorf("webhook AlloyDBOmni: %w", err) + } if err := (&PostgreSQL{}).SetupWebhookWithManager(mgr); err != nil { return fmt.Errorf("webhook PostgreSQL: %w", err) } diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 4e22cb22..458b5f35 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -2,6 +2,7 @@ # since it depends on service name and namespace that are out of this kustomize package. # It should be run by config/default resources: + - bases/aiven.io_alloydbomnis.yaml - bases/aiven.io_clickhouses.yaml - bases/aiven.io_clickhouseusers.yaml - bases/aiven.io_connectionpools.yaml diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 8dc58df7..fe1e5e2b 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -1,5 +1,6 @@ ## Append samples you want in your CSV to this file as resources ## resources: + - _v1alpha1_alloydbomni.yaml - _v1alpha1_clickhouse.yaml - _v1alpha1_clickhouseuser.yaml - _v1alpha1_connectionpool.yaml diff --git a/controllers/alloydbomni_controller.go b/controllers/alloydbomni_controller.go new file mode 100644 index 00000000..9ba6e9e8 --- /dev/null +++ b/controllers/alloydbomni_controller.go @@ -0,0 +1,97 @@ +// Copyright (c) 2024 Aiven, Helsinki, Finland. https://aiven.io/ + +package controllers + +import ( + "context" + "fmt" + + "github.com/aiven/aiven-go-client/v2" + avngen "github.com/aiven/go-client-codegen" + "github.com/aiven/go-client-codegen/handler/service" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/aiven/aiven-operator/api/v1alpha1" +) + +// AlloyDBOmniReconciler reconciles a AlloyDBOmni object +type AlloyDBOmniReconciler struct { + Controller +} + +func newAlloyDBOmniReconciler(c Controller) reconcilerType { + return &AlloyDBOmniReconciler{Controller: c} +} + +//+kubebuilder:rbac:groups=aiven.io,resources=alloydbomnis,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=aiven.io,resources=alloydbomnis/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=aiven.io,resources=alloydbomnis/finalizers,verbs=get;create;update + +func (r *AlloyDBOmniReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + return r.reconcileInstance(ctx, req, newGenericServiceHandler(newAlloyDBOmniAdapter), &v1alpha1.AlloyDBOmni{}) +} + +func (r *AlloyDBOmniReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.AlloyDBOmni{}). + Owns(&corev1.Secret{}). + Complete(r) +} + +func newAlloyDBOmniAdapter(_ *aiven.Client, object client.Object) (serviceAdapter, error) { + adbo, ok := object.(*v1alpha1.AlloyDBOmni) + if !ok { + return nil, fmt.Errorf("object is not of type v1alpha1.AlloyDBOmni") + } + return &alloyDBOmniAdapter{adbo}, nil +} + +// alloyDBOmniAdapter handles an Aiven AlloyDBOmni service +type alloyDBOmniAdapter struct { + *v1alpha1.AlloyDBOmni +} + +func (a *alloyDBOmniAdapter) getObjectMeta() *metav1.ObjectMeta { + return &a.ObjectMeta +} + +func (a *alloyDBOmniAdapter) getServiceStatus() *v1alpha1.ServiceStatus { + return &a.Status +} + +func (a *alloyDBOmniAdapter) getServiceCommonSpec() *v1alpha1.ServiceCommonSpec { + return &a.Spec.ServiceCommonSpec +} + +func (a *alloyDBOmniAdapter) getUserConfig() any { + return a.Spec.UserConfig +} + +func (a *alloyDBOmniAdapter) newSecret(ctx context.Context, s *service.ServiceGetOut) (*corev1.Secret, error) { + stringData := map[string]string{ + "HOST": s.ServiceUriParams["host"], + "PORT": s.ServiceUriParams["port"], + "DATABASE": s.ServiceUriParams["dbname"], + "USER": s.ServiceUriParams["user"], + "PASSWORD": s.ServiceUriParams["password"], + "SSLMODE": s.ServiceUriParams["sslmode"], + "DATABASE_URI": s.ServiceUri, + } + + return newSecret(a, stringData, true), nil +} + +func (a *alloyDBOmniAdapter) getServiceType() string { + return "alloydbomni" +} + +func (a *alloyDBOmniAdapter) getDiskSpace() string { + return a.Spec.DiskSpace +} + +func (a *alloyDBOmniAdapter) performUpgradeTaskIfNeeded(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { + return nil +} diff --git a/controllers/setup.go b/controllers/setup.go index 1650214d..c7995519 100644 --- a/controllers/setup.go +++ b/controllers/setup.go @@ -24,6 +24,7 @@ func SetupControllers(mgr ctrl.Manager, defaultToken, kubeVersion, operatorVersi } builders := map[string]reconcilerBuilder{ + "AlloyDBOmni": newAlloyDBOmniReconciler, "Cassandra": newCassandraReconciler, "Clickhouse": newClickhouseReconciler, "ClickhouseDatabase": newClickhouseDatabaseReconciler, diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index b0f1f1f2..2e35ce4e 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -88,6 +88,7 @@ nav: - resources/kafka/connect.md - API Reference: - api-reference/index.md + - api-reference/alloydbomni.md - api-reference/cassandra.md - api-reference/clickhouse.md - api-reference/clickhousedatabase.md diff --git a/main.go b/main.go index 27252e06..82f4d859 100644 --- a/main.go +++ b/main.go @@ -24,7 +24,7 @@ import ( //+kubebuilder:scaffold:imports ) -//go:generate go run ./generators/userconfigs/... --services mysql,cassandra,grafana,pg,kafka,redis,clickhouse,opensearch,kafka_connect +//go:generate go run ./generators/userconfigs/... --services alloydbomni,mysql,cassandra,grafana,pg,kafka,redis,clickhouse,opensearch,kafka_connect //go:generate go run ./generators/userconfigs/... --integrations autoscaler,clickhouse_kafka,clickhouse_postgresql,datadog,kafka_connect,kafka_logs,kafka_mirrormaker,logs,metrics,external_aws_cloudwatch_metrics //go:generate go run ./generators/userconfigs/... --integration-endpoints autoscaler,datadog,external_aws_cloudwatch_logs,external_aws_cloudwatch_metrics,external_elasticsearch_logs,external_google_cloud_bigquery,external_google_cloud_logging,external_kafka,external_opensearch_logs,external_postgresql,external_schema_registry,jolokia,prometheus,rsyslog diff --git a/test/e2e/alloydbomni/alloydbomni-simple-cluster/00-secret.yaml b/test/e2e/alloydbomni/alloydbomni-simple-cluster/00-secret.yaml new file mode 100644 index 00000000..11d5feae --- /dev/null +++ b/test/e2e/alloydbomni/alloydbomni-simple-cluster/00-secret.yaml @@ -0,0 +1,4 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: kubectl --namespace $NAMESPACE create secret generic aiven-token --from-literal=token=$AIVEN_TOKEN diff --git a/test/e2e/alloydbomni/alloydbomni-simple-cluster/01-cassandra.yaml b/test/e2e/alloydbomni/alloydbomni-simple-cluster/01-cassandra.yaml new file mode 100644 index 00000000..9f9a357e --- /dev/null +++ b/test/e2e/alloydbomni/alloydbomni-simple-cluster/01-cassandra.yaml @@ -0,0 +1,19 @@ +apiVersion: aiven.io/v1alpha1 +kind: AlloyDBOmni +metadata: + name: k8s-e2e-alloydbomni-simple +spec: + authSecretRef: + name: aiven-token + key: token + + connInfoSecretTarget: + name: alloydbomni-secret + + project: aiven-ci-kubernetes-operator + + cloudName: google-europe-west1 + plan: startup-4 + + maintenanceWindowDow: sunday + maintenanceWindowTime: 11:00:00 diff --git a/test/e2e/alloydbomni/alloydbomni-simple-cluster/02-check.yaml b/test/e2e/alloydbomni/alloydbomni-simple-cluster/02-check.yaml new file mode 100644 index 00000000..f9e829a6 --- /dev/null +++ b/test/e2e/alloydbomni/alloydbomni-simple-cluster/02-check.yaml @@ -0,0 +1,8 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + while ! avn --auth-token $AIVEN_TOKEN service wait k8s-e2e-alloydbomni-simple --project aiven-ci-kubernetes-operator; + do + sleep 10 + done diff --git a/test/e2e/alloydbomni/alloydbomni-simple-cluster/03-delete.yaml b/test/e2e/alloydbomni/alloydbomni-simple-cluster/03-delete.yaml new file mode 100644 index 00000000..c36eb95e --- /dev/null +++ b/test/e2e/alloydbomni/alloydbomni-simple-cluster/03-delete.yaml @@ -0,0 +1,6 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +delete: + - apiVersion: aiven.io/v1alpha1 + kind: AlloyDBOmni + name: k8s-e2e-alloydbomni-simple diff --git a/test/e2e/kuttl-test.preinstalled.yaml b/test/e2e/kuttl-test.preinstalled.yaml index e4d1de3f..abdca242 100644 --- a/test/e2e/kuttl-test.preinstalled.yaml +++ b/test/e2e/kuttl-test.preinstalled.yaml @@ -2,6 +2,7 @@ apiVersion: kuttl.dev/v1beta1 kind: TestSuite timeout: 600 testDirs: + - test/e2e/alloydbomni - test/e2e/kafka - test/e2e/kafka-topic - test/e2e/kafka-connector diff --git a/test/e2e/kuttl-test.yaml b/test/e2e/kuttl-test.yaml index 09d8ee11..0780be52 100644 --- a/test/e2e/kuttl-test.yaml +++ b/test/e2e/kuttl-test.yaml @@ -4,6 +4,7 @@ timeout: 9999 startKIND: true crdDir: config/crd/bases testDirs: + - test/e2e/alloydbomni - test/e2e/kafka - test/e2e/kafka-topic - test/e2e/kafka-connector diff --git a/tests/alloydbomni_test.go b/tests/alloydbomni_test.go new file mode 100644 index 00000000..d77bc28d --- /dev/null +++ b/tests/alloydbomni_test.go @@ -0,0 +1,113 @@ +package tests + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aiven/aiven-operator/api/v1alpha1" + alloydbomniuserconfig "github.com/aiven/aiven-operator/api/v1alpha1/userconfig/service/alloydbomni" +) + +func getAlloyDBOmniYaml(project, name, cloudName string) string { + return fmt.Sprintf(` +apiVersion: aiven.io/v1alpha1 +kind: AlloyDBOmni +metadata: + name: %[2]s +spec: + authSecretRef: + name: aiven-token + key: token + + project: %[1]s + cloudName: %[3]s + plan: startup-4 + disk_space: 90GiB + + tags: + env: test + instance: foo + + userConfig: + service_log: true + ip_filter: + - network: 0.0.0.0/32 + description: bar + - network: 10.20.0.0/16 + +`, project, name, cloudName) +} + +func TestAlloyDBOmni(t *testing.T) { + t.Parallel() + defer recoverPanic(t) + + // GIVEN + ctx, cancel := testCtx() + defer cancel() + + name := randName("alloydbomni") + yml := getAlloyDBOmniYaml(cfg.Project, name, cfg.PrimaryCloudName) + s := NewSession(ctx, k8sClient, cfg.Project) + + // Cleans test afterward + defer s.Destroy(t) + + // WHEN + // Applies given manifest + require.NoError(t, s.Apply(yml)) + + // Waits kube objects + cs := new(v1alpha1.AlloyDBOmni) + require.NoError(t, s.GetRunning(cs, name)) + + // THEN + csAvn, err := avnGen.ServiceGet(ctx, cfg.Project, name) + require.NoError(t, err) + assert.Equal(t, csAvn.ServiceName, cs.GetName()) + assert.Equal(t, serviceRunningState, cs.Status.State) + assert.Contains(t, serviceRunningStatesAiven, csAvn.State) + assert.Equal(t, csAvn.Plan, cs.Spec.Plan) + assert.Equal(t, csAvn.CloudName, cs.Spec.CloudName) + assert.Equal(t, "90GiB", cs.Spec.DiskSpace) + assert.Equal(t, int(92160), *csAvn.DiskSpaceMb) + assert.Equal(t, map[string]string{"env": "test", "instance": "foo"}, cs.Spec.Tags) + csResp, err := avnClient.ServiceTags.Get(ctx, cfg.Project, name) + require.NoError(t, err) + assert.Equal(t, csResp.Tags, cs.Spec.Tags) + + // UserConfig test + require.NotNil(t, cs.Spec.UserConfig) + require.NotNil(t, cs.Spec.UserConfig.ServiceLog) + assert.Equal(t, anyPointer(true), cs.Spec.UserConfig.ServiceLog) + + // Validates ip filters + require.Len(t, cs.Spec.UserConfig.IpFilter, 2) + + // First entry + assert.Equal(t, "0.0.0.0/32", cs.Spec.UserConfig.IpFilter[0].Network) + assert.Equal(t, "bar", *cs.Spec.UserConfig.IpFilter[0].Description) + + // Second entry + assert.Equal(t, "10.20.0.0/16", cs.Spec.UserConfig.IpFilter[1].Network) + assert.Nil(t, cs.Spec.UserConfig.IpFilter[1].Description) + + // Compares with Aiven ip_filter + var ipFilterAvn []*alloydbomniuserconfig.IpFilter + require.NoError(t, castInterface(csAvn.UserConfig["ip_filter"], &ipFilterAvn)) + assert.Equal(t, ipFilterAvn, cs.Spec.UserConfig.IpFilter) + + // Secrets test + secret, err := s.GetSecret(cs.GetName()) + require.NoError(t, err) + assert.NotEmpty(t, secret.Data["ALLOYDBOMNI_HOST"]) + assert.NotEmpty(t, secret.Data["ALLOYDBOMNI_PORT"]) + assert.NotEmpty(t, secret.Data["ALLOYDBOMNI_DATABASE"]) + assert.NotEmpty(t, secret.Data["ALLOYDBOMNI_USER"]) + assert.NotEmpty(t, secret.Data["ALLOYDBOMNI_PASSWORD"]) + assert.NotEmpty(t, secret.Data["ALLOYDBOMNI_SSLMODE"]) + assert.NotEmpty(t, secret.Data["ALLOYDBOMNI_DATABASE_URI"]) +}