From 16ce11fce0fcc81251af17d5065e73bbdc1a589d Mon Sep 17 00:00:00 2001 From: Timo Riski Date: Thu, 12 Dec 2024 10:57:42 +0200 Subject: [PATCH] feat: add alloydbomni service account validation --- api/v1alpha1/alloydbomni_types.go | 5 + api/v1alpha1/alloydbomni_webhook.go | 16 +- .../templates/aiven.io_alloydbomnis.yaml | 5 + config/crd/bases/aiven.io_alloydbomnis.yaml | 5 + controllers/alloydbomni_controller.go | 12 ++ controllers/cassandra_controller.go | 4 + controllers/clickhouse_controller.go | 4 + controllers/flink_controller.go | 4 + controllers/generic_service_handler.go | 6 + controllers/grafana_controller.go | 4 + controllers/kafka_controller.go | 4 + controllers/kafkaconnect_controller.go | 4 + controllers/mysql_controller.go | 4 + controllers/opensearch_controller.go | 4 + controllers/postgresql_controller.go | 4 + controllers/redis_controller.go | 4 + docs/docs/api-reference/alloydbomni.md | 1 + generators/docs/validator.go | 22 +-- tests/alloydbomni_test.go | 163 ++++++++++++------ .../service_account_credentials_validator.go | 103 +++++++++++ ...vice_account_credentials_validator_test.go | 61 +++++++ 21 files changed, 370 insertions(+), 69 deletions(-) create mode 100644 utils/alloydbomni/service_account_credentials_validator.go create mode 100644 utils/alloydbomni/service_account_credentials_validator_test.go diff --git a/api/v1alpha1/alloydbomni_types.go b/api/v1alpha1/alloydbomni_types.go index 4615180f..ccbadc82 100644 --- a/api/v1alpha1/alloydbomni_types.go +++ b/api/v1alpha1/alloydbomni_types.go @@ -12,6 +12,11 @@ import ( type AlloyDBOmniSpec struct { ServiceCommonSpec `json:",inline"` + // +kubebuilder:validation:Schemaless + // +kubebuilder:validation:Type=string + // Your [Google service account key](https://cloud.google.com/iam/docs/service-account-creds#key-types) in JSON format. + ServiceAccountCredentials string `json:"serviceAccountCredentials,omitempty"` + // AlloyDBOmni specific user configuration options UserConfig *alloydbomni.AlloydbomniUserConfig `json:"userConfig,omitempty"` } diff --git a/api/v1alpha1/alloydbomni_webhook.go b/api/v1alpha1/alloydbomni_webhook.go index 5e224aab..35d4c2ce 100644 --- a/api/v1alpha1/alloydbomni_webhook.go +++ b/api/v1alpha1/alloydbomni_webhook.go @@ -4,11 +4,14 @@ package v1alpha1 import ( "errors" + "fmt" "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" + + alloydbomniUtils "github.com/aiven/aiven-operator/utils/alloydbomni" ) // log is for logging in this package. @@ -37,13 +40,22 @@ var _ webhook.Validator = &AlloyDBOmni{} func (in *AlloyDBOmni) ValidateCreate() error { alloydbomnilog.Info("validate create", "name", in.Name) - return in.Spec.Validate() + if err := alloydbomniUtils.ValidateServiceAccountCredentials(in.Spec.ServiceAccountCredentials); err != nil { + return fmt.Errorf("invalid serviceAccountCredentials: %w", err) + } + + return nil } // 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() + + if err := alloydbomniUtils.ValidateServiceAccountCredentials(in.Spec.ServiceAccountCredentials); err != nil { + return fmt.Errorf("invalid serviceAccountCredentials: %w", err) + } + + return nil } // ValidateDelete implements webhook.Validator so a webhook will be registered for the type diff --git a/charts/aiven-operator-crds/templates/aiven.io_alloydbomnis.yaml b/charts/aiven-operator-crds/templates/aiven.io_alloydbomnis.yaml index ea9f9315..de39b71c 100644 --- a/charts/aiven-operator-crds/templates/aiven.io_alloydbomnis.yaml +++ b/charts/aiven-operator-crds/templates/aiven.io_alloydbomnis.yaml @@ -170,6 +170,11 @@ spec: description: Identifier of the VPC the service should be in, if any. maxLength: 36 type: string + serviceAccountCredentials: + description: + Your [Google service account key](https://cloud.google.com/iam/docs/service-account-creds#key-types) + in JSON format. + type: string serviceIntegrations: description: Service integrations to specify when creating a service. diff --git a/config/crd/bases/aiven.io_alloydbomnis.yaml b/config/crd/bases/aiven.io_alloydbomnis.yaml index ea9f9315..de39b71c 100644 --- a/config/crd/bases/aiven.io_alloydbomnis.yaml +++ b/config/crd/bases/aiven.io_alloydbomnis.yaml @@ -170,6 +170,11 @@ spec: description: Identifier of the VPC the service should be in, if any. maxLength: 36 type: string + serviceAccountCredentials: + description: + Your [Google service account key](https://cloud.google.com/iam/docs/service-account-creds#key-types) + in JSON format. + type: string serviceIntegrations: description: Service integrations to specify when creating a service. diff --git a/controllers/alloydbomni_controller.go b/controllers/alloydbomni_controller.go index 9ba6e9e8..deb478e2 100644 --- a/controllers/alloydbomni_controller.go +++ b/controllers/alloydbomni_controller.go @@ -8,6 +8,7 @@ import ( "github.com/aiven/aiven-go-client/v2" avngen "github.com/aiven/go-client-codegen" + "github.com/aiven/go-client-codegen/handler/alloydbomni" "github.com/aiven/go-client-codegen/handler/service" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -95,3 +96,14 @@ func (a *alloyDBOmniAdapter) getDiskSpace() string { func (a *alloyDBOmniAdapter) performUpgradeTaskIfNeeded(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { return nil } + +func (a *alloyDBOmniAdapter) createOrUpdateServiceSpecific(ctx context.Context, avnGen avngen.Client, old *service.ServiceGetOut) error { + if a.Spec.ServiceAccountCredentials == "" { + _, err := avnGen.AlloyDbOmniGoogleCloudPrivateKeyRemove(ctx, a.Spec.Project, a.Name) + return err + } + + req := &alloydbomni.AlloyDbOmniGoogleCloudPrivateKeySetIn{PrivateKey: a.Spec.ServiceAccountCredentials} + _, err := avnGen.AlloyDbOmniGoogleCloudPrivateKeySet(ctx, a.Spec.Project, a.Name, req) + return err +} diff --git a/controllers/cassandra_controller.go b/controllers/cassandra_controller.go index ff59684a..2836f01e 100644 --- a/controllers/cassandra_controller.go +++ b/controllers/cassandra_controller.go @@ -96,3 +96,7 @@ func (a *cassandraAdapter) getDiskSpace() string { func (a *cassandraAdapter) performUpgradeTaskIfNeeded(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { return nil } + +func (a *cassandraAdapter) createOrUpdateServiceSpecific(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { + return nil +} diff --git a/controllers/clickhouse_controller.go b/controllers/clickhouse_controller.go index 8f7d571d..4764a0a1 100644 --- a/controllers/clickhouse_controller.go +++ b/controllers/clickhouse_controller.go @@ -99,3 +99,7 @@ func (a *clickhouseAdapter) getDiskSpace() string { func (a *clickhouseAdapter) performUpgradeTaskIfNeeded(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { return nil } + +func (a *clickhouseAdapter) createOrUpdateServiceSpecific(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { + return nil +} diff --git a/controllers/flink_controller.go b/controllers/flink_controller.go index 7af49dd0..d66a8524 100644 --- a/controllers/flink_controller.go +++ b/controllers/flink_controller.go @@ -95,3 +95,7 @@ func (a *flinkAdapter) getDiskSpace() string { func (a *flinkAdapter) performUpgradeTaskIfNeeded(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { return nil } + +func (a *flinkAdapter) createOrUpdateServiceSpecific(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { + return nil +} diff --git a/controllers/generic_service_handler.go b/controllers/generic_service_handler.go index c293fb77..0ed5c07f 100644 --- a/controllers/generic_service_handler.go +++ b/controllers/generic_service_handler.go @@ -129,6 +129,11 @@ func (h *genericServiceHandler) createOrUpdate(ctx context.Context, avn *aiven.C } } + // Call service-specific createOrUpdate if it exists + if err := o.createOrUpdateServiceSpecific(ctx, avnGen, oldService); err != nil { + return fmt.Errorf("failed to create or update service-specific: %w", err) + } + // Updates tags. // Four scenarios: service created/updated * with/without tags // By sending empty tags it clears existing list @@ -271,4 +276,5 @@ type serviceAdapter interface { getUserConfig() any newSecret(ctx context.Context, s *service.ServiceGetOut) (*corev1.Secret, error) performUpgradeTaskIfNeeded(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error + createOrUpdateServiceSpecific(ctx context.Context, avnGen avngen.Client, old *service.ServiceGetOut) error } diff --git a/controllers/grafana_controller.go b/controllers/grafana_controller.go index c861f59e..565efdea 100644 --- a/controllers/grafana_controller.go +++ b/controllers/grafana_controller.go @@ -96,3 +96,7 @@ func (a *grafanaAdapter) getDiskSpace() string { func (a *grafanaAdapter) performUpgradeTaskIfNeeded(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { return nil } + +func (a *grafanaAdapter) createOrUpdateServiceSpecific(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { + return nil +} diff --git a/controllers/kafka_controller.go b/controllers/kafka_controller.go index d4df26e5..8144cab2 100644 --- a/controllers/kafka_controller.go +++ b/controllers/kafka_controller.go @@ -131,3 +131,7 @@ func (a *kafkaAdapter) getDiskSpace() string { func (a *kafkaAdapter) performUpgradeTaskIfNeeded(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { return nil } + +func (a *kafkaAdapter) createOrUpdateServiceSpecific(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { + return nil +} diff --git a/controllers/kafkaconnect_controller.go b/controllers/kafkaconnect_controller.go index e97dd8bc..a5bd3dde 100644 --- a/controllers/kafkaconnect_controller.go +++ b/controllers/kafkaconnect_controller.go @@ -88,3 +88,7 @@ func (a *kafkaConnectAdapter) GetConnInfoSecretTarget() v1alpha1.ConnInfoSecretT func (a *kafkaConnectAdapter) performUpgradeTaskIfNeeded(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { return nil } + +func (a *kafkaConnectAdapter) createOrUpdateServiceSpecific(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { + return nil +} diff --git a/controllers/mysql_controller.go b/controllers/mysql_controller.go index c87ddd29..d9b1d6e5 100644 --- a/controllers/mysql_controller.go +++ b/controllers/mysql_controller.go @@ -97,3 +97,7 @@ func (a *mySQLAdapter) getDiskSpace() string { func (a *mySQLAdapter) performUpgradeTaskIfNeeded(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { return nil } + +func (a *mySQLAdapter) createOrUpdateServiceSpecific(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { + return nil +} diff --git a/controllers/opensearch_controller.go b/controllers/opensearch_controller.go index 46c09f83..31e5a449 100644 --- a/controllers/opensearch_controller.go +++ b/controllers/opensearch_controller.go @@ -101,3 +101,7 @@ func (a *opensearchAdapter) getDiskSpace() string { func (a *opensearchAdapter) performUpgradeTaskIfNeeded(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { return nil } + +func (a *opensearchAdapter) createOrUpdateServiceSpecific(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { + return nil +} diff --git a/controllers/postgresql_controller.go b/controllers/postgresql_controller.go index 568706db..8b1e79cd 100644 --- a/controllers/postgresql_controller.go +++ b/controllers/postgresql_controller.go @@ -160,3 +160,7 @@ func waitForTaskToComplete[T any](ctx context.Context, f func() (bool, *T, error } } } + +func (a *postgreSQLAdapter) createOrUpdateServiceSpecific(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { + return nil +} diff --git a/controllers/redis_controller.go b/controllers/redis_controller.go index d7a6e19b..e2b8b669 100644 --- a/controllers/redis_controller.go +++ b/controllers/redis_controller.go @@ -103,3 +103,7 @@ func (a *redisAdapter) getDiskSpace() string { func (a *redisAdapter) performUpgradeTaskIfNeeded(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { return nil } + +func (a *redisAdapter) createOrUpdateServiceSpecific(ctx context.Context, avn avngen.Client, old *service.ServiceGetOut) error { + return nil +} diff --git a/docs/docs/api-reference/alloydbomni.md b/docs/docs/api-reference/alloydbomni.md index 2bda3106..18371450 100644 --- a/docs/docs/api-reference/alloydbomni.md +++ b/docs/docs/api-reference/alloydbomni.md @@ -41,6 +41,7 @@ The removal of this field does not change the value. - [`maintenanceWindowTime`](#spec.maintenanceWindowTime-property){: name='spec.maintenanceWindowTime-property'} (string, MaxLength: 8). Time of day when maintenance operations should be performed. UTC time in HH:mm:ss format. - [`projectVPCRef`](#spec.projectVPCRef-property){: name='spec.projectVPCRef-property'} (object). ProjectVPCRef reference to ProjectVPC resource to use its ID as ProjectVPCID automatically. See below for [nested schema](#spec.projectVPCRef). - [`projectVpcId`](#spec.projectVpcId-property){: name='spec.projectVpcId-property'} (string, MaxLength: 36). Identifier of the VPC the service should be in, if any. +- [`serviceAccountCredentials`](#spec.serviceAccountCredentials-property){: name='spec.serviceAccountCredentials-property'} (string). Your [Google service account key](https://cloud.google.com/iam/docs/service-account-creds#key-types) in JSON format. - [`serviceIntegrations`](#spec.serviceIntegrations-property){: name='spec.serviceIntegrations-property'} (array of objects, Immutable, MaxItems: 1). Service integrations to specify when creating a service. Not applied after initial service creation. See below for [nested schema](#spec.serviceIntegrations). - [`tags`](#spec.tags-property){: name='spec.tags-property'} (object, AdditionalProperties: string). Tags are key-value pairs that allow you to categorize services. - [`technicalEmails`](#spec.technicalEmails-property){: name='spec.technicalEmails-property'} (array of objects, MaxItems: 10). Defines the email addresses that will receive alerts about upcoming maintenance updates or warnings about service instability. See below for [nested schema](#spec.technicalEmails). diff --git a/generators/docs/validator.go b/generators/docs/validator.go index 9e330a2f..fa5fb195 100644 --- a/generators/docs/validator.go +++ b/generators/docs/validator.go @@ -109,14 +109,17 @@ func newSchemaValidator(kind string, crd []byte) (schemaValidator, error) { // If not to do so, new properties allowed on validation, // but won't work when applied with kubectl func patchSchema(m map[string]any) map[string]any { - if m["type"].(string) != "object" { + t, ok := m["type"].(string) + if !ok || t != "object" { return m } - if p, ok := m["properties"]; ok { - prop := p.(map[string]any) - for k, v := range prop { - vv := v.(map[string]any) + if p, ok := m["properties"].(map[string]any); ok { + for k, v := range p { + vv, ok := v.(map[string]any) + if !ok { + continue + } // metadata schema is empty, replaces with a good one if k == "metadata" && len(vv) == 1 { @@ -133,14 +136,13 @@ func patchSchema(m map[string]any) map[string]any { continue } - prop[k] = patchSchema(vv) + p[k] = patchSchema(vv) } - m["properties"] = prop + m["properties"] = p } - if i, ok := m["items"]; ok { - items := i.(map[string]any) - m["items"] = patchSchema(items) + if i, ok := m["items"].(map[string]any); ok { + m["items"] = patchSchema(i) } if _, ok := m["additionalProperties"]; !ok { diff --git a/tests/alloydbomni_test.go b/tests/alloydbomni_test.go index d77bc28d..bb697d49 100644 --- a/tests/alloydbomni_test.go +++ b/tests/alloydbomni_test.go @@ -11,7 +11,7 @@ import ( alloydbomniuserconfig "github.com/aiven/aiven-operator/api/v1alpha1/userconfig/service/alloydbomni" ) -func getAlloyDBOmniYaml(project, name, cloudName string) string { +func getAlloyDBOmniYaml(project, name, cloudName, serviceAccountCredentials string) string { return fmt.Sprintf(` apiVersion: aiven.io/v1alpha1 kind: AlloyDBOmni @@ -26,6 +26,7 @@ spec: cloudName: %[3]s plan: startup-4 disk_space: 90GiB + serviceAccountCredentials: %q tags: env: test @@ -38,7 +39,7 @@ spec: description: bar - network: 10.20.0.0/16 -`, project, name, cloudName) +`, project, name, cloudName, serviceAccountCredentials) } func TestAlloyDBOmni(t *testing.T) { @@ -50,64 +51,112 @@ func TestAlloyDBOmni(t *testing.T) { 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"]) + // Test cases + cases := []struct { + name string + serviceAccountCredentials string + expectError bool + expectedErrorMessage string + }{ + { + name: "valid credentials", + serviceAccountCredentials: getTestServiceAccountCredentials("valid_key_id"), + expectError: false, + }, + { + name: "invalid credentials", + serviceAccountCredentials: `{"private_key": "-----BEGIN PRIVATE KEY--.........----END PRIVATE KEY-----\n","client_email": "example@aiven.io","client_id": "example_user_id","type": "service_account","project_id": "example_project_id"}`, + expectError: true, + expectedErrorMessage: "invalid serviceAccountCredentials: (root): private_key_id is required", + }, + { + name: "empty credentials", + serviceAccountCredentials: "", + expectError: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + yml := getAlloyDBOmniYaml(cfg.Project, name, cfg.PrimaryCloudName, tc.serviceAccountCredentials) + + // WHEN + err := s.Apply(yml) + + // THEN + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrorMessage) + } else { + require.NoError(t, err) + + // Waits kube objects + cs := new(v1alpha1.AlloyDBOmni) + require.NoError(t, s.GetRunning(cs, name)) + + // Validate the resource + 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"]) + } + }) + } +} + +func getTestServiceAccountCredentials(privateKeyID string) string { + return fmt.Sprintf(`{ + "private_key_id": %q, + "private_key": "-----BEGIN PRIVATE KEY--.........----END PRIVATE KEY-----\n", + "client_email": "example@aiven.io", + "client_id": "example_user_id", + "type": "service_account", + "project_id": "example_project_id" + }`, privateKeyID) } diff --git a/utils/alloydbomni/service_account_credentials_validator.go b/utils/alloydbomni/service_account_credentials_validator.go new file mode 100644 index 00000000..296deee7 --- /dev/null +++ b/utils/alloydbomni/service_account_credentials_validator.go @@ -0,0 +1,103 @@ +package alloydbomniUtils + +import ( + "errors" + + "github.com/xeipuuv/gojsonschema" +) + +func ValidateServiceAccountCredentials(i interface{}) error { + s, ok := i.(string) + if !ok { + return errors.New("expected input to be a string") + } + + r, err := gojsonschema.Validate( + gojsonschema.NewStringLoader(serviceAccountCredentialsSchema), + gojsonschema.NewStringLoader(s), + ) + if err != nil { + return err + } + + if !r.Valid() { + var errMsg string + for _, e := range r.Errors() { + errMsg += e.String() + "\n" + } + return errors.New(errMsg) + } + + return nil +} + +// trunk-ignore-all(gitleaks/private-key) +const serviceAccountCredentialsSchema = `{ + "title": "Google service account credentials map", + "type": "object", + "properties": { + "type": { + "type": "string", + "title": "Credentials type", + "description": "Always service_account for credentials created in Gcloud console or CLI", + "example": "service_account" + }, + "project_id": { + "type": "string", + "title": "Gcloud project id", + "example": "some-my-project" + }, + "private_key_id": { + "type": "string", + "title": "Hexadecimal ID number of your private key", + "example": "5fdeb02a11ddf081930ac3ac60bf376a0aef8fad" + }, + "private_key": { + "type": "string", + "title": "PEM-encoded private key", + "example": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" + }, + "client_email": { + "type": "string", + "title": "Email of the service account", + "example": "my-service-account@some-my-project.iam.gserviceaccount.com" + }, + "client_id": { + "type": "string", + "title": "Numeric client id for this service account", + "example": "103654484443722885992" + }, + "auth_uri": { + "type": "string", + "title": "The authentication endpoint of Google", + "example": "https://accounts.google.com/o/oauth2/auth" + }, + "token_uri": { + "type": "string", + "title": "The token lease endpoint of Google", + "example": "https://accounts.google.com/o/oauth2/token" + }, + "auth_provider_x509_cert_url": { + "type": "string", + "title": "The certificate service of Google", + "example": "https://www.googleapis.com/oauth2/v1/certs" + }, + "client_x509_cert_url": { + "type": "string", + "title": "Certificate URL for your service account", + "example": "https://www.googleapis.com/robot/v1/metadata/x509/my-service-account%40some-my-project.iam.gserviceaccount.com" + }, + "universe_domain": { + "type": "string", + "title": "The universe domain", + "description": "The universe domain. The default universe domain is googleapis.com." + } + }, + "required": [ + "private_key_id", + "private_key", + "client_email", + "client_id" + ], + "additionalProperties": false +}` diff --git a/utils/alloydbomni/service_account_credentials_validator_test.go b/utils/alloydbomni/service_account_credentials_validator_test.go new file mode 100644 index 00000000..9f2d744a --- /dev/null +++ b/utils/alloydbomni/service_account_credentials_validator_test.go @@ -0,0 +1,61 @@ +package alloydbomniUtils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateServiceAccountCredentials(t *testing.T) { + cases := []struct { + name string + input string + expected string + }{ + { + name: "valid", + input: `{ + "private_key_id": "0", + "private_key": "1", + "client_email": "2", + "client_id": "3" + }`, + expected: "", + }, + { + name: "invalid, empty", + input: `{}`, + expected: "(root): private_key_id is required\n(root): private_key is required\n(root): client_email is required\n(root): client_id is required\n", + }, + { + name: "missing private_key_id", + input: `{ + "private_key": "1", + "client_email": "2", + "client_id": "3" + }`, + expected: "(root): private_key_id is required\n", + }, + { + name: "invalid type client_id", + input: `{ + "private_key_id": "0", + "private_key": "1", + "client_email": "2", + "client_id": 3 + }`, + expected: "client_id: Invalid type. Expected: string, given: integer\n", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateServiceAccountCredentials(tc.input) + if tc.expected == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tc.expected) + } + }) + } +}