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/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/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) + } + }) + } +}