Repository: kserve/kserve

Issue:
Allow setting multiple domains on an inference service
/kind feature

**Describe the solution you'd like**
Currently KServe supports setting only a single external host for inference services. It would be useful if we could support multiple hosts.

We could enable this feature on a global level or on a per inference service basis.

#### Globally:
Add a section to the ingress config in the `inferenceservice-config` configmap that allows users to configure additional ingress domains. e.g.

```yaml
  ingress: |-
    {
        "ingressGateway" : "istio-system/ingress-gateway",
        "ingressService" : "istio-ingressgateway.istio-system.svc.cluster.local",
        "localGateway" : "istio-system/local-gateway",
        "localGatewayService" : "knative-local-gateway.istio-system.svc.cluster.local",
        "ingressDomain"  : "example.com",
        "additionalIngressDomains": [
             "other-example.com" 
        ]
    }
```

#### Per inference service
A user can specify an "alias" hostname for the inference service. This will be used alongside the automatically generated domain.

```yaml
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
  name: test-model
spec:
  predictor:
    # predict definition...
  hostAliases:
    - test-model.other-example.com
```

For serverless mode these hosts will be added to the `VirtualService` that KServe creates. This means that only the canonical name of the inference service will have the alias domain attached. i.e. the predictor, explainer, and transformer specific hosts will not be created for the alias domains.


Discussion:
/assign

Resolving patch:
diff --git a/python/kserve/kserve/models/v1beta1_ingress_config.py b/python/kserve/kserve/models/v1beta1_ingress_config.py
--- a/python/kserve/kserve/models/v1beta1_ingress_config.py
+++ b/python/kserve/kserve/models/v1beta1_ingress_config.py
@@ -47,6 +47,7 @@ class V1beta1IngressConfig(object):
                             and the value is json key in definition.
     """
     openapi_types = {
+        'additional_ingress_domains': 'list[str]',
         'disable_ingress_creation': 'bool',
         'disable_istio_virtual_host': 'bool',
         'domain_template': 'str',
@@ -61,6 +62,7 @@ class V1beta1IngressConfig(object):
     }
 
     attribute_map = {
+        'additional_ingress_domains': 'additionalIngressDomains',
         'disable_ingress_creation': 'disableIngressCreation',
         'disable_istio_virtual_host': 'disableIstioVirtualHost',
         'domain_template': 'domainTemplate',
@@ -74,12 +76,13 @@ class V1beta1IngressConfig(object):
         'url_scheme': 'urlScheme'
     }
 
-    def __init__(self, disable_ingress_creation=None, disable_istio_virtual_host=None, domain_template=None, ingress_class_name=None, ingress_domain=None, ingress_gateway=None, ingress_service=None, local_gateway=None, local_gateway_service=None, path_template=None, url_scheme=None, local_vars_configuration=None):  # noqa: E501
+    def __init__(self, additional_ingress_domains=None, disable_ingress_creation=None, disable_istio_virtual_host=None, domain_template=None, ingress_class_name=None, ingress_domain=None, ingress_gateway=None, ingress_service=None, local_gateway=None, local_gateway_service=None, path_template=None, url_scheme=None, local_vars_configuration=None):  # noqa: E501
         """V1beta1IngressConfig - a model defined in OpenAPI"""  # noqa: E501
         if local_vars_configuration is None:
             local_vars_configuration = Configuration()
         self.local_vars_configuration = local_vars_configuration
 
+        self._additional_ingress_domains = None
         self._disable_ingress_creation = None
         self._disable_istio_virtual_host = None
         self._domain_template = None
@@ -93,6 +96,8 @@ def __init__(self, disable_ingress_creation=None, disable_istio_virtual_host=Non
         self._url_scheme = None
         self.discriminator = None
 
+        if additional_ingress_domains is not None:
+            self.additional_ingress_domains = additional_ingress_domains
         if disable_ingress_creation is not None:
             self.disable_ingress_creation = disable_ingress_creation
         if disable_istio_virtual_host is not None:
@@ -116,6 +121,27 @@ def __init__(self, disable_ingress_creation=None, disable_istio_virtual_host=Non
         if url_scheme is not None:
             self.url_scheme = url_scheme
 
+    @property
+    def additional_ingress_domains(self):
+        """Gets the additional_ingress_domains of this V1beta1IngressConfig.  # noqa: E501
+
+
+        :return: The additional_ingress_domains of this V1beta1IngressConfig.  # noqa: E501
+        :rtype: list[str]
+        """
+        return self._additional_ingress_domains
+
+    @additional_ingress_domains.setter
+    def additional_ingress_domains(self, additional_ingress_domains):
+        """Sets the additional_ingress_domains of this V1beta1IngressConfig.
+
+
+        :param additional_ingress_domains: The additional_ingress_domains of this V1beta1IngressConfig.  # noqa: E501
+        :type: list[str]
+        """
+
+        self._additional_ingress_domains = additional_ingress_domains
+
     @property
     def disable_ingress_creation(self):
         """Gets the disable_ingress_creation of this V1beta1IngressConfig.  # noqa: E501


Test patch:
diff --git a/pkg/apis/serving/v1beta1/configmap_test.go b/pkg/apis/serving/v1beta1/configmap_test.go
--- a/pkg/apis/serving/v1beta1/configmap_test.go
+++ b/pkg/apis/serving/v1beta1/configmap_test.go
@@ -17,8 +17,7 @@ limitations under the License.
 package v1beta1
 
 import (
-	ctx "context"
-	logger "log"
+	"fmt"
 	"testing"
 
 	"github.com/kserve/kserve/pkg/constants"
@@ -26,52 +25,28 @@ import (
 	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	fakeclientset "k8s.io/client-go/kubernetes/fake"
-	"sigs.k8s.io/controller-runtime/pkg/client"
-	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
 )
 
-func createFakeClient() client.WithWatch {
-	clientBuilder := fakeclient.NewClientBuilder()
-	fakeClient := clientBuilder.Build()
-	configMap := &v1.ConfigMap{
-		TypeMeta: metav1.TypeMeta{
-			Kind:       "ConfigMap",
-			APIVersion: "",
-		},
-		ObjectMeta: metav1.ObjectMeta{
-			Name:      constants.InferenceServiceConfigMapName,
-			Namespace: constants.KServeNamespace,
-		},
-		Immutable: nil,
-		Data:      map[string]string{},
-		BinaryData: map[string][]byte{
-			ExplainerConfigKeyName: []byte(`{                                                                                                                                                                                               │
-				     "art": {                                                                                                                                                                                                    │
-				         "image" : "kserve/art-explainer",                                                                                                                                                                       │
-				         "defaultImageVersion": "latest"                                                                                                                                                                         │
-				     }                                                                                                                                                                                                           │
-			}`),
-			IngressConfigKeyName: []byte(`{                                                                                                                                                                                                               │
-     				"ingressGateway" : "knative-serving/knative-ingress-gateway",                                                                                                                                               │
-     				"ingressService" : "istio-ingressgateway.istio-system.svc.cluster.local",                                                                                                                                   │
-     				"localGateway" : "knative-serving/knative-local-gateway",                                                                                                                                                   │
-     				"localGatewayService" : "knative-local-gateway.istio-system.svc.cluster.local",                                                                                                                             │
-     				"ingressDomain"  : "example.com",                                                                                                                                                                           │
-     				"ingressClassName" : "istio",                                                                                                                                                                               │
-     				"domainTemplate": "{{ .Name }}-{{ .Namespace }}.{{ .IngressDomain }}",                                                                                                                                      │
-     				"urlScheme": "http"                                                                                                                                                                                         │
- 			}`),
-			DeployConfigName: []byte(`{                                                                                                                                                                                                               │
-   				"defaultDeploymentMode": "Serverless"                                                                                                                                                                         │
- 			}`),
-		},
-	}
-	err := fakeClient.Create(ctx.TODO(), configMap)
-	if err != nil {
-		logger.Fatalf("Unable to create configmap: %v", err)
-	}
-	return fakeClient
-}
+var (
+	KnativeIngressGateway = "knative-serving/knative-ingress-gateway"
+	IngressService        = "test-destination"
+	KnativeLocalGateway   = "knative-serving/knative-local-gateway"
+	LocalGatewayService   = "knative-local-gateway.istio-system.svc.cluster.local"
+	UrlScheme             = "https"
+	IngressDomain         = "example.com"
+	AdditionalDomain      = "additional-example.com"
+	AdditionalDomainExtra = "additional-example-extra.com"
+	IngressConfigData     = fmt.Sprintf(`{
+		"ingressGateway" : "%s",
+		"ingressService" : "%s",
+		"localGateway" : "%s",
+		"localGatewayService" : "%s",
+		"ingressDomain": "%s",
+		"urlScheme": "https",
+        "additionalIngressDomains": ["%s","%s"]
+	}`, KnativeIngressGateway, IngressService, KnativeLocalGateway, LocalGatewayService, IngressDomain,
+		AdditionalDomain, AdditionalDomainExtra)
+)
 
 func TestNewInferenceServiceConfig(t *testing.T) {
 	g := gomega.NewGomegaWithT(t)
@@ -87,10 +62,21 @@ func TestNewIngressConfig(t *testing.T) {
 	g := gomega.NewGomegaWithT(t)
 	clientset := fakeclientset.NewSimpleClientset(&v1.ConfigMap{
 		ObjectMeta: metav1.ObjectMeta{Name: constants.InferenceServiceConfigMapName, Namespace: constants.KServeNamespace},
+		Data: map[string]string{
+			IngressConfigKeyName: IngressConfigData,
+		},
 	})
 	ingressCfg, err := NewIngressConfig(clientset)
 	g.Expect(err).Should(gomega.BeNil())
 	g.Expect(ingressCfg).ShouldNot(gomega.BeNil())
+
+	g.Expect(ingressCfg.IngressGateway).To(gomega.Equal(KnativeIngressGateway))
+	g.Expect(ingressCfg.IngressServiceName).To(gomega.Equal(IngressService))
+	g.Expect(ingressCfg.LocalGateway).To(gomega.Equal(KnativeLocalGateway))
+	g.Expect(ingressCfg.LocalGatewayServiceName).To(gomega.Equal(LocalGatewayService))
+	g.Expect(ingressCfg.UrlScheme).To(gomega.Equal(UrlScheme))
+	g.Expect(ingressCfg.IngressDomain).To(gomega.Equal(IngressDomain))
+	g.Expect(*ingressCfg.AdditionalIngressDomains).To(gomega.Equal([]string{AdditionalDomain, AdditionalDomainExtra}))
 }
 
 func TestNewDeployConfig(t *testing.T) {
diff --git a/pkg/controller/v1beta1/inferenceservice/reconcilers/ingress/ingress_reconciler_test.go b/pkg/controller/v1beta1/inferenceservice/reconcilers/ingress/ingress_reconciler_test.go
--- a/pkg/controller/v1beta1/inferenceservice/reconcilers/ingress/ingress_reconciler_test.go
+++ b/pkg/controller/v1beta1/inferenceservice/reconcilers/ingress/ingress_reconciler_test.go
@@ -18,6 +18,9 @@ package ingress
 
 import (
 	"fmt"
+	"net/url"
+	"testing"
+
 	"github.com/google/go-cmp/cmp"
 	"github.com/kserve/kserve/pkg/apis/serving/v1beta1"
 	"github.com/kserve/kserve/pkg/constants"
@@ -31,8 +34,6 @@ import (
 	"knative.dev/pkg/apis"
 	duckv1 "knative.dev/pkg/apis/duck/v1"
 	"knative.dev/pkg/network"
-	"net/url"
-	"testing"
 )
 
 func TestCreateVirtualService(t *testing.T) {
@@ -42,6 +43,8 @@ func TestCreateVirtualService(t *testing.T) {
 	isvcAnnotations := map[string]string{"test": "test", "kubectl.kubernetes.io/last-applied-configuration": "test"}
 	labels := map[string]string{"test": "test"}
 	domain := "example.com"
+	additionalDomain := "my-additional-domain.com"
+	additionalSecondDomain := "my-second-additional-domain.com"
 	serviceHostName := constants.InferenceServiceHostName(serviceName, namespace, domain)
 	serviceInternalHostName := network.GetServiceHostname(serviceName, namespace)
 	predictorHostname := constants.InferenceServiceHostName(constants.DefaultPredictorServiceName(serviceName), namespace, domain)
@@ -69,6 +72,7 @@ func TestCreateVirtualService(t *testing.T) {
 		name            string
 		isvc            *v1beta1.InferenceService
 		ingressConfig   *v1beta1.IngressConfig
+		domainList      *[]string
 		useDefault      bool
 		componentStatus *v1beta1.InferenceServiceStatus
 		expectedService *istioclientv1beta1.VirtualService
@@ -641,6 +645,144 @@ func TestCreateVirtualService(t *testing.T) {
 					},
 				},
 			},
+		}, {
+			name: "found predictor status with the additional ingress domains",
+			ingressConfig: &v1beta1.IngressConfig{
+				IngressGateway:           constants.KnativeIngressGateway,
+				IngressServiceName:       "someIngressServiceName",
+				LocalGateway:             constants.KnativeLocalGateway,
+				LocalGatewayServiceName:  "knative-local-gateway.istio-system.svc.cluster.local",
+				UrlScheme:                "http",
+				IngressDomain:            "my-domain.com",
+				AdditionalIngressDomains: &[]string{additionalDomain, additionalSecondDomain},
+				PathTemplate:             "/serving/{{ .Namespace }}/{{ .Name }}",
+				DisableIstioVirtualHost:  false,
+			},
+			domainList: &[]string{"my-domain-1.com", "example.com"},
+			useDefault: false,
+			componentStatus: &v1beta1.InferenceServiceStatus{
+				Status: duckv1.Status{
+					Conditions: duckv1.Conditions{
+						{
+							Type:   v1beta1.PredictorReady,
+							Status: corev1.ConditionTrue,
+						},
+					},
+				},
+				Components: map[v1beta1.ComponentType]v1beta1.ComponentStatusSpec{
+					v1beta1.PredictorComponent: {
+						URL: &apis.URL{
+							Scheme: "http",
+							Host:   predictorHostname,
+						},
+						Address: &duckv1.Addressable{
+							URL: &apis.URL{
+								Scheme: "http",
+								Host:   network.GetServiceHostname(constants.PredictorServiceName(serviceName), namespace),
+							},
+						},
+					},
+				},
+			},
+			expectedService: &istioclientv1beta1.VirtualService{
+				ObjectMeta: metav1.ObjectMeta{Name: serviceName, Namespace: namespace, Annotations: annotations, Labels: labels},
+				Spec: istiov1beta1.VirtualService{
+					Hosts: []string{serviceInternalHostName, serviceHostName, "my-domain.com",
+						"my-model.test.my-additional-domain.com", "my-model.test.my-second-additional-domain.com"},
+					Gateways: []string{constants.KnativeLocalGateway, constants.KnativeIngressGateway},
+					Http: []*istiov1beta1.HTTPRoute{
+						{
+							Match: []*istiov1beta1.HTTPMatchRequest{
+								{
+									Authority: &istiov1beta1.StringMatch{
+										MatchType: &istiov1beta1.StringMatch_Regex{
+											Regex: constants.HostRegExp(network.GetServiceHostname(serviceName, namespace)),
+										},
+									},
+									Gateways: []string{constants.KnativeLocalGateway},
+								},
+								{
+									Authority: &istiov1beta1.StringMatch{
+										MatchType: &istiov1beta1.StringMatch_Regex{
+											Regex: constants.HostRegExp(constants.InferenceServiceHostName(serviceName, namespace, domain)),
+										},
+									},
+									Gateways: []string{constants.KnativeIngressGateway},
+								},
+								{
+									Authority: &istiov1beta1.StringMatch{
+										MatchType: &istiov1beta1.StringMatch_Regex{
+											Regex: constants.HostRegExp(constants.InferenceServiceHostName(serviceName, namespace, additionalDomain)),
+										},
+									},
+									Gateways: []string{constants.KnativeIngressGateway},
+								},
+								{
+									Authority: &istiov1beta1.StringMatch{
+										MatchType: &istiov1beta1.StringMatch_Regex{
+											Regex: constants.HostRegExp(constants.InferenceServiceHostName(serviceName, namespace, additionalSecondDomain)),
+										},
+									},
+									Gateways: []string{constants.KnativeIngressGateway},
+								},
+							},
+							Route: []*istiov1beta1.HTTPRouteDestination{
+								{
+									Destination: &istiov1beta1.Destination{Host: constants.LocalGatewayHost, Port: &istiov1beta1.PortSelector{Number: constants.CommonDefaultHttpPort}},
+									Weight:      100,
+								},
+							},
+							Headers: &istiov1beta1.Headers{
+								Request: &istiov1beta1.Headers_HeaderOperations{Set: map[string]string{
+									"Host": network.GetServiceHostname(constants.PredictorServiceName(serviceName), namespace)}},
+							},
+						},
+						{
+							Match: []*istiov1beta1.HTTPMatchRequest{
+								{
+									Uri: &istiov1beta1.StringMatch{
+										MatchType: &istiov1beta1.StringMatch_Prefix{
+											Prefix: fmt.Sprintf("/serving/%s/%s/", namespace, serviceName),
+										},
+									},
+									Authority: &istiov1beta1.StringMatch{
+										MatchType: &istiov1beta1.StringMatch_Regex{
+											Regex: constants.HostRegExp("my-domain.com"),
+										},
+									},
+									Gateways: []string{constants.KnativeIngressGateway},
+								},
+								{
+									Uri: &istiov1beta1.StringMatch{
+										MatchType: &istiov1beta1.StringMatch_Exact{
+											Exact: fmt.Sprintf("/serving/%s/%s", namespace, serviceName),
+										},
+									},
+									Authority: &istiov1beta1.StringMatch{
+										MatchType: &istiov1beta1.StringMatch_Regex{
+											Regex: constants.HostRegExp("my-domain.com"),
+										},
+									},
+									Gateways: []string{constants.KnativeIngressGateway},
+								},
+							},
+							Rewrite: &istiov1beta1.HTTPRewrite{
+								Uri: "/",
+							},
+							Route: []*istiov1beta1.HTTPRouteDestination{
+								createHTTPRouteDestination("knative-local-gateway.istio-system.svc.cluster.local"),
+							},
+							Headers: &istiov1beta1.Headers{
+								Request: &istiov1beta1.Headers_HeaderOperations{
+									Set: map[string]string{
+										"Host": network.GetServiceHostname(constants.PredictorServiceName(serviceName), namespace),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
 		}, {
 			name: "found predictor status with default suffix",
 			ingressConfig: &v1beta1.IngressConfig{
@@ -1007,7 +1149,7 @@ func TestCreateVirtualService(t *testing.T) {
 				testIsvc.Spec.Explainer = &v1beta1.ExplainerSpec{}
 			}
 
-			actualService := createIngress(testIsvc, tc.useDefault, tc.ingressConfig)
+			actualService := createIngress(testIsvc, tc.useDefault, tc.ingressConfig, tc.domainList)
 			if diff := cmp.Diff(tc.expectedService.DeepCopy(), actualService.DeepCopy(), protocmp.Transform()); diff != "" {
 				t.Errorf("Test %q unexpected status (-want +got): %v", tc.name, diff)
 			}