You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by vi...@apache.org on 2026/10/18 17:48:02 UTC

(superset-kubernetes-operator) branch main updated: fix(networking): route a hostless Ingress to every host (#461)

This is an automated email from the ASF dual-hosted git repository.

villebro pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/superset-kubernetes-operator.git


The following commit(s) were added to refs/heads/main by this push:
     new 19debd1  fix(networking): route a hostless Ingress to every host (#461)
19debd1 is described below

commit 19debd1954e7e2b3a1e585bf5801801dc78ec8c6
Author: Younsung Lee <cy...@kakao.com>
AuthorDate: Mon Oct 19 02:47:56 2026 +0900

    fix(networking): route a hostless Ingress to every host (#461)
    
    When spec.networking.ingress has neither host nor hosts, for example
    only className: alb, the operator built an Ingress with no rules and
    no default backend. The Kubernetes API server rejects that object with
    "either `defaultBackend` or `rules` must be specified", so every
    reconcile failed and no Ingress was ever created. Both fields are
    optional in the CRD, and running an Ingress without a hostname is a
    common setup with AWS Load Balancer Controller and ingress-nginx.
    
    The operator now emits a single rule with an empty host when no host
    is given. An empty host matches every hostname, and the rule carries
    the same per-component path fan-out that a host without explicit
    paths already gets. Specs that set host or hosts render exactly as
    before.
    
    The existing unit tests use a fake client that does not run API server
    validation, which is why this was not caught. This change adds a unit
    test for the hostless spec and extends the e2e journey to drop the
    Ingress host on a real cluster and check the hostless rule. The
    networking guide now documents the hostless behavior.
    
    Signed-off-by: younsl <cy...@kakao.com>
---
 docs/user-guide/networking-and-monitoring.md |  2 ++
 internal/controller/networking.go            |  4 +++
 internal/controller/networking_test.go       | 47 ++++++++++++++++++++++++++++
 test/e2e/superset_journey_test.go            |  7 +++++
 4 files changed, 60 insertions(+)

diff --git a/docs/user-guide/networking-and-monitoring.md b/docs/user-guide/networking-and-monitoring.md
index 80f2b58..5a4ddcc 100644
--- a/docs/user-guide/networking-and-monitoring.md
+++ b/docs/user-guide/networking-and-monitoring.md
@@ -88,6 +88,8 @@ spec:
       host: superset.example.com   # no explicit paths -> all components routed by path
 ```
 
+Without `host` or `hosts`, the operator emits a single rule with no host. It matches every hostname and carries the same per-component fan-out.
+
 A host **with** explicit `paths` is treated as a user-controlled override and those paths route to the web server only — use this when you want full control of the path rules for a host:
 
 ```yaml
diff --git a/internal/controller/networking.go b/internal/controller/networking.go
index f02a7b3..408e2a2 100644
--- a/internal/controller/networking.go
+++ b/internal/controller/networking.go
@@ -382,6 +382,10 @@ func (r *SupersetReconciler) reconcileIngress(ctx context.Context, superset *sup
 				{Host: ing.Host},
 			}
 		}
+		// The apiserver rejects an Ingress with neither rules nor a default backend.
+		if len(hosts) == 0 {
+			hosts = []supersetv1alpha1.IngressHost{{}}
+		}
 
 		// Build rules from hosts. A host without explicit Paths gets the full
 		// per-component fan-out (web "/" plus /flower, /mcp, /ws for present
diff --git a/internal/controller/networking_test.go b/internal/controller/networking_test.go
index 4cd28ff..865493d 100644
--- a/internal/controller/networking_test.go
+++ b/internal/controller/networking_test.go
@@ -719,6 +719,53 @@ func TestReconcileIngress_HostFallback(t *testing.T) {
 	}
 }
 
+func TestReconcileIngress_HostlessFanout(t *testing.T) {
+	scheme := testScheme(t)
+
+	superset := &supersetv1alpha1.Superset{
+		Name: "test", Namespace: "default", UID: "uid-1",
+		Spec: supersetv1alpha1.SupersetSpec{
+			Image:     supersetv1alpha1.ImageSpec{Repository: "apache/superset", Tag: "latest"},
+			WebServer: &supersetv1alpha1.WebServerComponentSpec{},
+			McpServer: &supersetv1alpha1.McpServerComponentSpec{},
+			Lifecycle: &supersetv1alpha1.LifecycleSpec{Disabled: new(true)},
+			Networking: &supersetv1alpha1.NetworkingSpec{
+				Ingress: &supersetv1alpha1.IngressSpec{ClassName: new("alb")},
+			},
+		},
+	}
+
+	c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(superset).Build()
+	r := &SupersetReconciler{Client: c, Scheme: scheme, Recorder: events.NewFakeRecorder(10)}
+
+	if err := r.reconcileIngress(context.Background(), superset); err != nil {
+		t.Fatalf("reconcileIngress: %v", err)
+	}
+
+	ingress := &networkingv1.Ingress{}
+	if err := c.Get(context.Background(), types.NamespacedName{Name: "test", Namespace: "default"}, ingress); err != nil {
+		t.Fatalf("expected Ingress: %v", err)
+	}
+
+	if len(ingress.Spec.Rules) != 1 {
+		t.Fatalf("expected 1 hostless rule, got %d", len(ingress.Spec.Rules))
+	}
+	if ingress.Spec.Rules[0].Host != "" {
+		t.Errorf("expected empty host, got %q", ingress.Spec.Rules[0].Host)
+	}
+	got := map[string]string{}
+	for _, p := range ingress.Spec.Rules[0].HTTP.Paths {
+		got[p.Path] = p.Backend.Service.Name
+	}
+	want := map[string]string{
+		"/mcp": "test-mcp-server",
+		"/":    "test-web-server",
+	}
+	if !reflect.DeepEqual(got, want) {
+		t.Errorf("ingress paths = %+v, want %+v", got, want)
+	}
+}
+
 func TestReconcileIngress_WithTLS(t *testing.T) {
 	scheme := testScheme(t)
 
diff --git a/test/e2e/superset_journey_test.go b/test/e2e/superset_journey_test.go
index 4cc4a7a..784fd60 100644
--- a/test/e2e/superset_journey_test.go
+++ b/test/e2e/superset_journey_test.go
@@ -242,6 +242,13 @@ spec:
 			expectJSONPathContains("ingress", crName, "{.spec.rules[0].http.paths[*].path}", path)
 		}
 
+		By("dropping the Ingress host")
+		patchSuperset(crName, "json", `[{"op":"remove","path":"/spec/networking/ingress/host"}]`)
+		expectJSONPath("ingress", crName, "{.spec.rules[0].host}", "", time.Minute)
+		for _, path := range []string{"/ws", "/mcp", "/flower"} {
+			expectJSONPathContains("ingress", crName, "{.spec.rules[0].http.paths[*].path}", path)
+		}
+
 		By("switching networking from Ingress to Gateway")
 		patchSuperset(crName, "json", `[
   {"op":"remove","path":"/spec/networking/ingress"},