diff --git a/api/formance.com/v1beta1/gateway_types.go b/api/formance.com/v1beta1/gateway_types.go index 925cd5e1..5828da2a 100644 --- a/api/formance.com/v1beta1/gateway_types.go +++ b/api/formance.com/v1beta1/gateway_types.go @@ -25,11 +25,15 @@ type GatewayIngressTLS struct { SecretName string `json:"secretName"` } +// GatewayIngress represents the ingress configuration for the gateway. type GatewayIngress struct { // Indicates the hostname on which the stack will be served. // Example : `formance.example.com` //+required Host string `json:"host"` + // Additional hosts for the ingress. Combined with Host. + //+optional + Hosts []string `json:"hosts,omitempty"` // Indicate the scheme. // // Actually, It should be `https` unless you know what you are doing. @@ -47,6 +51,28 @@ type GatewayIngress struct { TLS *GatewayIngressTLS `json:"tls,omitempty"` } +// DedupHosts returns the given hosts deduplicated, preserving order and skipping empty strings. +func DedupHosts(input []string) []string { + seen := map[string]struct{}{} + var hosts []string + for _, h := range input { + if h == "" { + continue + } + if _, ok := seen[h]; ok { + continue + } + seen[h] = struct{}{} + hosts = append(hosts, h) + } + return hosts +} + +// GetHosts returns the deduplicated union of Host and Hosts. +func (in *GatewayIngress) GetHosts() []string { + return DedupHosts(append([]string{in.Host}, in.Hosts...)) +} + type GatewaySpec struct { StackDependency `json:",inline"` ModuleProperties `json:",inline"` diff --git a/api/formance.com/v1beta1/zz_generated.deepcopy.go b/api/formance.com/v1beta1/zz_generated.deepcopy.go index 6acf8a9b..23ec3b07 100644 --- a/api/formance.com/v1beta1/zz_generated.deepcopy.go +++ b/api/formance.com/v1beta1/zz_generated.deepcopy.go @@ -1079,6 +1079,11 @@ func (in *GatewayHTTPAPIStatus) DeepCopy() *GatewayHTTPAPIStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GatewayIngress) DeepCopyInto(out *GatewayIngress) { *out = *in + if in.Hosts != nil { + in, out := &in.Hosts, &out.Hosts + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.IngressClassName != nil { in, out := &in.IngressClassName, &out.IngressClassName *out = new(string) diff --git a/config/crd/bases/formance.com_gateways.yaml b/config/crd/bases/formance.com_gateways.yaml index 7ba4afc8..aaf0f6ac 100644 --- a/config/crd/bases/formance.com_gateways.yaml +++ b/config/crd/bases/formance.com_gateways.yaml @@ -84,6 +84,11 @@ spec: Indicates the hostname on which the stack will be served. Example : `formance.example.com` type: string + hosts: + description: Additional hosts for the ingress. Combined with Host. + items: + type: string + type: array ingressClassName: description: Ingress class to use type: string diff --git a/docs/04-Modules/02-Gateway.md b/docs/04-Modules/02-Gateway.md index 8e20dded..32738fe8 100644 --- a/docs/04-Modules/02-Gateway.md +++ b/docs/04-Modules/02-Gateway.md @@ -28,4 +28,37 @@ spec: ingress: host: YOUR_DOMAIN scheme: http|https +``` + +### Serving on Multiple Hosts + +You can serve a stack on multiple hostnames by adding the `hosts` field alongside `host`. The ingress will contain a rule for each host, and TLS will cover all of them. + +```yaml +apiVersion: formance.com/v1beta1 +kind: Gateway +metadata: + name: formance-dev +spec: + stack: formance-dev + ingress: + host: app.example.com + hosts: + - app.example.org + - app.example.net + scheme: https +``` + +Additional hosts can also be provided via a [Settings](../09-Configuration%20reference/01-Settings.md) resource using the `gateway.ingress.hosts` key. Hosts from the setting are merged with those defined on the Gateway CRD. The `{stack}` placeholder is replaced with the stack name. + +```yaml +apiVersion: formance.com/v1beta1 +kind: Settings +metadata: + name: gateway-extra-hosts +spec: + key: gateway.ingress.hosts + stacks: + - '*' + value: "{stack}.example.com, {stack}.example.org" ``` \ No newline at end of file diff --git a/docs/09-Configuration reference/01-Settings.md b/docs/09-Configuration reference/01-Settings.md index 3b03a190..a374edf1 100644 --- a/docs/09-Configuration reference/01-Settings.md +++ b/docs/09-Configuration reference/01-Settings.md @@ -29,7 +29,7 @@ While we have some basic types (string, number, bool ...), we also have some com | ledger.experimental-numscript-flags | Array | experimental-overdraft-function experimental-get-asset-function experimental-get-amount-function experimental-oneof experimental-account-interpolation experimental-mid-script-function-call experimental-asset-colors | Enable numscript interpreter flags | | ledger.experimental-exporters | Bool | true | Enable new exporters feature | | ledger.worker.async-block-hasher | Map | max-block-size=1000, schedule="0 * * * * *" | Configure async block hasher for the Ledger worker (v2.3+). Fields: `max-block-size`, `schedule` | -| ledger.worker.pipelines | Map | pull-interval=5s, push-retry-period=10s, sync-period=1m, logs-page-size=100 | Configure pipelines for the Ledger worker (v2.3+). Fields: `pull-interval`, `push-retry-period`, `sync-period`, `logs-page-size` | +| ledger.worker.pipelines | Map | pull-interval=5s, push-retry-period=10s, sync-period=1m, logs-page-size=100 | Configure pipelines for the Ledger worker (v2.3+). Fields: `pull-interval`, `push-retry-period`, `sync-period`, `logs-page-size` | | payments.encryption-key | string | | Payments data encryption key | | payments.worker.temporal-max-concurrent-workflow-task-pollers | Int | | Payments worker max concurrent workflow task pollers configuration | | payments.worker.temporal-max-concurrent-activity-task-pollers | Int | | Payments worker max concurrent activity task pollers configuration | @@ -53,11 +53,12 @@ While we have some basic types (string, number, bool ...), we also have some com | services.``.annotations | Map | | Allow to specify custom annotations to apply on created k8s services | | services.``.traffic-distribution | string | PreferSameZone, PreferSameNode, PreferClose | Configure traffic distribution for Kubernetes services (requires Kubernetes 1.34+). See [Kubernetes documentation](https://kubernetes.io/docs/reference/networking/virtual-ips) | | gateway.ingress.annotations | Map | | Allow to specify custom annotations to apply on the gateway ingress | +| gateway.ingress.hosts | string | {stack}.example.com,{stack}.example.org | Comma-separated list of additional hosts for the gateway ingress. Combined with hosts defined on the Gateway CRD. Supports `{stack}` placeholder | | gateway.ingress.labels | Map | | Allow to specify custom labels to apply on the gateways ingress | | logging.json | bool | | Configure services to log as json | | modules.``.database.connection-pool | Map | max-idle=10, max-idle-time=10s, max-open=10, max-lifetime=5m | Configure database connection pool for each module. See [Golang documentation](https://go.dev/doc/database/manage-connections) | | orchestration.max-parallel-activities | Int | 10 | Configure max parallel temporal activities on orchestration workers | -| transactionplane.worker-enabled | bool | false | Enable the embedded worker inside the transactionplane server to run a single service instead of separate API and worker processes | +| transactionplane.worker-enabled | bool | false | Enable the embedded worker inside the transactionplane server to run a single service instead of separate API and worker processes | | modules.``.grace-period | string | 5s | Defer application shutdown | | namespace.labels | Map | somelabel=somevalue,anotherlabel=anothervalue | Add static labels to namespace | | namespace.annotations | Map | someannotation=somevalue,anotherannotation=anothervalue | Add static annotations to namespace | @@ -77,7 +78,7 @@ While we have some basic types (string, number, bool ...), we also have some com | gateway.dns.public.record-type | string | CNAME | DNS record type (e.g., CNAME, A, AAAA) | | gateway.dns.public.provider-specific | Map | alias=true,aws/target-hosted-zone=same-zone | Provider-specific DNS settings for public endpoints | | gateway.dns.public.annotations | Map | | Annotations to add to the public DNSEndpoint resource | -| networkpolicies.enabled | bool | true | Enable network micro-segmentation within a Stack namespace. When enabled, only the Gateway can reach other services | +| networkpolicies.enabled | bool | true | Enable network micro-segmentation within a Stack namespace. When enabled, only the Gateway can reach other services | ### Postgres URI format diff --git a/helm/crds/templates/crds/apiextensions.k8s.io_v1_customresourcedefinition_gateways.formance.com.yaml b/helm/crds/templates/crds/apiextensions.k8s.io_v1_customresourcedefinition_gateways.formance.com.yaml index eb34a483..91c103c2 100644 --- a/helm/crds/templates/crds/apiextensions.k8s.io_v1_customresourcedefinition_gateways.formance.com.yaml +++ b/helm/crds/templates/crds/apiextensions.k8s.io_v1_customresourcedefinition_gateways.formance.com.yaml @@ -87,6 +87,11 @@ spec: Indicates the hostname on which the stack will be served. Example : `formance.example.com` type: string + hosts: + description: Additional hosts for the ingress. Combined with Host. + items: + type: string + type: array ingressClassName: description: Ingress class to use type: string diff --git a/internal/resources/gateways/ingress.go b/internal/resources/gateways/ingress.go index 7f890c49..9be29919 100644 --- a/internal/resources/gateways/ingress.go +++ b/internal/resources/gateways/ingress.go @@ -1,6 +1,8 @@ package gateways import ( + "strings" + v1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -48,7 +50,20 @@ func withLabels(ctx core.Context, stack *v1beta1.Stack, owner client.Object) cor } } -func withTls(ctx core.Context, gateway *v1beta1.Gateway) core.ObjectMutator[*v1.Ingress] { +func getAllHosts(ctx core.Context, gateway *v1beta1.Gateway) ([]string, error) { + settingsHosts, err := settings.GetTrimmedStringSlice(ctx, gateway.Spec.Stack, "gateway", "ingress", "hosts") + if err != nil { + return nil, err + } + + for i, h := range settingsHosts { + settingsHosts[i] = strings.ReplaceAll(h, "{stack}", gateway.Spec.Stack) + } + + return v1beta1.DedupHosts(append(gateway.Spec.Ingress.GetHosts(), settingsHosts...)), nil +} + +func withTls(ctx core.Context, gateway *v1beta1.Gateway, hosts []string) core.ObjectMutator[*v1.Ingress] { return func(t *v1.Ingress) error { var secretName string if gateway.Spec.Ingress.TLS == nil { @@ -66,7 +81,7 @@ func withTls(ctx core.Context, gateway *v1beta1.Gateway) core.ObjectMutator[*v1. t.Spec.TLS = []v1.IngressTLS{{ SecretName: secretName, - Hosts: []string{gateway.Spec.Ingress.Host}, + Hosts: hosts, }} return nil @@ -93,12 +108,13 @@ func withIngressClassName(ctx core.Context, stack *v1beta1.Stack, gateway *v1bet } } -func withIngressRules(ctx core.Context, gateway *v1beta1.Gateway) core.ObjectMutator[*v1.Ingress] { +func withIngressRules(hosts []string) core.ObjectMutator[*v1.Ingress] { return func(t *v1.Ingress) error { pathType := v1.PathTypePrefix - r := []v1.IngressRule{ - { - Host: gateway.Spec.Ingress.Host, + var rules []v1.IngressRule + for _, host := range hosts { + rules = append(rules, v1.IngressRule{ + Host: host, IngressRuleValue: v1.IngressRuleValue{ HTTP: &v1.HTTPIngressRuleValue{ Paths: []v1.HTTPIngressPath{ @@ -117,9 +133,9 @@ func withIngressRules(ctx core.Context, gateway *v1beta1.Gateway) core.ObjectMut }, }, }, - }, + }) } - t.Spec.Rules = r + t.Spec.Rules = rules return nil } } @@ -134,12 +150,17 @@ func createIngress(ctx core.Context, stack *v1beta1.Stack, return core.DeleteIfExists[*v1.Ingress](ctx, name) } - _, _, err := core.CreateOrUpdate(ctx, name, + hosts, err := getAllHosts(ctx, gateway) + if err != nil { + return err + } + + _, _, err = core.CreateOrUpdate(ctx, name, withAnnotations(ctx, stack, gateway), withLabels(ctx, stack, gateway), withIngressClassName(ctx, stack, gateway), - withIngressRules(ctx, gateway), - withTls(ctx, gateway), + withIngressRules(hosts), + withTls(ctx, gateway, hosts), core.WithController[*v1.Ingress](ctx.GetScheme(), gateway), ) diff --git a/internal/tests/gateway_controller_test.go b/internal/tests/gateway_controller_test.go index 9b3386e6..c0a80949 100644 --- a/internal/tests/gateway_controller_test.go +++ b/internal/tests/gateway_controller_test.go @@ -132,6 +132,77 @@ var _ = Describe("GatewayController", func() { }) }) }) + Context("with multiple hosts defined", func() { + JustBeforeEach(func() { + patch := client.MergeFrom(gateway.DeepCopy()) + gateway.Spec.Ingress = &v1beta1.GatewayIngress{ + Host: "example.com", + Hosts: []string{"example.org"}, + Scheme: "https", + TLS: &v1beta1.GatewayIngressTLS{ + SecretName: "my-tls-secret", + }, + } + Expect(Patch(gateway, patch)).To(Succeed()) + }) + It("Should create an ingress with rules for all hosts", func() { + ingress := &networkingv1.Ingress{} + Eventually(func(g Gomega) { + g.Expect(LoadResource(stack.Name, "gateway", ingress)).To(Succeed()) + g.Expect(ingress.Spec.Rules).To(HaveLen(2)) + g.Expect(ingress.Spec.Rules[0].Host).To(Equal("example.com")) + g.Expect(ingress.Spec.Rules[1].Host).To(Equal("example.org")) + }).Should(Succeed()) + }) + It("Should include all hosts in TLS", func() { + ingress := &networkingv1.Ingress{} + Eventually(func(g Gomega) { + g.Expect(LoadResource(stack.Name, "gateway", ingress)).To(Succeed()) + g.Expect(ingress.Spec.TLS).To(HaveLen(1)) + g.Expect(ingress.Spec.TLS[0].Hosts).To(ConsistOf("example.com", "example.org")) + g.Expect(ingress.Spec.TLS[0].SecretName).To(Equal("my-tls-secret")) + }).Should(Succeed()) + }) + }) + Context("with additional hosts from settings", func() { + var hostsSetting *v1beta1.Settings + JustBeforeEach(func() { + hostsSetting = settings.New(uuid.NewString(), "gateway.ingress.hosts", "{stack}.example.com, {stack}.example.org", stack.Name) + Expect(Create(hostsSetting)).To(Succeed()) + }) + AfterEach(func() { + Expect(Delete(hostsSetting)).To(Succeed()) + }) + It("Should create an ingress with rules for spec host and settings hosts with {stack} replaced", func() { + ingress := &networkingv1.Ingress{} + Eventually(func(g Gomega) { + g.Expect(LoadResource(stack.Name, "gateway", ingress)).To(Succeed()) + g.Expect(ingress.Spec.Rules).To(HaveLen(3)) + g.Expect(ingress.Spec.Rules[0].Host).To(Equal("example.net")) + g.Expect(ingress.Spec.Rules[1].Host).To(Equal(stack.Name + ".example.com")) + g.Expect(ingress.Spec.Rules[2].Host).To(Equal(stack.Name + ".example.org")) + }).Should(Succeed()) + }) + }) + Context("with overlapping hosts between spec and settings", func() { + var hostsSetting *v1beta1.Settings + JustBeforeEach(func() { + hostsSetting = settings.New(uuid.NewString(), "gateway.ingress.hosts", "example.net, extra.example.com", stack.Name) + Expect(Create(hostsSetting)).To(Succeed()) + }) + AfterEach(func() { + Expect(Delete(hostsSetting)).To(Succeed()) + }) + It("Should deduplicate hosts in the ingress rules", func() { + ingress := &networkingv1.Ingress{} + Eventually(func(g Gomega) { + g.Expect(LoadResource(stack.Name, "gateway", ingress)).To(Succeed()) + g.Expect(ingress.Spec.Rules).To(HaveLen(2)) + g.Expect(ingress.Spec.Rules[0].Host).To(Equal("example.net")) + g.Expect(ingress.Spec.Rules[1].Host).To(Equal("extra.example.com")) + }).Should(Succeed()) + }) + }) Context("Configure ingress annotations", func() { var ingressSetting *v1beta1.Settings JustBeforeEach(func() {