diff --git a/CHANGELOG.md b/CHANGELOG.md index 49340a5..e001b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +## Unreleased + +- Support for routes on mailgun +- Support for adding an additional DNS records to make external DNS set all records at once and set a proper owner if you need an additional DNS entries + ## 1.2.7 - fix saving status of domain on mailgun diff --git a/PROJECT b/PROJECT index 2279697..a270132 100644 --- a/PROJECT +++ b/PROJECT @@ -31,4 +31,13 @@ resources: group: externaldns kind: DNSEndpoint version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: mailgun.com + group: domain + kind: Route + path: github.com/amoniacou/mailgun-operator/api/domain/v1 + version: v1 version: "3" diff --git a/api/domain/v1/domain_types.go b/api/domain/v1/domain_types.go index 6d2077d..a88cdde 100644 --- a/api/domain/v1/domain_types.go +++ b/api/domain/v1/domain_types.go @@ -19,6 +19,7 @@ package v1 import ( "github.com/mailgun/mailgun-go/v4" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/external-dns/endpoint" ) type DnsRecord struct { @@ -51,8 +52,11 @@ const ( type DomainSpec struct { // Domain is a domain name which we need to create on Mailgun Domain string `json:"domain"` + // Support for External-DNS - ExternalDNS *bool `json:"external_dns,omitempty"` + ExternalDNS *bool `json:"external_dns,omitempty"` + ExternalDNSRecords []endpoint.Endpoint `json:"external_dns_records,omitempty"` + // See https://documentation.mailgun.com/en/latest/api-domains.html#domains WebScheme *WebSchemeType `json:"web_scheme,omitempty"` DKIMKeySize *int `json:"dkim_key_size,omitempty"` @@ -66,13 +70,10 @@ type DomainSpec struct { // Export SMTP or API credentials to a secret ExportCredentials *bool `json:"export_credentials,omitempty"` // Export SMTP credentials to a secret - // +kubebuilder:validation:RequiredIf=ExportCredentials==true ExportSecretName *string `json:"export_secret_name,omitempty"` // Export secret key for login - // +kubebuilder:validation:RequiredIf=ExportCredentials==true ExportSecretLoginKey *string `json:"export_secret_login_key,omitempty"` // Export secret key for password - // +kubebuilder:validation:RequiredIf=ExportCredentials==true ExportSecretPasswordKey *string `json:"export_secret_password_key,omitempty"` // Force validation of MX records for receiving mail diff --git a/api/domain/v1/route_types.go b/api/domain/v1/route_types.go new file mode 100644 index 0000000..d4a9ce1 --- /dev/null +++ b/api/domain/v1/route_types.go @@ -0,0 +1,71 @@ +/* +Copyright 2024 Amoniac OU. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// RouteSpec defines the desired state of Route +type RouteSpec struct { + // Description of the route + // +kubebuilder:validation:required + Description string `json:"description"` + // Matching expression for the route + // +kubebuilder:validation:required + Expression string `json:"expression"` + // Action to be taken when the route matches an incoming email + // +kubebuilder:validation:required + Actions []string `json:"action"` + // Priority of route + // Smaller number indicates higher priority. Higher priority routes are handled first. + // +optional + Priority *int `json:"priority"` +} + +// RouteStatus defines the observed state of Route +type RouteStatus struct { + // ID of created route on mailgun + RouteID *string `json:"route_id,omitempty"` + // Mailgun error message if any error occurred during route creation or deletion + MailgunError *string `json:"mailgun_error,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// Route is the Schema for the routes API +type Route struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec RouteSpec `json:"spec,omitempty"` + Status RouteStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// RouteList contains a list of Route +type RouteList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Route `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Route{}, &RouteList{}) +} diff --git a/api/domain/v1/zz_generated.deepcopy.go b/api/domain/v1/zz_generated.deepcopy.go index 54000b3..8d55405 100644 --- a/api/domain/v1/zz_generated.deepcopy.go +++ b/api/domain/v1/zz_generated.deepcopy.go @@ -23,6 +23,7 @@ package v1 import ( v4 "github.com/mailgun/mailgun-go/v4" runtime "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/external-dns/endpoint" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. @@ -107,6 +108,13 @@ func (in *DomainSpec) DeepCopyInto(out *DomainSpec) { *out = new(bool) **out = **in } + if in.ExternalDNSRecords != nil { + in, out := &in.ExternalDNSRecords, &out.ExternalDNSRecords + *out = make([]endpoint.Endpoint, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.WebScheme != nil { in, out := &in.WebScheme, &out.WebScheme *out = new(WebSchemeType) @@ -212,3 +220,112 @@ func (in *DomainStatus) DeepCopy() *DomainStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Route) DeepCopyInto(out *Route) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Route. +func (in *Route) DeepCopy() *Route { + if in == nil { + return nil + } + out := new(Route) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Route) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RouteList) DeepCopyInto(out *RouteList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Route, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouteList. +func (in *RouteList) DeepCopy() *RouteList { + if in == nil { + return nil + } + out := new(RouteList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RouteList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RouteSpec) DeepCopyInto(out *RouteSpec) { + *out = *in + if in.Actions != nil { + in, out := &in.Actions, &out.Actions + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Priority != nil { + in, out := &in.Priority, &out.Priority + *out = new(int) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouteSpec. +func (in *RouteSpec) DeepCopy() *RouteSpec { + if in == nil { + return nil + } + out := new(RouteSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RouteStatus) DeepCopyInto(out *RouteStatus) { + *out = *in + if in.RouteID != nil { + in, out := &in.RouteID, &out.RouteID + *out = new(string) + **out = **in + } + if in.MailgunError != nil { + in, out := &in.MailgunError, &out.MailgunError + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RouteStatus. +func (in *RouteStatus) DeepCopy() *RouteStatus { + if in == nil { + return nil + } + out := new(RouteStatus) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/main.go b/cmd/main.go index 00bd077..d467507 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -197,6 +197,15 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "Domain") os.Exit(1) } + if err = (&domaincontroller.RouteReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("mailgun-controller"), + Config: config, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Route") + os.Exit(1) + } // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/crd/bases/domain.mailgun.com_domains.yaml b/config/crd/bases/domain.mailgun.com_domains.yaml index 59b0591..18242fb 100644 --- a/config/crd/bases/domain.mailgun.com_domains.yaml +++ b/config/crd/bases/domain.mailgun.com_domains.yaml @@ -59,6 +59,51 @@ spec: external_dns: description: Support for External-DNS type: boolean + external_dns_records: + items: + description: Endpoint is a high-level way of a connection between + a service and an IP + properties: + dnsName: + description: The hostname of the DNS record + type: string + labels: + additionalProperties: + type: string + description: Labels stores labels defined for the Endpoint + type: object + providerSpecific: + description: ProviderSpecific stores provider specific config + items: + description: ProviderSpecificProperty holds the name and value + of a configuration which is specific to individual DNS providers + properties: + name: + type: string + value: + type: string + type: object + type: array + recordTTL: + description: TTL for the record + format: int64 + type: integer + recordType: + description: RecordType type of record, e.g. CNAME, A, AAAA, + SRV, TXT etc + type: string + setIdentifier: + description: Identifier to distinguish multiple records with + the same name and type (e.g. Route53 records with routing + policies other than 'simple') + type: string + targets: + description: The targets the DNS record points to + items: + type: string + type: array + type: object + type: array force_dkim_authority: type: boolean force_mx_check: diff --git a/config/crd/bases/domain.mailgun.com_routes.yaml b/config/crd/bases/domain.mailgun.com_routes.yaml new file mode 100644 index 0000000..840aebb --- /dev/null +++ b/config/crd/bases/domain.mailgun.com_routes.yaml @@ -0,0 +1,79 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + name: routes.domain.mailgun.com +spec: + group: domain.mailgun.com + names: + kind: Route + listKind: RouteList + plural: routes + singular: route + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: Route is the Schema for the routes API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: RouteSpec defines the desired state of Route + properties: + action: + description: Action to be taken when the route matches an incoming + email + items: + type: string + type: array + description: + description: Description of the route + type: string + expression: + description: Matching expression for the route + type: string + priority: + description: |- + Priority of route + Smaller number indicates higher priority. Higher priority routes are handled first. + type: integer + required: + - action + - description + - expression + type: object + status: + description: RouteStatus defines the observed state of Route + properties: + mailgun_error: + description: Mailgun error message if any error occurred during route + creation or deletion + type: string + route_id: + description: ID of created route on mailgun + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index ff68422..892b564 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -4,6 +4,7 @@ resources: - bases/domain.mailgun.com_domains.yaml - bases/domain.mailgun.com_webhooks.yaml +- bases/domain.mailgun.com_routes.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: @@ -15,6 +16,7 @@ patches: # patches here are for enabling the CA injection for each CRD #- path: patches/cainjection_in_domain_domains.yaml #- path: patches/cainjection_in_domain_webhooks.yaml +#- path: patches/cainjection_in_domain_routes.yaml # +kubebuilder:scaffold:crdkustomizecainjectionpatch # [WEBHOOK] To enable webhook, uncomment the following section diff --git a/config/rbac/domain_route_editor_role.yaml b/config/rbac/domain_route_editor_role.yaml new file mode 100644 index 0000000..74d2482 --- /dev/null +++ b/config/rbac/domain_route_editor_role.yaml @@ -0,0 +1,27 @@ +# permissions for end users to edit routes. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: mailgun-operator + app.kubernetes.io/managed-by: kustomize + name: domain-route-editor-role +rules: +- apiGroups: + - domain.mailgun.com + resources: + - routes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - domain.mailgun.com + resources: + - routes/status + verbs: + - get diff --git a/config/rbac/domain_route_viewer_role.yaml b/config/rbac/domain_route_viewer_role.yaml new file mode 100644 index 0000000..acaa148 --- /dev/null +++ b/config/rbac/domain_route_viewer_role.yaml @@ -0,0 +1,23 @@ +# permissions for end users to view routes. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: mailgun-operator + app.kubernetes.io/managed-by: kustomize + name: domain-route-viewer-role +rules: +- apiGroups: + - domain.mailgun.com + resources: + - routes + verbs: + - get + - list + - watch +- apiGroups: + - domain.mailgun.com + resources: + - routes/status + verbs: + - get diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 469b605..c35a827 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -22,6 +22,8 @@ resources: # default, aiding admins in cluster management. Those roles are # not used by the Project itself. You can comment the following lines # if you do not want those helpers be installed with your Project. +- domain_route_editor_role.yaml +- domain_route_viewer_role.yaml - domain_webhook_editor_role.yaml - domain_webhook_viewer_role.yaml - domain_domain_editor_role.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 14770ef..0c03443 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -35,6 +35,7 @@ rules: - domain.mailgun.com resources: - domains + - routes verbs: - create - delete @@ -47,12 +48,14 @@ rules: - domain.mailgun.com resources: - domains/finalizers + - routes/finalizers verbs: - update - apiGroups: - domain.mailgun.com resources: - domains/status + - routes/status verbs: - get - patch diff --git a/config/samples/domain_v1_route.yaml b/config/samples/domain_v1_route.yaml new file mode 100644 index 0000000..0fcc629 --- /dev/null +++ b/config/samples/domain_v1_route.yaml @@ -0,0 +1,9 @@ +apiVersion: domain.mailgun.com/v1 +kind: Route +metadata: + labels: + app.kubernetes.io/name: mailgun-operator + app.kubernetes.io/managed-by: kustomize + name: route-sample +spec: + # TODO(user): Add fields here diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 706d4f4..f06752a 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -2,4 +2,5 @@ resources: - domain_v1_domain.yaml - domain_v1_webhook.yaml +- domain_v1_route.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/internal/controller/domain/domain_controller.go b/internal/controller/domain/domain_controller.go index 7f64223..b2f151f 100644 --- a/internal/controller/domain/domain_controller.go +++ b/internal/controller/domain/domain_controller.go @@ -41,7 +41,7 @@ import ( ) const ( - finalizerName = "domain.mailgun.com/finalizer" + domainFinalizer = "domain.mailgun.com/finalizer" endpointOwnerKey = ".metadata.controller" ) @@ -83,15 +83,15 @@ func (r *DomainReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr // The object is not being deleted, so if it does not have our finalizer, // then lets add the finalizer and update the object. This is equivalent // to registering our finalizer. - if !controllerutil.ContainsFinalizer(mailgunDomain, finalizerName) { - controllerutil.AddFinalizer(mailgunDomain, finalizerName) + if !controllerutil.ContainsFinalizer(mailgunDomain, domainFinalizer) { + controllerutil.AddFinalizer(mailgunDomain, domainFinalizer) if err := r.Update(ctx, mailgunDomain); err != nil { return ctrl.Result{}, err } } } else { // The object is being deleted - if controllerutil.ContainsFinalizer(mailgunDomain, finalizerName) { + if controllerutil.ContainsFinalizer(mailgunDomain, domainFinalizer) { if err := r.deleteDomain(ctx, mailgunDomain, mg); err != nil { return ctrl.Result{}, err } @@ -312,6 +312,8 @@ func (r *DomainReconciler) checkMXRecordsAndSetState(ctx context.Context, mg *ma // create external DNS entity for the domain func (r *DomainReconciler) createExternalDNSEntity(ctx context.Context, mailgunDomain *domainv1.Domain) error { log := log.FromContext(ctx) + log.V(1).Info("Creating external DNS entity for domain", "domain", mailgunDomain.Spec.Domain) + log.V(1).Info("Spec:", "domain", mailgunDomain.Spec) domainName := mailgunDomain.Spec.Domain // create receive records dnsEntrypoint := &endpoint.DNSEndpoint{ @@ -348,6 +350,15 @@ func (r *DomainReconciler) createExternalDNSEntity(ctx context.Context, mailgunD dnsEntrypoint.Spec.Endpoints = append(dnsEntrypoint.Spec.Endpoints, dnsRecordEndpoint) } + if len(mailgunDomain.Spec.ExternalDNSRecords) > 0 { + log.V(1).Info("Adding external DNS records to the domain", "domain", domainName) + for _, record := range mailgunDomain.Spec.ExternalDNSRecords { + dnsRecordEndpoint := endpoint.NewEndpoint(record.DNSName, record.RecordType, record.Targets...) + dnsEntrypoint.Spec.Endpoints = append(dnsEntrypoint.Spec.Endpoints, dnsRecordEndpoint) + } + log.V(1).Info("External DNS records added to the domain", "domain", domainName, "entrypoint", dnsEntrypoint.Spec.Endpoints) + } + if err := r.Create(ctx, dnsEntrypoint); err != nil { log.Error(err, "unable to create DNSEndpoint!", "entrypoint", dnsEntrypoint) return err @@ -467,7 +478,7 @@ func (r *DomainReconciler) deleteDomain(ctx context.Context, domain *domainv1.Do } // remove our finalizer from the list and update it. - controllerutil.RemoveFinalizer(domain, finalizerName) + controllerutil.RemoveFinalizer(domain, domainFinalizer) if err := r.Update(ctx, domain); err != nil { return err } diff --git a/internal/controller/domain/domain_controller_test.go b/internal/controller/domain/domain_controller_test.go index 0b6dd51..3defef6 100644 --- a/internal/controller/domain/domain_controller_test.go +++ b/internal/controller/domain/domain_controller_test.go @@ -21,6 +21,7 @@ import ( "time" domainv1 "github.com/amoniacou/mailgun-operator/api/domain/v1" + "github.com/amoniacou/mailgun-operator/internal/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" @@ -124,6 +125,82 @@ var _ = Describe("Domain Controller", func() { Expect(dnsEndpoint.Spec.Endpoints[2].Targets).To(Equal(endpoint.Targets{"mailgun.org"})) }) + It("should create mailgun domain, store DNS records and create external DNS entities with additional records", func() { + namespace := newFakeNamespace() + Expect(namespace).ToNot(BeNil()) + domainName := "another-domain-with-records.com" + forceMXChecks := true + name := "domain-" + rand.String(10) + doDomain := &domainv1.Domain{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: domainv1.DomainSpec{ + Domain: domainName, + ForceMXCheck: &forceMXChecks, + ExternalDNS: &forceMXChecks, + ExternalDNSRecords: []endpoint.Endpoint{ + { + DNSName: "*." + domainName, + RecordType: "A", + Targets: []string{"127.0.0.1"}, + }, + { + DNSName: domainName, + RecordType: "A", + Targets: []string{"127.0.0.1"}, + }, + }, + }, + } + + err := k8sClient.Create(ctx, doDomain) + Expect(err).ToNot(HaveOccurred()) + + doDomainLookup := types.NamespacedName{Name: doDomain.Name, Namespace: namespace} + createdDODomain := &domainv1.Domain{} + Eventually(func() bool { + err := k8sClient.Get(ctx, doDomainLookup, createdDODomain) + if err == nil { + utils.PrettyPrint(createdDODomain.Status.State) + return createdDODomain.Status.State == domainv1.DomainStateCreated + } + return false + }, timeout, interval).Should(BeTrue()) + + Expect(createdDODomain.Spec.Domain).Should(Equal(domainName)) + Expect(createdDODomain.Status.DomainState).Should(Equal("unverified")) + Expect(createdDODomain.Status.ReceivingDnsRecords).Should(HaveLen(2)) + Expect(createdDODomain.Status.SendingDnsRecords).Should(HaveLen(3)) + dnsEndpoint := &endpoint.DNSEndpoint{} + err = k8sClient.Get(ctx, types.NamespacedName{ + Name: createdDODomain.Name, + Namespace: createdDODomain.Namespace, + }, dnsEndpoint) + Expect(err).NotTo(HaveOccurred()) + Expect(dnsEndpoint.Spec.Endpoints).Should(HaveLen(6)) + // mx records + Expect(dnsEndpoint.Spec.Endpoints[3].RecordType).To(Equal("MX")) + Expect(dnsEndpoint.Spec.Endpoints[3].DNSName).To(Equal(domainName)) + Expect(dnsEndpoint.Spec.Endpoints[3].Targets).To(Equal(endpoint.Targets{"10 mxa.mailgun.org", "10 mxb.mailgun.org"})) + Expect(dnsEndpoint.Spec.Endpoints[0].RecordType).To(Equal("TXT")) + Expect(dnsEndpoint.Spec.Endpoints[0].DNSName).To(Equal(domainName)) + Expect(dnsEndpoint.Spec.Endpoints[0].Targets).To(Equal(endpoint.Targets{"v=spf1 include:mailgun.org ~all"})) + Expect(dnsEndpoint.Spec.Endpoints[1].RecordType).To(Equal("TXT")) + Expect(dnsEndpoint.Spec.Endpoints[1].DNSName).To(Equal("d.mail." + domainName)) + Expect(dnsEndpoint.Spec.Endpoints[1].Targets).To(Equal(endpoint.Targets{"k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUA..."})) + Expect(dnsEndpoint.Spec.Endpoints[2].RecordType).To(Equal("CNAME")) + Expect(dnsEndpoint.Spec.Endpoints[2].DNSName).To(Equal("email." + domainName)) + Expect(dnsEndpoint.Spec.Endpoints[2].Targets).To(Equal(endpoint.Targets{"mailgun.org"})) + Expect(dnsEndpoint.Spec.Endpoints[4].RecordType).To(Equal("A")) + Expect(dnsEndpoint.Spec.Endpoints[4].DNSName).To(Equal("*." + domainName)) + Expect(dnsEndpoint.Spec.Endpoints[4].Targets).To(Equal(endpoint.Targets{"127.0.0.1"})) + Expect(dnsEndpoint.Spec.Endpoints[5].RecordType).To(Equal("A")) + Expect(dnsEndpoint.Spec.Endpoints[5].DNSName).To(Equal(domainName)) + Expect(dnsEndpoint.Spec.Endpoints[5].Targets).To(Equal(endpoint.Targets{"127.0.0.1"})) + }) + It("should create mailgun domain and change state to activated", func() { namespace := newFakeNamespace() Expect(namespace).ToNot(BeNil()) @@ -199,6 +276,7 @@ var _ = Describe("Domain Controller", func() { return 0 }, timeout, interval).Should(And(SatisfyAll(BeNumerically(">=", 2), BeNumerically("<", 5)))) Expect(createdDODomain.Status.State).To(Equal(domainv1.DomainStateCreated)) + By("Activate mx records on fake mailgun") mgm.ActivateMXDomain(domainName) diff --git a/internal/controller/domain/route_controller.go b/internal/controller/domain/route_controller.go new file mode 100644 index 0000000..a97cfa3 --- /dev/null +++ b/internal/controller/domain/route_controller.go @@ -0,0 +1,192 @@ +/* +Copyright 2024 Amoniac OU. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package domain + +import ( + "context" + "time" + + "github.com/amoniacou/mailgun-operator/internal/configuration" + "github.com/mailgun/mailgun-go/v4" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + domainv1 "github.com/amoniacou/mailgun-operator/api/domain/v1" + corev1 "k8s.io/api/core/v1" +) + +const ( + routeFinalizer = "route.finalizers.mailgun.com" +) + +// RouteReconciler reconciles a Route object +type RouteReconciler struct { + client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder + Config *configuration.Data +} + +// +kubebuilder:rbac:groups=domain.mailgun.com,resources=routes,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=domain.mailgun.com,resources=routes/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=domain.mailgun.com,resources=routes/finalizers,verbs=update + +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.0/pkg/reconcile +func (r *RouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := log.FromContext(ctx) + + var mailgunRoute = &domainv1.Route{} + + // lookup for item + if err := r.Get(ctx, req.NamespacedName, mailgunRoute); err != nil { + if client.IgnoreNotFound(err) != nil { + log.Error(err, "unable to fetch Route") + } + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + log.V(1).Info("Start to reconcile route", "route", mailgunRoute) + mg := r.Config.MailgunClient("") + + // examine DeletionTimestamp to determine if object is under deletion + if mailgunRoute.ObjectMeta.DeletionTimestamp.IsZero() { + // The object is not being deleted, so if it does not have our finalizer, + // then lets add the finalizer and update the object. This is equivalent + // to registering our finalizer. + if !controllerutil.ContainsFinalizer(mailgunRoute, routeFinalizer) { + log.V(1).Info("adding finalizer for route") + controllerutil.AddFinalizer(mailgunRoute, routeFinalizer) + if err := r.Update(ctx, mailgunRoute); err != nil { + return ctrl.Result{}, err + } + } + } else { + // The object is being deleted + if controllerutil.ContainsFinalizer(mailgunRoute, routeFinalizer) { + log.V(1).Info("route is being deleted") + if mailgunRoute.Status.RouteID == nil { + // no need to try to remove the route if it does not created + controllerutil.RemoveFinalizer(mailgunRoute, routeFinalizer) + if err := r.Update(ctx, mailgunRoute); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + log.V(1).Info("trying to get route from mailgun") + _, err := mg.GetRoute(ctx, *mailgunRoute.Status.RouteID) + if err != nil { + log.V(1).Info("route not exits on mailgun", "id", *mailgunRoute.Status.RouteID, "error", err) + return ctrl.Result{}, nil + } + log.V(1).Info("trying to delete route from mailgun") + if err := mg.DeleteRoute(ctx, *mailgunRoute.Status.RouteID); err != nil { + errorMessage := "Unable to delete route from Mailgun" + mailgunRoute.Status.MailgunError = &errorMessage + log.V(1).Info("error deleting route from mailgun", "id", *mailgunRoute.Status.RouteID, "error", err) + if err := r.Status().Update(ctx, mailgunRoute); err != nil { + log.V(1).Info("unable to update status") + return ctrl.Result{}, err + } + return ctrl.Result{}, err + } + r.Recorder.Eventf(mailgunRoute, corev1.EventTypeWarning, "Deleted", "Route is deleted from mailgun") + controllerutil.RemoveFinalizer(mailgunRoute, routeFinalizer) + if err := r.Update(ctx, mailgunRoute); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + } + + log.V(1).Info("generate route request") + + routeRequest := mailgun.Route{ + Description: mailgunRoute.Spec.Description, + Expression: mailgunRoute.Spec.Expression, + Actions: mailgunRoute.Spec.Actions, + } + + if mailgunRoute.Spec.Priority != nil && *mailgunRoute.Spec.Priority != 0 { + routeRequest.Priority = *mailgunRoute.Spec.Priority + } + + log.V(1).Info("Route Request", "request", routeRequest) + + if mailgunRoute.Status.RouteID != nil && len(*mailgunRoute.Status.RouteID) > 0 { + existRoute, err := mg.GetRoute(ctx, *mailgunRoute.Status.RouteID) + if err != nil { + log.V(1).Info("route not exits not mailgun", "id", *mailgunRoute.Status.RouteID, "error", err) + return ctrl.Result{}, nil + } + update := false + if existRoute.Description != routeRequest.Description || + existRoute.Expression != routeRequest.Expression || + len(existRoute.Actions) != len(routeRequest.Actions) || + existRoute.Priority != routeRequest.Priority { + update = true + } + if update { + log.V(1).Info("update route on mailgun") + _, err = mg.UpdateRoute(ctx, *mailgunRoute.Status.RouteID, routeRequest) + if err != nil { + log.V(1).Info("Unable to update route", "error", err) + errorMessage := "Unable to update route" + mailgunRoute.Status.MailgunError = &errorMessage + if err := r.Status().Update(ctx, mailgunRoute); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil + } else { + log.V(1).Info("create route on mailgun") + routeResp, err := mg.CreateRoute(ctx, routeRequest) + + if err != nil { + log.Error(err, "unable to create route") + errorMessage := "Unable to create route" + r.Recorder.Eventf(mailgunRoute, corev1.EventTypeWarning, "FailedCreate", errorMessage) + mailgunRoute.Status.MailgunError = &errorMessage + if err := r.Status().Update(ctx, mailgunRoute); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{ + RequeueAfter: 1 * time.Minute, + }, nil + } + mailgunRoute.Status.RouteID = &routeResp.Id + } + + log.V(1).Info("successfully created/updated route", "id", *mailgunRoute.Status.RouteID) + + return ctrl.Result{}, r.Status().Update(ctx, mailgunRoute) +} + +func (r *RouteReconciler) SetupWithManager(mgr ctrl.Manager) error { + pred := predicate.GenerationChangedPredicate{} + return ctrl.NewControllerManagedBy(mgr). + For(&domainv1.Route{}). + WithEventFilter(pred). + Complete(r) +} diff --git a/internal/controller/domain/route_controller_test.go b/internal/controller/domain/route_controller_test.go new file mode 100644 index 0000000..65ea726 --- /dev/null +++ b/internal/controller/domain/route_controller_test.go @@ -0,0 +1,232 @@ +/* +Copyright 2024 Amoniac OU. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package domain + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + domainv1 "github.com/amoniacou/mailgun-operator/api/domain/v1" +) + +var _ = Describe("Route Controller", func() { + + const ( + timeout = time.Second * 10 + interval = time.Millisecond * 250 + duration = time.Second * 10 + ) + + Context("When reconciling a resource", func() { + It("should create mailgun route correctly and store ID of record", func() { + namespace := newFakeNamespace() + Expect(namespace).ToNot(BeNil()) + + router := newFakeRouter(namespace) + + doRouterLookup := types.NamespacedName{Name: router.Name, Namespace: namespace} + createdRouter := &domainv1.Route{} + Eventually(func() bool { + err := k8sClient.Get(ctx, doRouterLookup, createdRouter) + if err == nil { + return createdRouter.Status.RouteID != nil && len(*createdRouter.Status.RouteID) > 0 + } + return false + }, timeout, interval).Should(BeTrue()) + Expect(*createdRouter.Status.RouteID).ToNot(BeEmpty()) + Expect(createdRouter.Status.MailgunError).To(BeNil()) + }) + + It("should fail create of mailgun route", func() { + namespace := newFakeNamespace() + Expect(namespace).ToNot(BeNil()) + + routerName := "test-router" + router := &domainv1.Route{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "domain.mydomain.com/v1", + Kind: "Route", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: routerName, + Namespace: namespace, + }, + Spec: domainv1.RouteSpec{ + Description: "fail", + Expression: "match_recipient('.*@example.com')", + Actions: []string{ + "stop()", + }, + }, + } + err := k8sClient.Create(ctx, router) + Expect(err).ToNot(HaveOccurred()) + + doRouterLookup := types.NamespacedName{Name: router.Name, Namespace: namespace} + createdRouter := &domainv1.Route{} + + Eventually(func() bool { + err := k8sClient.Get(ctx, doRouterLookup, createdRouter) + if err == nil { + return createdRouter.Status.MailgunError != nil && len(*createdRouter.Status.MailgunError) > 0 + } + return false + }, timeout, interval).Should(BeTrue()) + + Expect(*createdRouter.Status.MailgunError).To(Equal("Unable to create route")) + + err = k8sClient.Delete(ctx, router) + Expect(err).ToNot(HaveOccurred()) + + routerList := domainv1.RouteList{} + + Eventually(func() bool { + err := k8sClient.List(ctx, &routerList, client.InNamespace(namespace)) + if err == nil { + return len(routerList.Items) == 0 + } + return false + }, timeout, interval).Should(BeTrue()) + + err = k8sClient.Get(ctx, doRouterLookup, createdRouter) + Expect(err).To(HaveOccurred()) + }) + + It("should delete mailgun route correctly", func() { + namespace := newFakeNamespace() + Expect(namespace).ToNot(BeNil()) + + router := newFakeRouter(namespace) + + doRouterLookup := types.NamespacedName{Name: router.Name, Namespace: namespace} + createdRouter := &domainv1.Route{} + Eventually(func() bool { + err := k8sClient.Get(ctx, doRouterLookup, createdRouter) + if err == nil { + return createdRouter.Status.RouteID != nil && len(*createdRouter.Status.RouteID) > 0 + } + return false + }, timeout, interval).Should(BeTrue()) + + err := k8sClient.Delete(ctx, router) + Expect(err).ToNot(HaveOccurred()) + + routerList := domainv1.RouteList{} + + Eventually(func() bool { + err := k8sClient.List(ctx, &routerList, client.InNamespace(namespace)) + if err == nil { + return len(routerList.Items) == 0 + } + return false + }, timeout, interval).Should(BeTrue()) + + err = k8sClient.Get(ctx, doRouterLookup, createdRouter) + Expect(err).To(HaveOccurred()) + }) + + It("should not remove finalizer if unable to remove route from mailgun", func() { + namespace := newFakeNamespace() + Expect(namespace).ToNot(BeNil()) + + router := newFakeRouter(namespace) + + doRouterLookup := types.NamespacedName{Name: router.Name, Namespace: namespace} + createdRouter := &domainv1.Route{} + + Eventually(func() bool { + err := k8sClient.Get(ctx, doRouterLookup, createdRouter) + if err == nil { + return createdRouter.Status.RouteID != nil && len(*createdRouter.Status.RouteID) > 0 + } + return false + }, timeout, interval).Should(BeTrue()) + + Expect(*createdRouter.Status.RouteID).ToNot(BeEmpty()) + Expect(createdRouter.Finalizers).To(ContainElement(routeFinalizer)) + By("Make the delete route to fail") + + mgm.FailRoutes("delete", *createdRouter.Status.RouteID) + + err := k8sClient.Delete(ctx, router) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { + err := k8sClient.Get(ctx, doRouterLookup, createdRouter) + if err == nil { + return createdRouter.Status.MailgunError != nil && len(*createdRouter.Status.MailgunError) > 0 + } + return false + }, timeout, interval).Should(BeTrue()) + + Expect(*createdRouter.Status.MailgunError).To(Equal("Unable to delete route from Mailgun")) + Expect(createdRouter.Finalizers).ToNot(BeEmpty()) + }) + + It("should update route on mailgun", func() { + namespace := newFakeNamespace() + Expect(namespace).ToNot(BeNil()) + routerName := "test-router" + router := &domainv1.Route{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "domain.mydomain.com/v1", + Kind: "Route", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: routerName, + Namespace: namespace, + }, + Spec: domainv1.RouteSpec{ + Description: "test-router", + Expression: "match_recipient('.*@example.com')", + Actions: []string{ + "stop()", + }, + }, + } + err := k8sClient.Create(ctx, router) + Expect(err).ToNot(HaveOccurred()) + + doRouterLookup := types.NamespacedName{Name: router.Name, Namespace: namespace} + createdRouter := &domainv1.Route{} + + Eventually(func() bool { + err := k8sClient.Get(ctx, doRouterLookup, createdRouter) + if err == nil { + return createdRouter.Status.RouteID != nil && len(*createdRouter.Status.RouteID) > 0 + } + return false + }, timeout, interval).Should(BeTrue()) + + Expect(*createdRouter.Status.RouteID).ToNot(BeEmpty()) + + createdRouter.Spec.Expression = "match_recipient('.*@updated.com')" + + err = k8sClient.Update(ctx, createdRouter) + Expect(err).ToNot(HaveOccurred()) + + err = k8sClient.Get(ctx, doRouterLookup, createdRouter) + Expect(err).ToNot(HaveOccurred()) + Expect(createdRouter.Status.MailgunError).To(BeNil()) + }) + }) +}) diff --git a/internal/controller/domain/suite_test.go b/internal/controller/domain/suite_test.go index 1d02b2d..089de88 100644 --- a/internal/controller/domain/suite_test.go +++ b/internal/controller/domain/suite_test.go @@ -110,7 +110,7 @@ var _ = BeforeSuite(func() { DomainVerifyDuration: 5, } - // start reconciler + // start domain reconciler err = (&DomainReconciler{ Client: k8sManager.GetClient(), Scheme: k8sManager.GetScheme(), @@ -119,6 +119,15 @@ var _ = BeforeSuite(func() { }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) + // start router reconciler + err = (&RouteReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: k8sManager.GetEventRecorderFor("route-controller"), + Config: operatorConfig, + }).SetupWithManager(k8sManager) + Expect(err).ToNot(HaveOccurred()) + // start manager go func() { defer GinkgoRecover() @@ -192,3 +201,25 @@ func newDigitalOceanDomain(namespace, domainName string, externalDNS bool) *doma Expect(err).ToNot(HaveOccurred()) return manager } + +func newFakeRouter(namespace string) *domainv1.Route { + name := "route-" + rand.String(10) + router := &domainv1.Route{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: domainv1.RouteSpec{ + Description: "fake description", + Expression: "match_recipient('.*@gmail.com')", + Actions: []string{ + "store()", + "forward(\"https://example.com\")", + }, + }, + } + err := k8sClient.Create(context.Background(), router) + Expect(err).ToNot(HaveOccurred()) + return router +} diff --git a/internal/utils/mailgun_mock_server.go b/internal/utils/mailgun_mock_server.go index 2973ecd..ffd6f60 100644 --- a/internal/utils/mailgun_mock_server.go +++ b/internal/utils/mailgun_mock_server.go @@ -5,10 +5,12 @@ import ( "net/http" "net/http/httptest" "slices" + "strconv" "sync" "time" "github.com/mailgun/mailgun-go/v4" + "k8s.io/apimachinery/pkg/util/rand" ) type MailgunMockServer struct { @@ -18,6 +20,8 @@ type MailgunMockServer struct { activeMXDomains []string failedDomains []string domainList []mailgun.DomainContainer + routes map[string]mailgun.Route + failRoutes map[string][]string mutex sync.Mutex } @@ -99,6 +103,19 @@ func (m *MailgunMockServer) DeleteDomain(domainName string) { } } +func (m *MailgunMockServer) FailRoutes(action, id string) { + defer m.mutex.Unlock() + m.mutex.Lock() + + if m.failRoutes == nil { + m.failRoutes = map[string][]string{} + } + if _, ok := m.failRoutes[action]; !ok { + m.failRoutes[action] = []string{} + } + m.failRoutes[action] = append(m.failRoutes[action], id) +} + // Private methods func (m *MailgunMockServer) initRoutes(mux *http.ServeMux) { @@ -107,6 +124,10 @@ func (m *MailgunMockServer) initRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /v4/domains/{domain}", m.authHandler(m.getDomain)) mux.HandleFunc("PUT /v4/domains/{domain}/verify", m.authHandler(m.verifyDomain)) mux.HandleFunc("DELETE /v4/domains/{domain}", m.authHandler(m.deleteDomain)) + mux.HandleFunc("POST /v4/routes", m.authHandler(m.createRoute)) + mux.HandleFunc("GET /v4/routes/{id}", m.authHandler(m.getRoute)) + mux.HandleFunc("PUT /v4/routes/{id}", m.authHandler(m.updateRoute)) + mux.HandleFunc("DELETE /v4/routes/{id}", m.authHandler(m.deleteRoute)) } func (m *MailgunMockServer) createDomain(w http.ResponseWriter, r *http.Request) { @@ -124,8 +145,18 @@ func (m *MailgunMockServer) createDomain(w http.ResponseWriter, r *http.Request) newDomain := m.newMGDomainFor(domainName) - receiveRecords := m.newMGDnsRecordsFor(domainName, false, slices.Contains(m.activeDomains, domainName), slices.Contains(m.activeMXDomains, domainName)) - sendingRecords := m.newMGDnsRecordsFor(domainName, true, slices.Contains(m.activeDomains, domainName), slices.Contains(m.activeMXDomains, domainName)) + receiveRecords := m.newMGDnsRecordsFor( + domainName, + false, + slices.Contains(m.activeDomains, domainName), + slices.Contains(m.activeMXDomains, domainName), + ) + sendingRecords := m.newMGDnsRecordsFor( + domainName, + true, + slices.Contains(m.activeDomains, domainName), + slices.Contains(m.activeMXDomains, domainName), + ) m.domainList = append(m.domainList, mailgun.DomainContainer{ Domain: newDomain, @@ -150,7 +181,12 @@ func (m *MailgunMockServer) getDomain(w http.ResponseWriter, r *http.Request) { domainName := r.PathValue("domain") for i, domain := range m.domainList { if domainName == domain.Domain.Name { - m.domainList[i].ReceivingDNSRecords = m.newMGDnsRecordsFor(domainName, false, slices.Contains(m.activeDomains, domainName), slices.Contains(m.activeMXDomains, domainName)) + m.domainList[i].ReceivingDNSRecords = m.newMGDnsRecordsFor( + domainName, + false, + slices.Contains(m.activeDomains, domainName), + slices.Contains(m.activeMXDomains, domainName), + ) toJSON(w, map[string]interface{}{ "message": "Domain DNS records have been retrieved", "domain": m.domainList[i].Domain, @@ -225,6 +261,159 @@ func (m *MailgunMockServer) deleteDomain(w http.ResponseWriter, r *http.Request) }, http.StatusNotFound) } +func (m *MailgunMockServer) createRoute(w http.ResponseWriter, r *http.Request) { + defer m.mutex.Unlock() + m.mutex.Lock() + + description := r.FormValue("description") + if description == "fail" { + toJSON(w, map[string]string{ + "message": "Failed to create route due to invalid expression.", + }, http.StatusInternalServerError) + return + } + expression := r.FormValue("expression") + priority := r.FormValue("priority") + priorityVal := 0 + if len(priority) > 0 { + p, err := strconv.Atoi(priority) + if err != nil { + toJSON(w, map[string]interface{}{ + "message": "Invalid priority value", + }, http.StatusBadRequest) + return + } + priorityVal = p + } + + id := "route-" + rand.String(10) + + newRoute := mailgun.Route{ + Id: id, + Priority: priorityVal, + Actions: r.Form["action"], + Description: description, + Expression: expression, + } + + if m.routes == nil { + m.routes = map[string]mailgun.Route{} + } + m.routes[newRoute.Id] = newRoute + + toJSON(w, map[string]interface{}{ + "message": "Route has been created", + "route": newRoute, + }, http.StatusOK) +} + +func (m *MailgunMockServer) getRoute(w http.ResponseWriter, r *http.Request) { + defer m.mutex.Unlock() + m.mutex.Lock() + + routeID := r.PathValue("id") + if routeID == "" { + toJSON(w, map[string]interface{}{ + "message": "Route ID is required", + }, http.StatusBadRequest) + return + } + + if _, ok := m.failRoutes["get"]; ok { + if slices.Contains(m.failRoutes["get"], routeID) { + toJSON(w, map[string]interface{}{ + "message": "Failed to get route", + }, http.StatusInternalServerError) + return + } + } + + route, ok := m.routes[routeID] + if !ok { + toJSON(w, map[string]interface{}{ + "message": "Route not found", + }, http.StatusNotFound) + return + } + toJSON(w, map[string]interface{}{ + "route": route, + }, http.StatusOK) +} + +func (m *MailgunMockServer) deleteRoute(w http.ResponseWriter, r *http.Request) { + defer m.mutex.Unlock() + m.mutex.Lock() + + routeID := r.PathValue("id") + if routeID == "" { + toJSON(w, map[string]interface{}{ + "message": "Route ID is required", + }, http.StatusBadRequest) + return + } + + if _, ok := m.failRoutes["delete"]; ok { + if slices.Contains(m.failRoutes["delete"], routeID) { + toJSON(w, map[string]interface{}{ + "message": "Failed to delete route", + }, http.StatusInternalServerError) + return + } + } + + delete(m.routes, routeID) + toJSON(w, map[string]interface{}{ + "message": "Route has been deleted", + "id": routeID, + }, http.StatusOK) +} + +func (m *MailgunMockServer) updateRoute(w http.ResponseWriter, r *http.Request) { + defer m.mutex.Unlock() + m.mutex.Lock() + + routeID := r.PathValue("id") + if routeID == "" { + toJSON(w, map[string]interface{}{ + "message": "Route ID is required", + }, http.StatusBadRequest) + return + } + route, ok := m.routes[routeID] + if !ok { + toJSON(w, map[string]interface{}{ + "message": "Route not found", + }, http.StatusNotFound) + return + } + + if r.FormValue("action") != "" { + route.Actions = r.Form["action"] + } + if r.FormValue("priority") != "" { + p, err := strconv.Atoi(r.FormValue("priority")) + if err != nil { + toJSON(w, map[string]interface{}{ + "message": "Invalid priority value", + }, http.StatusBadRequest) + return + } + route.Priority = p + } + if r.FormValue("description") != "" { + route.Description = r.FormValue("description") + } + if r.FormValue("expression") != "" { + route.Expression = r.FormValue("expression") + } + + m.routes[routeID] = route + toJSON(w, map[string]interface{}{ + "message": "Route has been updated", + "route": route, + }, http.StatusOK) +} + func (m *MailgunMockServer) authHandler(next http.HandlerFunc) http.HandlerFunc { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, password, ok := r.BasicAuth()