diff --git a/api/v1alpha1/common.go b/api/v1alpha1/common.go index 9e8216edd..da2727045 100644 --- a/api/v1alpha1/common.go +++ b/api/v1alpha1/common.go @@ -47,6 +47,12 @@ type ServiceStatus struct { State string `json:"state,omitempty"` } +type ServiceTechEmail struct { + // +kubebuilder:validation:Format="^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + // Email address. + Email string `json:"email"` +} + type ServiceCommonSpec struct { // +kubebuilder:validation:MaxLength=63 // +kubebuilder:validation:Format="^[a-zA-Z0-9_-]*$" @@ -87,6 +93,10 @@ type ServiceCommonSpec struct { // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" // Service integrations to specify when creating a service. Not applied after initial service creation ServiceIntegrations []*ServiceIntegrationItem `json:"serviceIntegrations,omitempty"` + + // +kubebuilder:validation:MaxItems=10 + // Defines the email addresses that will receive alerts about upcoming maintenance updates or warnings about service instability. + TechnicalEmails []ServiceTechEmail `json:"technicalEmails,omitempty"` } // Validate runs complex validation on ServiceCommonSpec diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 3fa865d1f..cff735f48 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -2025,6 +2025,11 @@ func (in *ServiceCommonSpec) DeepCopyInto(out *ServiceCommonSpec) { } } } + if in.TechnicalEmails != nil { + in, out := &in.TechnicalEmails, &out.TechnicalEmails + *out = make([]ServiceTechEmail, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceCommonSpec. @@ -2220,6 +2225,21 @@ func (in *ServiceStatus) DeepCopy() *ServiceStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceTechEmail) DeepCopyInto(out *ServiceTechEmail) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceTechEmail. +func (in *ServiceTechEmail) DeepCopy() *ServiceTechEmail { + if in == nil { + return nil + } + out := new(ServiceTechEmail) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceUser) DeepCopyInto(out *ServiceUser) { *out = *in diff --git a/controllers/generic_service_handler.go b/controllers/generic_service_handler.go index 56d6318bf..677c58c88 100644 --- a/controllers/generic_service_handler.go +++ b/controllers/generic_service_handler.go @@ -48,6 +48,11 @@ func (h *genericServiceHandler) createOrUpdate(ctx context.Context, avn *aiven.C return fmt.Errorf("failed to fetch service: %w", err) } + technicalEmails := make([]aiven.ContactEmail, 0) + for _, email := range spec.TechnicalEmails { + technicalEmails = append(technicalEmails, aiven.ContactEmail(email)) + } + // Creates if not exists or updates existing service var reason string if !exists { @@ -68,6 +73,7 @@ func (h *genericServiceHandler) createOrUpdate(ctx context.Context, avn *aiven.C ServiceType: o.getServiceType(), TerminationProtection: fromAnyPointer(spec.TerminationProtection), UserConfig: userConfig, + TechnicalEmails: &technicalEmails, } for _, s := range spec.ServiceIntegrations { @@ -98,6 +104,7 @@ func (h *genericServiceHandler) createOrUpdate(ctx context.Context, avn *aiven.C ProjectVPCID: toOptionalStringPointer(projectVPCID), TerminationProtection: fromAnyPointer(spec.TerminationProtection), UserConfig: userConfig, + TechnicalEmails: &technicalEmails, } _, err = avn.Services.Update(ctx, spec.Project, ometa.Name, req) if err != nil { diff --git a/tests/mysql_test.go b/tests/mysql_test.go index 2bb6949c5..5c5fbd4ff 100644 --- a/tests/mysql_test.go +++ b/tests/mysql_test.go @@ -12,8 +12,8 @@ import ( mysqluserconfig "github.com/aiven/aiven-operator/api/v1alpha1/userconfig/service/mysql" ) -func getMySQLYaml(project, name, cloudName string) string { - return fmt.Sprintf(` +func getMySQLYaml(project, name, cloudName string, includeTechnicalEmails bool) string { + baseYaml := ` apiVersion: aiven.io/v1alpha1 kind: MySQL metadata: @@ -39,8 +39,16 @@ spec: - network: 0.0.0.0/32 description: bar - network: 10.20.0.0/16 +` -`, project, name, cloudName) + if includeTechnicalEmails { + baseYaml += ` + technicalEmails: + - email: "test@example.com" +` + } + + return fmt.Sprintf(baseYaml, project, name, cloudName) } func TestMySQL(t *testing.T) { @@ -50,7 +58,7 @@ func TestMySQL(t *testing.T) { // GIVEN ctx := context.Background() name := randName("mysql") - yml := getMySQLYaml(testProject, name, testPrimaryCloudName) + yml := getMySQLYaml(testProject, name, testPrimaryCloudName, false) s := NewSession(k8sClient, avnClient, testProject) // Cleans test afterwards @@ -112,3 +120,48 @@ func TestMySQL(t *testing.T) { assert.NotEmpty(t, secret.Data["MYSQL_URI"]) assert.NotEmpty(t, secret.Data["MYSQL_REPLICA_URI"]) // business-4 has replica } + +func TestMySQLTechnicalEmails(t *testing.T) { + t.Parallel() + defer recoverPanic(t) + + // GIVEN + ctx := context.Background() + name := randName("mysql") + yml := getMySQLYaml(testProject, name, testPrimaryCloudName, true) + s := NewSession(k8sClient, avnClient, testProject) + + // Cleans test afterwards + defer s.Destroy() + + // WHEN + // Applies given manifest + require.NoError(t, s.Apply(yml)) + + // Waits kube objects + ms := new(v1alpha1.MySQL) + require.NoError(t, s.GetRunning(ms, name)) + + // THEN + // Technical emails are set + msAvn, err := avnClient.Services.Get(ctx, testProject, name) + require.NoError(t, err) + assert.Len(t, ms.Spec.TechnicalEmails, 1) + assert.Equal(t, "test@example.com", msAvn.TechnicalEmails[0].Email) + + // WHEN + // Technical emails are removed from manifest + updatedYml := getMySQLYaml(testProject, name, testPrimaryCloudName, false) + + // Applies updated manifest + require.NoError(t, s.Apply(updatedYml)) + + // Waits kube objects + require.NoError(t, s.GetRunning(ms, name)) + + // THEN + // Technical emails are removed from service + msAvnUpdated, err := avnClient.Services.Get(ctx, testProject, name) + require.NoError(t, err) + assert.Empty(t, msAvnUpdated.TechnicalEmails) +}