diff --git a/Makefile b/Makefile index 9d4c6db593..b30095aba0 100644 --- a/Makefile +++ b/Makefile @@ -294,7 +294,7 @@ test-integration: ## Run integration tests using an available cluster. .PHONY: test-e2e test-e2e: func-instrumented-bin ## Basic E2E tests (includes core, metadata and remote tests) # Runtime and other options can be configured using the FUNC_E2E_* environment variables. see e2e_test.go - go test -tags e2e -timeout 60m ./e2e -v -run "TestCore_|TestMetadata_|TestRemote_|TestLifecycle_" + go test -tags e2e -timeout 60m ./e2e -v -run "TestGateway_|TestCore_|TestMetadata_|TestRemote_|TestLifecycle_" go tool covdata textfmt -i=$${FUNC_E2E_GOCOVERDIR:-.coverage} -o coverage.txt .PHONY: test-e2e-podman diff --git a/cmd/completion_util.go b/cmd/completion_util.go index cffb092534..28e6292b73 100644 --- a/cmd/completion_util.go +++ b/cmd/completion_util.go @@ -190,3 +190,23 @@ func CompleteDeployerList(cmd *cobra.Command, args []string, complete string) (m return } + +func CompleteExposeList(cmd *cobra.Command, args []string, complete string) (matches []string, d cobra.ShellCompDirective) { + values := []string{"none", "gateway", "gateway:"} + + d = cobra.ShellCompDirectiveNoFileComp + matches = []string{} + + if len(complete) == 0 { + matches = values + return + } + + for _, v := range values { + if strings.HasPrefix(v, complete) { + matches = append(matches, v) + } + } + + return +} diff --git a/cmd/deploy.go b/cmd/deploy.go index e5a1a8af29..506cc28f83 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -131,10 +131,10 @@ EXAMPLES SuggestFor: []string{"delpoy", "deplyo"}, PreRunE: bindEnv("build", "build-timestamp", "builder", "builder-image", "base-image", "confirm", "domain", "env", "git-branch", "git-dir", - "git-url", "image", "image-pull-secret", "management-disabled", "namespace", "path", "platform", "push", "pvc-size", - "service-account", "deployer", "registry", "registry-insecure", - "registry-authfile", "remote", "username", "password", "token", "verbose", - "remote-storage-class"), + "git-url", "image", "image-pull-secret", "management-disabled", + "namespace", "path", "platform", "push", "pvc-size", "service-account", + "deployer", "expose", "registry", "registry-insecure", "registry-authfile", + "remote", "username", "password", "token", "verbose", "remote-storage-class"), RunE: func(cmd *cobra.Command, args []string) error { return runDeploy(cmd, newClient) }, @@ -199,6 +199,12 @@ EXAMPLES "Image pull secret to use when the function's image is in a private registry ($FUNC_IMAGE_PULL_SECRET)") cmd.Flags().String("deployer", f.Deploy.Deployer, fmt.Sprintf("Type of deployment to use: '%s' for Knative Service (default), '%s' for Kubernetes Deployment or '%s' for Deployment with a Keda HTTP scaler ($FUNC_DEPLOY_TYPE)", knative.KnativeDeployerName, k8s.KubernetesDeployerName, keda.KedaDeployerName)) + cmd.Flags().String("expose", f.Deploy.Expose, + "External exposure mode: 'gateway' (exposed, enforced, cluster-wide Gateway auto-discovery "+ + "- the default), 'gateway:/' (pin an exact Gateway), 'gateway:/' "+ + "(restrict discovery to a namespace), 'none' (cluster-local opt-out). Raw deployer only. "+ + "An explicitly empty value (--expose=\"\") clears the persisted deploy.expose key and "+ + "returns to the default. ($FUNC_EXPOSE)") // Static Flags: // Options which have static defaults only (not globally configurable nor // persisted with the function) @@ -239,6 +245,10 @@ EXAMPLES fmt.Println("internal: error while calling RegisterFlagCompletionFunc: ", err) } + if err := cmd.RegisterFlagCompletionFunc("expose", CompleteExposeList); err != nil { + fmt.Println("internal: error while calling RegisterFlagCompletionFunc: ", err) + } + return cmd } @@ -560,6 +570,10 @@ type deployConfig struct { // ManagementDisabled disables automatic Function CR sync after deploy. ManagementDisabled bool + + // Expose controls external access - how/if the function should be exposed + // externally, defaults to gateway (exposed). + Expose string } // newDeployConfig creates a buildConfig populated from command flags and @@ -582,6 +596,7 @@ func newDeployConfig(cmd *cobra.Command) deployConfig { ImagePullSecret: viper.GetString("image-pull-secret"), Deployer: viper.GetString("deployer"), ManagementDisabled: viper.GetBool("management-disabled"), + Expose: viper.GetString("expose"), } // NOTE: .Env should be viper.GetStringSlice, but this returns unparsed // results and appears to be an open issue since 2017: @@ -619,6 +634,8 @@ func (c deployConfig) Configure(f fn.Function) (fn.Function, error) { f.Deploy.ImagePullSecret = c.ImagePullSecret f.Deploy.Deployer = c.Deployer f.Deploy.ManagementDisabled = c.ManagementDisabled + // The effective value always lands on f; explicitly-empty flags are rejected in Validate. + f.Deploy.Expose = c.Expose f.Local.Remote = c.Remote // PVCSize @@ -738,6 +755,51 @@ func (c deployConfig) Validate(cmd *cobra.Command) (err error) { } } + // Validate expose format here (mirroring domain/namespace above) so a + // malformed value is rejected with CLI guidance before it ever reaches + // f.Write's Function.Validate, whose bundled error loses the sentinel. + if _, _, err = fn.ParseExpose(c.Expose); err != nil { + return err + } + + // deploy.expose only takes effect for the raw deployer. For knative/keda + // a set value is ignored, so warn (fired here so it precedes any build + // work on both the local and remote deploy paths) and proceed - except + // where the deployer's fixed behavior already matches the user's intent + // (keda+none: keda is cluster-local already), which stays silent. + // Effective deployer is c.Deployer as configured; empty defaults to + // knative. + if c.Expose != "" { + effectiveDeployer := c.Deployer + if effectiveDeployer == "" { + effectiveDeployer = knative.KnativeDeployerName + } + switch effectiveDeployer { + case knative.KnativeDeployerName: + if c.Expose == "none" { + // Knative exposes externally by default regardless of this + // field, so "none" leaves the user's privacy intent unmet - + // unlike the other combos below, this warrants the stronger, + // privacy-specific wording naming the actual remedy. + fmt.Fprintln(cmd.OutOrStderr(), fmt.Sprintf( + "warning: deploy.expose %q is ignored by the knative deployer - "+ + "Knative exposes this function externally by default; label the Service "+ + "networking.knative.dev/visibility=cluster-local to keep it private.", c.Expose)) + } else { + fmt.Fprintln(cmd.OutOrStderr(), fmt.Sprintf( + "warning: deploy.expose %q is ignored by the knative deployer - "+ + "Knative manages exposure itself.", c.Expose)) + } + case keda.KedaDeployerName: + if c.Expose != "none" { + fmt.Fprintln(cmd.OutOrStderr(), fmt.Sprintf( + "warning: deploy.expose %q is ignored by the keda deployer - "+ + "external exposure for keda functions is not yet supported.", c.Expose)) + } + // keda + none: silence - the user's intent is already met. + } + } + // Check Image Digest was included var digest bool if c.Image != "" { diff --git a/cmd/deploy_test.go b/cmd/deploy_test.go index 540cb4e15f..f6a96f8f5e 100644 --- a/cmd/deploy_test.go +++ b/cmd/deploy_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "path/filepath" "reflect" "strings" @@ -2547,3 +2548,214 @@ func TestDeploy_ValidDomain(t *testing.T) { func TestDeploy_RegistryInsecurePersists(t *testing.T) { testRegistryInsecurePersists(NewDeployCmd, t) } + +// TestDeploy_ExposeExplicitEmptyClears: an explicitly empty --expose="" +// clears the persisted deploy.expose key reverting to the default at deploy +// time, while a deploy without the flag leaves the persisted value untouched. +func TestDeploy_ExposeExplicitEmptyClears(t *testing.T) { + // newFn initializes a Go function in a temp directory (the cwd for the + // deploys below) and returns its root. + newFn := func(t *testing.T) string { + t.Helper() + root := FromTempDirectory(t) + if _, err := fn.New().Init(fn.Function{Runtime: "go", Root: root}); err != nil { + t.Fatal(err) + } + return root + } + + // deploy runs `func deploy` with args against mock builder/deployer, + // failing the test on error and returning the command's combined output. + deploy := func(t *testing.T, args ...string) string { + t.Helper() + cmd := NewDeployCmd(NewTestClient( + fn.WithBuilder(mock.NewBuilder()), + fn.WithDeployer(mock.NewDeployer()), + fn.WithRegistry(TestRegistry), + )) + cmd.SetArgs(args) + var out strings.Builder + cmd.SetOut(&out) + cmd.SetErr(&out) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + return out.String() + } + + // loadFn re-reads the function from disk. + loadFn := func(t *testing.T, root string) fn.Function { + t.Helper() + f, err := fn.NewFunction(root) + if err != nil { + t.Fatal(err) + } + return f + } + + t.Run(`--expose="" clears a previously-persisted "none"`, func(t *testing.T) { + root := newFn(t) + + deploy(t, "--deployer", "raw", "--expose", "none") + if f := loadFn(t, root); f.Deploy.Expose != "none" { + t.Fatalf("setup: expected expose 'none' to be persisted, got %q", f.Deploy.Expose) + } + + deploy(t, "--deployer", "raw", "--expose=") + // unmarshalled yaml would not be able to distinguish between the value + // being empty and gone (not in the file) + raw, err := os.ReadFile(filepath.Join(root, "func.yaml")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "expose") { + t.Errorf("expected NO expose key in func.yaml, got:\n%s", raw) + } + }) + + t.Run("plain deploy without the flag still works and leaves expose unpersisted", func(t *testing.T) { + root := newFn(t) + deploy(t, "--deployer", "raw") + if f := loadFn(t, root); f.Deploy.Expose != "" { + t.Errorf("expected expose to remain unpersisted (empty), got %q", f.Deploy.Expose) + } + }) + + t.Run("persisted none + no flag round-trips untouched", func(t *testing.T) { + root := newFn(t) + + deploy(t, "--deployer", "raw", "--expose", "none") + if f := loadFn(t, root); f.Deploy.Expose != "none" { + t.Fatalf("expected expose 'none' to be persisted, got %q", f.Deploy.Expose) + } + + // redeploy without changing the flag should keep it as is + deploy(t, "--deployer", "raw") + if f := loadFn(t, root); f.Deploy.Expose != "none" { + t.Errorf("expected persisted 'none' to round-trip untouched, got %q", f.Deploy.Expose) + } + }) +} + +// TestDeploy_ExposeInvalidValueError: a malformed --expose value fails the +// deploy (any deployer) with the CLI's typed ErrInvalidExpose. +func TestDeploy_ExposeInvalidValueError(t *testing.T) { + root := FromTempDirectory(t) + if _, err := fn.New().Init(fn.Function{Runtime: "go", Root: root}); err != nil { + t.Fatal(err) + } + cmd := NewDeployCmd(NewTestClient( + fn.WithBuilder(mock.NewBuilder()), + fn.WithDeployer(mock.NewDeployer()), + fn.WithRegistry(TestRegistry), + )) + cmd.SetArgs([]string{"--expose", "bogus"}) + var want *ErrInvalidExpose + if err := cmd.Execute(); !errors.As(err, &want) { + t.Errorf("expected ErrInvalidExpose, got %v", err) + } +} + +// TestDeploy_ExposeGatewayRefPersists ensures the union "gateway:/" +// form round-trips through --expose into f.Deploy.Expose end-to-end. +func TestDeploy_ExposeGatewayRefPersists(t *testing.T) { + root := FromTempDirectory(t) + + if _, err := fn.New().Init(fn.Function{Runtime: "go", Root: root}); err != nil { + t.Fatal(err) + } + + cmd := NewDeployCmd(NewTestClient( + fn.WithBuilder(mock.NewBuilder()), + fn.WithDeployer(mock.NewDeployer()), + fn.WithRegistry(TestRegistry), + )) + cmd.SetArgs([]string{"--deployer", "raw", "--expose=gateway:infra/gw"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + + f, err := fn.NewFunction(root) + if err != nil { + t.Fatal(err) + } + if f.Deploy.Expose != "gateway:infra/gw" { + t.Fatalf("expected expose 'gateway:infra/gw' to be persisted, got %q", f.Deploy.Expose) + } +} + +// TestDeploy_ExposeIgnoredByDeployerNote: a deployer that ignores a set +// deploy.expose warns and proceeds +func TestDeploy_ExposeIgnoredByDeployerNote(t *testing.T) { + tests := []struct { + name string + args []string + wantWarning string // distinguishing substring of the warning; "" means silent + }{ + { + name: "raw+gateway: silent", + args: []string{"--deployer", "raw", "--expose", "gateway"}, + }, + { + name: "knative+empty: silent", + args: []string{"--deployer", "knative"}, + }, + { + name: "knative+gateway: warns, proceeds", + args: []string{"--deployer", "knative", "--expose", "gateway"}, + wantWarning: `deploy.expose "gateway" is ignored by the knative deployer`, + }, + { + name: "knative+none: privacy-specific warning, proceeds", + args: []string{"--deployer", "knative", "--expose", "none"}, + wantWarning: `networking.knative.dev/visibility=cluster-local`, + }, + { + name: "keda+gateway: warns, proceeds", + args: []string{"--deployer", "keda", "--expose", "gateway"}, + wantWarning: `deploy.expose "gateway" is ignored by the keda deployer`, + }, + { + name: "keda+none: silent (intent already met, keda is cluster-local)", + args: []string{"--deployer", "keda", "--expose", "none"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := FromTempDirectory(t) + if _, err := fn.New().Init(fn.Function{Runtime: "go", Root: root}); err != nil { + t.Fatal(err) + } + + builder := mock.NewBuilder() + cmd := NewDeployCmd(NewTestClient( + fn.WithBuilder(builder), + fn.WithDeployer(mock.NewDeployer()), + fn.WithRegistry(TestRegistry), + )) + cmd.SetArgs(tt.args) + var stderr strings.Builder + cmd.SetOut(&stderr) + cmd.SetErr(&stderr) + err := cmd.Execute() + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !builder.BuildInvoked { + t.Error("expected the deploy to proceed to build") + } + + if tt.wantWarning == "" { + if strings.Contains(stderr.String(), "deploy.expose") { + t.Errorf("expected no warning on stderr, got:\n%s", stderr.String()) + } + return + } + if !strings.Contains(stderr.String(), tt.wantWarning) { + t.Errorf("expected stderr to contain:\n%s\ngot:\n%s", tt.wantWarning, stderr.String()) + } + }) + } +} diff --git a/cmd/errors.go b/cmd/errors.go index e820df1d96..2f9f6b47fa 100644 --- a/cmd/errors.go +++ b/cmd/errors.go @@ -31,6 +31,9 @@ Internal error during error-wrapping: specified cmd '%s' not supported`, cmd) if errors.Is(err, fn.ErrPlatformNotSupported) { return NewErrPlatformNotSupported(err, cmd) } + if errors.Is(err, fn.ErrInvalidExpose) { + return NewErrInvalidExpose(err, cmd) + } return err } @@ -217,6 +220,40 @@ func (e *ErrInvalidDomain) Unwrap() error { // -------------------------------------------------------------------------- // +type ErrInvalidExpose struct { + Err error + Cmd string +} + +func NewErrInvalidExpose(err error, cmd string) error { + return &ErrInvalidExpose{Err: err, Cmd: cmd} +} + +func (e *ErrInvalidExpose) Error() string { + switch e.Cmd { + case "deploy": + return fmt.Sprintf(`%v + +Try this: + func deploy --expose=gateway Exposed, enforced, cluster-wide Gateway auto-discovery (default) + func deploy --expose=gateway:/ Pin an exact Gateway + func deploy --expose=gateway:/ Restrict discovery to a namespace + func deploy --expose=none Cluster-local opt-out, no external exposure + +deploy.expose takes effect with the raw deployer only (--deployer=raw). +For more options, run 'func deploy --help'`, e.Err) + + default: + return e.Err.Error() + } +} + +func (e *ErrInvalidExpose) Unwrap() error { + return e.Err +} + +// -------------------------------------------------------------------------- // + type ErrInvalidKubeconfig struct { Err error } diff --git a/docs/reference/func_deploy.md b/docs/reference/func_deploy.md index 81880ce1c4..119aaf4655 100644 --- a/docs/reference/func_deploy.md +++ b/docs/reference/func_deploy.md @@ -122,6 +122,7 @@ func deploy --deployer string Type of deployment to use: 'knative' for Knative Service (default), 'raw' for Kubernetes Deployment or 'keda' for Deployment with a Keda HTTP scaler ($FUNC_DEPLOY_TYPE) --domain string Domain to use for the function's route. Cluster must be configured with domain matching for the given domain (ignored if unrecognized) ($FUNC_DOMAIN) -e, --env stringArray Environment variable to set in the form NAME=VALUE. You may provide this flag multiple times for setting multiple environment variables. To unset, specify the environment variable name followed by a "-" (e.g., NAME-). + --expose string External exposure mode: 'gateway' (exposed, enforced, cluster-wide Gateway auto-discovery - the default), 'gateway:/' (pin an exact Gateway), 'gateway:/' (restrict discovery to a namespace), 'none' (cluster-local opt-out). Raw deployer only. An explicitly empty value (--expose="") clears the persisted deploy.expose key and returns to the default. ($FUNC_EXPOSE) -t, --git-branch string Git revision (branch) to be used when deploying via the Git repository ($FUNC_GIT_BRANCH) -d, --git-dir string Directory in the Git repository containing the function (default is the root) ($FUNC_GIT_DIR) -g, --git-url string Repository url containing the function to build ($FUNC_GIT_URL) diff --git a/e2e/e2e_gateway_test.go b/e2e/e2e_gateway_test.go new file mode 100644 index 0000000000..a1d515013a --- /dev/null +++ b/e2e/e2e_gateway_test.go @@ -0,0 +1,289 @@ +//go:build e2e + +package e2e + +import ( + "fmt" + "os/exec" + "strings" + "testing" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + gwclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" + + "knative.dev/func/pkg/k8s" +) + +// TestGateway_ExposureHappyPath exercises the raw deployer's default Gateway +// API exposure end to end: deploy, assert the HTTPRoute reaches +// Accepted=True, reach the function THROUGH the gateway from inside the +// cluster, then delete and confirm the HTTPRoute is garbage-collected via +// its Deployment ownerRef. +// +// The Gateway's address and the minted hostname are both read back from the +// live cluster/CLI rather than assumed: the address comes from the +// Gateway's own status.addresses after Programmed=True, and the hostname +// comes from func's own 'describe --output url', which reflects exactly +// what ResolveHostname minted from that address (see pkg/k8s/httproute.go). +// This keeps the test valid across clusters with different address-pool +// ranges, and proves the exact URL func told the user actually works - an +// in-cluster curl is the only reachability check trusted here, since on +// kind/WSL a host-to-LoadBalancer-IP connection is known-intermittent and a +// host-side failure is not a feature defect. +func TestGateway_ExposureHappyPath(t *testing.T) { + ctx := t.Context() + + // fromCleanEnv (via setupEnv) sets KUBECONFIG to the E2E test cluster - + // must run before any client is constructed, or GetClientConfig() below + // falls back to the default kubeconfig (a different cluster entirely). + functionName := "func-e2e-test-gateway" + fromCleanEnv(t, functionName) + + restConfig, err := k8s.GetClientConfig().ClientConfig() + if err != nil { + t.Fatal(err) + } + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + t.Fatal(err) + } + gwClient, err := gwclientset.NewForConfig(restConfig) + if err != nil { + t.Fatal(err) + } + + available, err := k8s.GatewayAPIAvailable(clientset) + if err != nil { + t.Fatal(err) + } + if !available { + t.Skip("Gateway API is not installed on this cluster; clusters built by " + + "'func cluster create' or hack/cluster.sh install it as part of networking - rebuild with either to run this test") + } + + if err := newCmd(t, "init", "-l=go", "-t=http").Run(); err != nil { + t.Fatal(err) + } + + // Default expose is "gateway" (cluster-wide auto-discovery); deploy with + // the raw deployer so the exposure reconciliation path is exercised + // (see pkg/k8s/deployer.go reconcileExposure/ensureExposure). + if err := newCmd(t, "deploy", "--deployer", "raw").Run(); err != nil { + t.Fatal(err) + } + defer clean(t, functionName, Namespace) + + waitForDeployment(t, Namespace, functionName) + + gwName, gwNamespace := resolveFunctionGateway(t, gwClient, functionName, Namespace) + t.Logf("HTTPRoute is attached to Gateway %s/%s", gwNamespace, gwName) + + if err := k8s.WaitForRouteAccepted(ctx, gwClient, Namespace, functionName, gwName, gwNamespace, 60*time.Second); err != nil { + t.Fatalf("HTTPRoute was not accepted: %v", err) + } + t.Log("HTTPRoute Accepted=True") + + gwAddress := gatewayAddress(t, gwClient, gwNamespace, gwName) + hostname := mintedHostname(t, functionName) + t.Logf("Gateway address %s, minted hostname %s", gwAddress, hostname) + + curlThroughGateway(t, gwAddress, hostname) + + if err := newCmd(t, "delete", functionName, "--namespace", Namespace).Run(); err != nil { + t.Fatal(err) + } + + waitForHTTPRouteGone(t, gwClient, Namespace, functionName) +} + +// TestGateway_ExposureCustomDomain exercises the raw deployer's --domain +// flow: ResolveHostname's f.Domain branch mints .. +// instead of an sslip.io hostname off the Gateway's IP (see +// pkg/k8s/httproute.go ResolveHostname). Reached with a plain HOST-side +// curl, not an in-cluster pod - deterministic here, unlike the sslip/ +// LoadBalancer-IP path TestGateway_ExposureHappyPath curls in-cluster only: +// localtest.me is a public wildcard DNS name resolving to 127.0.0.1, and +// kind's host port mapping (80 -> the shared envoy Service) is a fixed +// forward, not a per-cluster LoadBalancer IP that can flake on kind/WSL - +// the same stable path the Knative e2e tests already host-curl through. +func TestGateway_ExposureCustomDomain(t *testing.T) { + ctx := t.Context() + + functionName := "func-e2e-test-gateway-domain" + fromCleanEnv(t, functionName) + + restConfig, err := k8s.GetClientConfig().ClientConfig() + if err != nil { + t.Fatal(err) + } + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + t.Fatal(err) + } + gwClient, err := gwclientset.NewForConfig(restConfig) + if err != nil { + t.Fatal(err) + } + + available, err := k8s.GatewayAPIAvailable(clientset) + if err != nil { + t.Fatal(err) + } + if !available { + t.Skip("Gateway API is not installed on this cluster; clusters built by " + + "'func cluster create' or hack/cluster.sh install it as part of networking - rebuild with either to run this test") + } + + if err := newCmd(t, "init", "-l=go", "-t=http").Run(); err != nil { + t.Fatal(err) + } + + if err := newCmd(t, "deploy", "--deployer", "raw", "--domain", "localtest.me").Run(); err != nil { + t.Fatal(err) + } + defer clean(t, functionName, Namespace) + + waitForDeployment(t, Namespace, functionName) + + wantHostname := fmt.Sprintf("%s.%s.localtest.me", functionName, Namespace) + if gotHostname := mintedHostname(t, functionName); gotHostname != wantHostname { + t.Fatalf("minted hostname = %q, want %q", gotHostname, wantHostname) + } + + gwName, gwNamespace := resolveFunctionGateway(t, gwClient, functionName, Namespace) + if err := k8s.WaitForRouteAccepted(ctx, gwClient, Namespace, functionName, gwName, gwNamespace, 60*time.Second); err != nil { + t.Fatalf("HTTPRoute was not accepted: %v", err) + } + + if !waitFor(t, "http://"+wantHostname) { + t.Fatalf("host-side curl to %s never succeeded", wantHostname) + } + t.Logf("host-side curl to %s succeeded", wantHostname) + + if err := newCmd(t, "delete", functionName, "--namespace", Namespace).Run(); err != nil { + t.Fatal(err) + } + + waitForHTTPRouteGone(t, gwClient, Namespace, functionName) +} + +// resolveFunctionGateway reads the Gateway that the function's own +// HTTPRoute targets (spec.ParentRefs, set by func at generation time - see +// GenerateHTTPRoute) rather than assuming a name, so this works regardless +// of how many Gateways exist on the cluster. +func resolveFunctionGateway(t *testing.T, gwClient gwclientset.Interface, functionName, namespace string) (gwName, gwNamespace string) { + t.Helper() + + route, err := gwClient.GatewayV1().HTTPRoutes(namespace).Get(t.Context(), functionName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get HTTPRoute %q: %v", functionName, err) + } + if len(route.Spec.ParentRefs) == 0 { + t.Fatalf("HTTPRoute %q has no parentRefs", functionName) + } + + ref := route.Spec.ParentRefs[0] + gwName = string(ref.Name) + gwNamespace = namespace + if ref.Namespace != nil && *ref.Namespace != "" { + gwNamespace = string(*ref.Namespace) + } + return gwName, gwNamespace +} + +// gatewayAddress reads back the Gateway's own status.addresses - the +// address MetalLB actually handed out, whatever pool/range that came from. +func gatewayAddress(t *testing.T, gwClient gwclientset.Interface, namespace, name string) string { + t.Helper() + + gw, err := gwClient.GatewayV1().Gateways(namespace).Get(t.Context(), name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("failed to get Gateway %s/%s: %v", namespace, name, err) + } + if len(gw.Status.Addresses) == 0 { + t.Fatalf("Gateway %s/%s has no status.addresses", namespace, name) + } + return gw.Status.Addresses[0].Value +} + +// mintedHostname reads the hostname func itself minted for the function, via +// 'func describe --output url' (which reflects the RouteHostnameAnnotation +// written at exposure time - see pkg/k8s/describer.go). Reading it back +// rather than recomputing it independently proves the exact URL func told +// the user is the one that works. +func mintedHostname(t *testing.T, functionName string) string { + t.Helper() + + cmd := exec.Command(Bin, "describe", functionName, "--namespace", Namespace, "--output", "url") + out, err := cmd.Output() + if err != nil { + t.Fatalf("func describe --output url failed: %v", err) + } + + url := strings.TrimSpace(string(out)) + hostname := strings.TrimPrefix(strings.TrimPrefix(url, "http://"), "https://") + if hostname == "" || hostname == url { + t.Fatalf("could not parse a hostname from describe output: %q", url) + } + return hostname +} + +// curlThroughGateway is the authoritative reachability check: it runs a +// one-shot curl Pod inside the cluster against the Gateway's address, +// setting the Host header to the hostname func minted. A host-side +// (kind/WSL) connection to the Gateway's LoadBalancer IP is known +// intermittent and is never asserted here - only the in-cluster path is +// trusted. +func curlThroughGateway(t *testing.T, gatewayAddress, hostname string) { + t.Helper() + + deadline := time.Now().Add(2 * time.Minute) + var lastErr error + var lastOutput []byte + + for attempt := 0; time.Now().Before(deadline); attempt++ { + podName := fmt.Sprintf("func-e2e-gateway-curl-%d", attempt) + cmd := exec.Command("kubectl", "run", podName, + "--namespace", Namespace, + "--image=curlimages/curl", + "--restart=Never", + "--rm", "-i", + "--command", "--", + "curl", "-sS", "-f", "--max-time", "10", + "-H", "Host: "+hostname, + fmt.Sprintf("http://%s/", gatewayAddress)) + + out, err := cmd.CombinedOutput() + if err == nil { + t.Logf("curl through gateway succeeded: %s", strings.TrimSpace(string(out))) + return + } + lastErr, lastOutput = err, out + time.Sleep(5 * time.Second) + } + t.Fatalf("curl through gateway %s (Host: %s) never succeeded: %v\noutput: %s", + gatewayAddress, hostname, lastErr, lastOutput) +} + +// waitForHTTPRouteGone polls until the function's HTTPRoute is deleted, +// confirming that Kubernetes garbage-collected it via its Deployment +// ownerRef (func's raw deployer delete path never touches the HTTPRoute +// directly - see pkg/k8s/remover.go). GC is asynchronous, hence the +// generous poll rather than an immediate assertion. +func waitForHTTPRouteGone(t *testing.T, gwClient gwclientset.Interface, namespace, name string) { + t.Helper() + + deadline := time.Now().Add(2 * time.Minute) + for time.Now().Before(deadline) { + _, err := gwClient.GatewayV1().HTTPRoutes(namespace).Get(t.Context(), name, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + t.Log("HTTPRoute was garbage-collected") + return + } + time.Sleep(5 * time.Second) + } + t.Fatalf("HTTPRoute %s/%s was not garbage-collected within timeout", namespace, name) +} diff --git a/e2e/e2e_metadata_test.go b/e2e/e2e_metadata_test.go index e05585ec4a..ad9e0f82ef 100644 --- a/e2e/e2e_metadata_test.go +++ b/e2e/e2e_metadata_test.go @@ -680,7 +680,7 @@ func TestMetadata_Subscriptions_Raw(t *testing.T) { } // Deploy with raw deployer to test trigger creation - if err := newCmd(t, "deploy", "--deployer", "raw").Run(); err != nil { + if err := newCmd(t, "deploy", "--deployer", "raw", "--expose", "none").Run(); err != nil { t.Fatal(err) } defer clean(t, subscriberName, Namespace) diff --git a/e2e/e2e_trigger_sync_test.go b/e2e/e2e_trigger_sync_test.go index 72953b0d1c..e2a860f786 100644 --- a/e2e/e2e_trigger_sync_test.go +++ b/e2e/e2e_trigger_sync_test.go @@ -50,7 +50,7 @@ func TestMetadata_TriggerSync(t *testing.T) { if err := f.Write(); err != nil { t.Fatal(err) } - if err := newCmd(t, "deploy", "--deployer", "raw").Run(); err != nil { + if err := newCmd(t, "deploy", "--deployer", "raw", "--expose", "none").Run(); err != nil { t.Fatal(err) } } @@ -109,7 +109,7 @@ func TestMetadata_TriggerSync(t *testing.T) { t.Logf("Created manual trigger: %s", manualTriggerName) // Redeploy (no changes to subscriptions) - if err := newCmd(t, "deploy", "--deployer", "raw").Run(); err != nil { + if err := newCmd(t, "deploy", "--deployer", "raw", "--expose", "none").Run(); err != nil { t.Fatal(err) } time.Sleep(5 * time.Second) @@ -168,10 +168,7 @@ func TestMetadata_TriggerSync(t *testing.T) { t.Logf("Initial triggers: %v", initialTriggers) // One redeploy is sufficient to exercise the create-then-no-op - // (AlreadyExists-tolerated) cluster path; trigger-name determinism is - // already exhaustively unit-tested (pkg/k8s/deployer_test.go:157-333), - // so the previous ×3 loop is reduced to ×1. - if err := newCmd(t, "deploy", "--deployer", "raw").Run(); err != nil { + if err := newCmd(t, "deploy", "--deployer", "raw", "--expose", "none").Run(); err != nil { t.Fatal(err) } time.Sleep(3 * time.Second) diff --git a/go.mod b/go.mod index 136887cd55..ea96d7fe2e 100644 --- a/go.mod +++ b/go.mod @@ -76,6 +76,7 @@ require ( knative.dev/pkg v0.0.0-20260622140654-39ebae2ee2dc knative.dev/serving v0.49.1-0.20260624144117-dbce551f802c sigs.k8s.io/controller-runtime v0.23.3 + sigs.k8s.io/gateway-api v1.5.1 ) require ( @@ -319,7 +320,6 @@ require ( k8s.io/cli-runtime v0.34.1 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect knative.dev/networking v0.0.0-20260616021346-d4e13b76b133 // indirect - sigs.k8s.io/gateway-api v1.4.1 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/kustomize/api v0.21.0 // indirect sigs.k8s.io/kustomize/kyaml v0.21.0 // indirect diff --git a/go.sum b/go.sum index 69240a0923..c346a55bb5 100644 --- a/go.sum +++ b/go.sum @@ -1896,8 +1896,8 @@ sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.1.2/go.mod h1:+qG7ISX sigs.k8s.io/controller-runtime v0.15.3/go.mod h1:kp4jckA4vTx281S/0Yk2LFEEQe67mjg+ev/yknv47Ds= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= -sigs.k8s.io/gateway-api v1.4.1 h1:NPxFutNkKNa8UfLd2CMlEuhIPMQgDQ6DXNKG9sHbJU8= -sigs.k8s.io/gateway-api v1.4.1/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk= +sigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0= +sigs.k8s.io/gateway-api v1.5.1/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= diff --git a/hack/cluster.sh b/hack/cluster.sh index d4f01926b4..facb13c7c7 100755 --- a/hack/cluster.sh +++ b/hack/cluster.sh @@ -255,16 +255,91 @@ networking() { echo "${blue}Installing Ingress Controller (Contour)${reset}" echo "Version: ${contour_version}" + echo "Installing Gateway API CRDs." + # experimental-install.yaml, not standard: Contour's gatewayRef mode + # crashes at startup without it - it unconditionally informers on + # TLSRoute (gateway.networking.k8s.io/v1alpha2), which only the + # experimental channel provides, even though our own code only uses the + # four standard CRDs waited on below. + $KUBECTL apply --server-side -f "https://github.com/kubernetes-sigs/gateway-api/releases/download/${gateway_api_version}/experimental-install.yaml" + # Waited on by name (the four CRDs the raw deployer's exposure path + # actually uses - see pkg/k8s/httproute.go) rather than "--all crd", so a + # failure names exactly which one didn't Establish. + $KUBECTL wait --for=condition=Established --timeout=5m \ + crd/gatewayclasses.gateway.networking.k8s.io \ + crd/gateways.gateway.networking.k8s.io \ + crd/httproutes.gateway.networking.k8s.io \ + crd/referencegrants.gateway.networking.k8s.io + + echo "Granting Gateway API access to the admin ClusterRole." + # Gateway API ships no ClusterRole and no aggregation labels of its own, + # so the built-in "admin" ClusterRole has zero gateway.networking.k8s.io + # rules - this is a property of the Gateway API install, not of Tekton + # (see tekton(), which also binds this role directly to the pipeline + # ServiceAccount as belt-and-suspenders). + $KUBECTL apply -f - <" kubectl resource args. +func crdArgs() []string { + args := make([]string, len(gatewayAPICRDs)) + for i, name := range gatewayAPICRDs { + args[i] = "crd/" + name + } + return args +} + // installNetworking installs Contour ingress controller and configures Knative // to use it. The Contour YAML is modified in Go (replacing yq) to add IPv6 -// dual-stack support args. +// dual-stack support args and to point Contour at a shared Gateway (below), +// so the raw deployer's Gateway API exposure path has something to attach +// HTTPRoutes to on every cluster with Serving installed. func installNetworking(ctx context.Context, cfg ClusterConfig, out io.Writer) error { start := time.Now() status(out, "Installing Ingress Controller (Contour)") fmt.Fprintf(out, "Version: %s\n", contourVersion) + fmt.Fprintln(out, "Installing Gateway API CRDs.") + // experimental-install.yaml, not standard: Contour's gatewayRef mode + // crashes at startup without it - it unconditionally informers on + // TLSRoute (gateway.networking.k8s.io/v1alpha2), which only the + // experimental channel provides, even though our own code only uses + // the four standard CRDs (gatewayAPICRDs below). + crdsURL := fmt.Sprintf("https://github.com/kubernetes-sigs/gateway-api/releases/download/%s/experimental-install.yaml", gatewayAPIVersion) + if err := run(ctx, out, "", cfg.kubectl(), "apply", "--server-side", "-f", crdsURL); err != nil { + return fmt.Errorf("applying gateway API CRDs: %w", err) + } + waitArgs := append([]string{"wait", "--for=condition=Established", "--timeout=5m"}, crdArgs()...) + if err := run(ctx, out, "", cfg.kubectl(), waitArgs...); err != nil { + return fmt.Errorf("waiting for gateway API CRDs (%s) to Establish: %w", strings.Join(gatewayAPICRDs, ", "), err) + } + + fmt.Fprintln(out, "Granting Gateway API access to the admin ClusterRole.") + // Gateway API ships no ClusterRole and no aggregation labels of its own, + // so the built-in "admin" ClusterRole has zero gateway.networking.k8s.io + // rules - this is a property of the Gateway API install, not of Tekton + // (see installTekton, which also binds this role directly to the + // pipeline ServiceAccount as belt-and-suspenders). + gatewayAPIUserRole := fmt.Sprintf(`apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: %s + labels: + rbac.authorization.k8s.io/aggregate-to-admin: "true" +rules: +- apiGroups: ["gateway.networking.k8s.io"] + resources: ["gateways"] + verbs: ["get", "list", "watch"] +- apiGroups: ["gateway.networking.k8s.io"] + resources: ["httproutes"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +`, gatewayAPIUserClusterRole) + if err := applyManifest(ctx, out, cfg, gatewayAPIUserRole); err != nil { + return fmt.Errorf("applying %s ClusterRole: %w", gatewayAPIUserClusterRole, err) + } + fmt.Fprintln(out, "Installing a configured Contour.") contourURL := fmt.Sprintf("https://github.com/knative/net-contour/releases/download/knative-%s/contour.yaml", contourVersion) contourYAML, err := httpGet(ctx, contourURL) @@ -106,6 +166,55 @@ func installNetworking(ctx context.Context, cfg ClusterConfig, out io.Writer) er return fmt.Errorf("waiting for contour pods: %w", err) } + fmt.Fprintln(out, "Creating GatewayClass and shared Gateway.") + gatewayClass := `apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: contour +spec: + controllerName: projectcontour.io/contour +` + if err := applyManifest(ctx, out, cfg, gatewayClass); err != nil { + return fmt.Errorf("applying GatewayClass: %w", err) + } + gateway := fmt.Sprintf(`apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: %s + namespace: %s +spec: + gatewayClassName: contour + listeners: + - name: http + port: 80 + protocol: HTTP + allowedRoutes: + namespaces: + from: All +`, gatewayName, gatewayNamespace) + if err := applyManifest(ctx, out, cfg, gateway); err != nil { + return fmt.Errorf("applying Gateway: %w", err) + } + // No GatewayClass wait: Contour's static gatewayRef mode serves the one + // referenced Gateway directly and never reconciles GatewayClass status + // (verified live - the Gateway itself reaches Programmed=True with the + // waits below while GatewayClass.status.conditions stays "Pending: + // Waiting for controller" indefinitely). Two waits, not three. + if err := run(ctx, out, "", + cfg.kubectl(), "wait", fmt.Sprintf("gateway/%s", gatewayName), + "--for=condition=Programmed", "--namespace", gatewayNamespace, "--timeout=5m"); err != nil { + return fmt.Errorf("waiting for Gateway %s/%s to become Programmed: %w", gatewayNamespace, gatewayName, err) + } + // Programmed=True should imply an address was assigned, but consumers + // (e.g. the raw deployer's hostname minting) read status.addresses[0] + // directly - verify it explicitly rather than assume the implication + // holds. + if err := run(ctx, out, "", + cfg.kubectl(), "wait", fmt.Sprintf("gateway/%s", gatewayName), + "--for=jsonpath={.status.addresses[0].value}", "--namespace", gatewayNamespace, "--timeout=30s"); err != nil { + return fmt.Errorf("waiting for Gateway %s/%s to report a status.addresses: %w", gatewayNamespace, gatewayName, err) + } + fmt.Fprintln(out, "Installing the Knative Contour controller.") netContourURL := fmt.Sprintf("https://github.com/knative/net-contour/releases/download/knative-%s/net-contour.yaml", contourVersion) if err := run(ctx, out, "", cfg.kubectl(), "apply", "-f", netContourURL); err != nil { @@ -166,11 +275,13 @@ func installNetworking(ctx context.Context, cfg ClusterConfig, out io.Writer) er } // addContourIPv6Args modifies the Contour deployment YAML to add -// --envoy-service-http-address=:: and --envoy-service-https-address=:: args. -// This replaces the yq pipeline from the shell script. The input is split -// on the multi-document separator and each non-empty chunk is decoded -// individually, which avoids the k8s YAML decoder's quirk of returning a -// string-compared "Object 'Kind' is missing" error for empty documents. +// --envoy-service-http-address=:: and --envoy-service-https-address=:: args, +// and patches the Contour ConfigMap to point it at the shared Gateway via +// gatewayRef. This replaces the yq pipeline from the shell script. The +// input is split on the multi-document separator and each non-empty chunk +// is decoded individually, which avoids the k8s YAML decoder's quirk of +// returning a string-compared "Object 'Kind' is missing" error for empty +// documents. func addContourIPv6Args(yamlContent string) (string, error) { var docs []unstructured.Unstructured for _, chunk := range splitYAMLDocs(yamlContent) { @@ -197,6 +308,14 @@ func addContourIPv6Args(yamlContent string) (string, error) { } } + if obj.GetKind() == "ConfigMap" && obj.GetName() == "contour" && obj.GetNamespace() == gatewayNamespace { + data, _, _ := unstructured.NestedStringMap(obj.Object, "data") + if data != nil { + data["contour.yaml"] += fmt.Sprintf("\ngateway:\n gatewayRef:\n namespace: %s\n name: %s\n", gatewayNamespace, gatewayName) + _ = unstructured.SetNestedStringMap(obj.Object, data, "data") + } + } + docs = append(docs, obj) } diff --git a/pkg/cluster/tekton.go b/pkg/cluster/tekton.go index 7d1d760448..3a4b712bf1 100644 --- a/pkg/cluster/tekton.go +++ b/pkg/cluster/tekton.go @@ -40,6 +40,10 @@ func installTekton(ctx context.Context, cfg ClusterConfig, out io.Writer) error {namespace + ":knative-serving-namespaced-admin", "knative-serving-namespaced-admin"}, {namespace + ":admin", "admin"}, {namespace + ":keda-add-ons-http-operator", "keda-add-ons-http-operator"}, + // Gateway API resources needed by raw deployer exposure - belt-and- + // suspenders alongside the admin binding above, which already + // absorbs these rules via aggregation (see installNetworking). + {namespace + ":" + gatewayAPIUserClusterRole, gatewayAPIUserClusterRole}, } for _, rb := range rbacBindings { manifest := fmt.Sprintf(`apiVersion: rbac.authorization.k8s.io/v1 diff --git a/pkg/cluster/versions.go b/pkg/cluster/versions.go index 49350ac9a0..761c9f3b66 100644 --- a/pkg/cluster/versions.go +++ b/pkg/cluster/versions.go @@ -13,6 +13,32 @@ const ( kedaVersion = "v2.17.0" kedaHTTPAddOnVersion = "v0.12.0" metalLBVersion = "v0.13.7" + + // gatewayAPIVersion pins the Gateway API CRD bundle, applied from the + // EXPERIMENTAL channel (see installNetworking) because Contour's + // gatewayRef mode requires TLSRoute at startup even though the raw + // deployer's exposure path only uses the four standard CRDs + // (gatewayAPICRDs). v1.4.1 speaks v1 GA, compatible with func's + // gateway-api v1.5.1 Go client (the CRD manifest version and the Go + // client module version are independent). + gatewayAPIVersion = "v1.4.1" + + // gatewayName and gatewayNamespace are the single shared Gateway that + // the existing net-contour Contour deployment is pointed at (see + // installNetworking's gatewayRef patch) - the raw deployer's + // cluster-wide Gateway auto-discovery finds it like any other Gateway. + gatewayName = "contour-gateway" + gatewayNamespace = "contour-external" + + // gatewayAPIUserClusterRole grants the Gateway API rules the raw + // deployer's exposure path actually calls (see pkg/k8s/httproute.go). + // Gateway API ships no ClusterRole of its own, so the built-in "admin" + // role Tekton's pipeline ServiceAccount is bound to (see installTekton) + // has zero gateway.networking.k8s.io rules on a fresh cluster - this is + // aggregated into "admin" via its label (see installNetworking) AND + // bound directly to that ServiceAccount (see installTekton) as + // belt-and-suspenders, matching the existing per-dependency bindings. + gatewayAPIUserClusterRole = "func-gateway-api-user" ) // Tool versions — only tools we download and manage. diff --git a/pkg/deployer/testing/integration_test_helper.go b/pkg/deployer/testing/integration_test_helper.go index 379cad6a73..34dd40e42c 100644 --- a/pkg/deployer/testing/integration_test_helper.go +++ b/pkg/deployer/testing/integration_test_helper.go @@ -66,6 +66,9 @@ func TestInt_Deploy(t *testing.T, deployer fn.Deployer, remover fn.Remover, desc if err != nil { t.Fatal(err) } + // expose:none on a Gateway-less test cluster - not necessary + f.Deploy.Deployer = deployerName + f.Deploy.Expose = exposeNoneForRawOrKeda(deployerName) // Not really necessary, but it allows us to reuse the "invoke" method: handlerPath := filepath.Join(root, "function.go") if err := os.WriteFile(handlerPath, []byte(testHandler), 0644); err != nil { @@ -169,6 +172,9 @@ func TestInt_Metadata(t *testing.T, deployer fn.Deployer, remover fn.Remover, de if err != nil { t.Fatal(err) } + // Explicit, not blank - see TestInt_Deploy for why. + f.Deploy.Deployer = deployerName + f.Deploy.Expose = exposeNoneForRawOrKeda(deployerName) handlerPath := filepath.Join(root, "function.go") if err := os.WriteFile(handlerPath, []byte(testHandler), 0644); err != nil { @@ -324,6 +330,9 @@ func TestInt_Events(t *testing.T, deployer fn.Deployer, remover fn.Remover, desc if err != nil { t.Fatal(err) } + // Explicit, not blank - see TestInt_Deploy for why. + f.Deploy.Deployer = deployerName + f.Deploy.Expose = exposeNoneForRawOrKeda(deployerName) // Trigger // ------- @@ -407,6 +416,9 @@ func TestInt_Scale(t *testing.T, deployer fn.Deployer, remover fn.Remover, descr if err != nil { t.Fatal(err) } + // Explicit, not blank - see TestInt_Deploy for why. + f.Deploy.Deployer = deployerName + f.Deploy.Expose = exposeNoneForRawOrKeda(deployerName) // Note: There is no reason for all these being pointers: minScale := int64(2) maxScale := int64(100) @@ -522,6 +534,9 @@ func TestInt_EnvsUpdate(t *testing.T, deployer fn.Deployer, remover fn.Remover, if err != nil { t.Fatal(err) } + // Explicit, not blank - see TestInt_Deploy for why. + f.Deploy.Deployer = deployerName + f.Deploy.Expose = exposeNoneForRawOrKeda(deployerName) // Write custom test handler handlerPath := filepath.Join(root, "function.go") @@ -731,11 +746,14 @@ func TestInt_FullPath(t *testing.T, deployer fn.Deployer, remover fn.Remover, li // * application also prints the same info to stderr on startup Created: now, Deploy: fn.DeploySpec{ - // TODO: gauron99 - is it okay to have this explicitly set to deploy.image already? - // With this I skip the logic of setting the .Deploy.Image field but it should be fine for this test + // pinned prebuilt image: these tests exercise deployment, not the + // build/image-resolution flow Image: "quay.io/mvasek/func-test-service@sha256:2eca4de00d7569c8791634bdbb0c4d5ec8fb061b001549314591e839dabd5269", Namespace: namespace, - Labels: []fn.Label{{Key: ptr("my-label"), Value: ptr("my-label-value")}}, + // Explicit, not blank - see TestInt_Deploy for why. + Deployer: deployerName, + Expose: exposeNoneForRawOrKeda(deployerName), + Labels: []fn.Label{{Key: ptr("my-label"), Value: ptr("my-label-value")}}, Options: fn.Options{ Scale: &fn.ScaleOptions{ Min: &minScale, @@ -931,6 +949,9 @@ func TestInt_ResourceValidationOnFirstDeploy(t *testing.T, deployer fn.Deployer, if err != nil { t.Fatal(err) } + // Explicit, not blank - see TestInt_Deploy for why. + f.Deploy.Deployer = deployerName + f.Deploy.Expose = exposeNoneForRawOrKeda(deployerName) handlerPath := filepath.Join(root, "function.go") if err := os.WriteFile(handlerPath, []byte(testHandler), 0644); err != nil { @@ -1237,6 +1258,9 @@ func TestInt_OperatorSync(t *testing.T, deployer fn.Deployer, remover fn.Remover if err != nil { t.Fatal(err) } + // Explicit, not blank - see TestInt_Deploy for why. + f.Deploy.Deployer = deployerName + f.Deploy.Expose = exposeNoneForRawOrKeda(deployerName) f.Build.Git.URL = repoURL f.Build.Git.Revision = repoBranch @@ -1387,6 +1411,20 @@ func ptr[T interface{}](s T) *T { return &s } +// exposeNoneForRawOrKeda returns the fixtures' explicit external-exposure +// opt-out ("none") for the raw and keda deployers. Knative gets "" (unset): +// knative+none is a hard error (Knative always exposes functions per the +// cluster configuration), so leaving expose unset is knative's legitimate +// fixture state here, not an oversight. +func exposeNoneForRawOrKeda(deployerName string) string { + switch deployerName { + case k8s.KubernetesDeployerName, keda.KedaDeployerName: + return "none" + default: + return "" + } +} + func getHttpClient(ctx context.Context, deployer string) (*http.Client, func(), error) { noopDeferFunc := func() {} diff --git a/pkg/functions/client.go b/pkg/functions/client.go index a7f45c4254..5ee8aa6421 100644 --- a/pkg/functions/client.go +++ b/pkg/functions/client.go @@ -121,6 +121,9 @@ type DeploymentResult struct { Status Status URL string Namespace string + // Exposed is true when URL is externally reachable; false when it is + // only reachable in-cluster (e.g. the cluster-local Service URL). + Exposed bool } // Status of the function from the DeploymentResult @@ -185,8 +188,8 @@ type Describer interface { type Instance struct { // Route is the primary route of a function instance. Route string - // Routes is the primary route plus any other route at which the function - // can be contacted. + // Routes is the primary route first (external when exposed), plus any + // other route at which the function can be contacted. Routes []string `json:"routes" yaml:"routes"` Name string `json:"name" yaml:"name"` Image string `json:"image" yaml:"image"` @@ -902,12 +905,8 @@ func (c *Client) Deploy(ctx context.Context, f Function, oo ...DeployOption) (Fu // Update the function to reflect the new deployed state of the Function f.Deploy.Namespace = result.Namespace - switch result.Status { - case Deployed: - fmt.Fprintf(os.Stderr, "✅ Function deployed in namespace %q and exposed at URL: \n %v\n", result.Namespace, result.URL) - case Updated: - fmt.Fprintf(os.Stderr, "✅ Function updated in namespace %q and exposed at URL: \n %v\n", result.Namespace, result.URL) - default: + if result.Status == Deployed || result.Status == Updated { + fmt.Fprintln(os.Stderr, deployResultMessage(result)) } if c.syncer != nil && !f.Deploy.ManagementDisabled { @@ -919,6 +918,19 @@ func (c *Client) Deploy(ctx context.Context, f Function, oo ...DeployOption) (Fu return f, nil } +// deployResultMessage renders the deploy success message, distinguishing an +// externally exposed function from one reachable in-cluster only. +func deployResultMessage(result DeploymentResult) string { + verb := "deployed" + if result.Status == Updated { + verb = "updated" + } + if result.Exposed { + return fmt.Sprintf("✅ Function %s in namespace %q and exposed at URL: \n %v", verb, result.Namespace, result.URL) + } + return fmt.Sprintf("✅ Function %s in namespace %q, reachable in-cluster only at: \n %v", verb, result.Namespace, result.URL) +} + // RunPipeline runs a Pipeline to build and deploy the function. // Returned function contains applicable registry and deployed image name. // String is the default route. diff --git a/pkg/functions/client_message_unit_test.go b/pkg/functions/client_message_unit_test.go new file mode 100644 index 0000000000..e7f0e772be --- /dev/null +++ b/pkg/functions/client_message_unit_test.go @@ -0,0 +1,36 @@ +package functions + +import ( + "strings" + "testing" +) + +// TestDeployResultMessage covers the deploy success message matrix: +// deployed/updated crossed with exposed ("exposed at URL") vs cluster-local +// ("reachable in-cluster only"). +func TestDeployResultMessage(t *testing.T) { + tests := []struct { + name string + status Status + exposed bool + want string + }{ + {"deployed and exposed", Deployed, true, "deployed in namespace \"ns\" and exposed at URL"}, + {"deployed, in-cluster only", Deployed, false, "deployed in namespace \"ns\", reachable in-cluster only"}, + {"updated and exposed", Updated, true, "updated in namespace \"ns\" and exposed at URL"}, + {"updated, in-cluster only", Updated, false, "updated in namespace \"ns\", reachable in-cluster only"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := deployResultMessage(DeploymentResult{ + Status: tt.status, + Namespace: "ns", + URL: "http://f.example.com", + Exposed: tt.exposed, + }) + if !strings.Contains(got, tt.want) || !strings.Contains(got, "http://f.example.com") { + t.Errorf("expected message to contain %q and the URL, got: %q", tt.want, got) + } + }) + } +} diff --git a/pkg/functions/client_test.go b/pkg/functions/client_test.go index d229c2e0fd..7e5461d0ba 100644 --- a/pkg/functions/client_test.go +++ b/pkg/functions/client_test.go @@ -1540,6 +1540,43 @@ func TestClient_Deploy_UnbuiltErrors(t *testing.T) { } } +// TestClient_Deploy_ExposedMessage asserts Deploy actually prints the +// deployResultMessage (the message matrix itself is unit-tested) +func TestClient_Deploy_ExposedMessage(t *testing.T) { + root, rm := Mktemp(t) + defer rm() + f, err := fn.New().Init(fn.Function{Runtime: TestRuntime, Name: "f", Root: root}) + if err != nil { + t.Fatal(err) + } + + deployer := mock.NewDeployerWithResult(fn.DeploymentResult{ + Status: fn.Deployed, + Namespace: TestNamespace, + URL: "http://f.example.com", + Exposed: false, + }) + client := fn.New(fn.WithDeployer(deployer)) + + old := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + _, err = client.Deploy(t.Context(), f, fn.WithDeploySkipBuildCheck(true)) + + w.Close() + os.Stderr = old + if err != nil { + t.Fatal(err) + } + + var buf [4096]byte + n, _ := r.Read(buf[:]) + if output := string(buf[:n]); !strings.Contains(output, "reachable in-cluster only") { + t.Errorf("expected stderr to contain the in-cluster message, got: %q", output) + } +} + // TestClient_New_BuilderImagesPersisted Asserts that the client preserves user- // provided Builder Images func TestClient_New_BuildersPersisted(t *testing.T) { diff --git a/pkg/functions/errors.go b/pkg/functions/errors.go index 1829bddb03..b52cc3d137 100644 --- a/pkg/functions/errors.go +++ b/pkg/functions/errors.go @@ -10,6 +10,7 @@ import ( var ( ErrEnvironmentNotFound = errors.New("environment not found") ErrFunctionNotFound = errors.New("function not found") + ErrInvalidExpose = errors.New("invalid deploy.expose value") ErrMismatchedName = errors.New("name passed the function source") ErrNameRequired = errors.New("name required") ErrNamespaceRequired = errors.New("namespace required") diff --git a/pkg/functions/function.go b/pkg/functions/function.go index 86c96321ba..2c91010edf 100644 --- a/pkg/functions/function.go +++ b/pkg/functions/function.go @@ -233,6 +233,13 @@ type DeploySpec struct { // for operator management after deploy. The zero value (false) means // the function is managed by default when the func-operator is installed. ManagementDisabled bool `yaml:"managementDisabled,omitempty"` + + // Expose controls external access for the raw deployer (other deployers + // warn and ignore it). Optional; default to exposure for raw deployer. + // Values: "gateway", "gateway:/" (namespace-scope discovery), + // "gateway:/" (pinned), "none" (cluster-local only). + // func creates only HTTPRoute resource, failure to expose fails the deploy. + Expose string `yaml:"expose,omitempty"` } // HealthEndpoints specify the liveness and readiness endpoints for a Runtime @@ -361,6 +368,7 @@ func (f Function) Validate() error { validateOptions(f.Deploy.Options), ValidateLabels(f.Deploy.Labels), validateGit(f.Build.Git), + validateExpose(f.Deploy.Expose), } var b strings.Builder diff --git a/pkg/functions/function_expose.go b/pkg/functions/function_expose.go new file mode 100644 index 0000000000..30861231f3 --- /dev/null +++ b/pkg/functions/function_expose.go @@ -0,0 +1,40 @@ +package functions + +import ( + "fmt" + "strings" +) + +// ParseExpose splits an expose value into technology ("gateway"|"none") and +// optional reference on the FIRST ":" - unambiguous, since k8s +// namespace/name values cannot contain ":". The reference is checked for +// shape only ("namespace/name" or "namespace/"); no cluster lookups. +func ParseExpose(expose string) (tech, ref string, err error) { + if expose == "" || expose == "none" { + return expose, "", nil + } + + tech, ref, _ = strings.Cut(expose, ":") + + if tech != "gateway" { + return "", "", fmt.Errorf("%w: %q", ErrInvalidExpose, expose) + } + + if ref != "" { + if ns, _, found := strings.Cut(ref, "/"); !found || ns == "" { + return "", "", fmt.Errorf( + "%w: %q (gateway reference must be \"namespace/name\" or \"namespace/\", got %q)", ErrInvalidExpose, expose, ref) + } + } + + return tech, ref, nil +} + +// validateExpose delegates to ParseExpose(), the single source of truth for +// valid expose values. +func validateExpose(expose string) (errors []string) { + if _, _, err := ParseExpose(expose); err != nil { + errors = append(errors, err.Error()) + } + return +} diff --git a/pkg/functions/function_expose_unit_test.go b/pkg/functions/function_expose_unit_test.go new file mode 100644 index 0000000000..025538af8e --- /dev/null +++ b/pkg/functions/function_expose_unit_test.go @@ -0,0 +1,76 @@ +package functions + +import ( + "errors" + "testing" +) + +func Test_ParseExpose(t *testing.T) { + tests := []struct { + expose string + tech string + ref string + wantErr bool + }{ + {expose: "", tech: ""}, + {expose: "gateway", tech: "gateway"}, + {expose: "gateway:infra/gw", tech: "gateway", ref: "infra/gw"}, + {expose: "gateway:infra/", tech: "gateway", ref: "infra/"}, + {expose: "none", tech: "none"}, + {expose: "auto", wantErr: true}, + {expose: "bogus", wantErr: true}, + {expose: "gateway:badref", wantErr: true}, + {expose: "gateway:/gw", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.expose, func(t *testing.T) { + tech, ref, err := ParseExpose(tt.expose) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseExpose(%q): expected an error, got nil", tt.expose) + } + return + } + if err != nil { + t.Fatalf("ParseExpose(%q): unexpected error: %v", tt.expose, err) + } + if tech != tt.tech || ref != tt.ref { + t.Errorf("ParseExpose(%q) = (%q, %q), want (%q, %q)", tt.expose, tech, ref, tt.tech, tt.ref) + } + }) + } +} + +func Test_validateExpose(t *testing.T) { + tests := []struct { + expose string + errs int + }{ + {"", 0}, + {"gateway", 0}, + {"gateway:infra/gw", 0}, + {"gateway:infra/", 0}, + {"none", 0}, + {"auto", 1}, + {"bogus", 1}, + {"gateway:badref", 1}, + {"gateway:/gw", 1}, + } + for _, tt := range tests { + t.Run(tt.expose, func(t *testing.T) { + if got := validateExpose(tt.expose); len(got) != tt.errs { + t.Errorf("validateExpose(%q) = %v\n got %d errors but want %d", tt.expose, got, len(got), tt.errs) + } + }) + } +} + +// Test_ParseExpose_TypedError pins the sentinel contract: every invalid +// value wraps ErrInvalidExpose so callers can branch with errors.Is. +func Test_ParseExpose_TypedError(t *testing.T) { + for _, v := range []string{"bogus", "ingress", "gateway:no-slash", "gateway:/name"} { + if _, _, err := ParseExpose(v); !errors.Is(err, ErrInvalidExpose) { + t.Errorf("ParseExpose(%q): expected errors.Is(err, ErrInvalidExpose), got %v", v, err) + } + } +} diff --git a/pkg/k8s/deployer.go b/pkg/k8s/deployer.go index f9f88879d6..66d4c6fca2 100644 --- a/pkg/k8s/deployer.go +++ b/pkg/k8s/deployer.go @@ -21,6 +21,7 @@ import ( "k8s.io/apimachinery/pkg/util/sets" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" + "k8s.io/client-go/util/retry" clienteventingv1 "knative.dev/client/pkg/eventing/v1" eventingv1 "knative.dev/eventing/pkg/apis/eventing/v1" eventingv1client "knative.dev/eventing/pkg/client/clientset/versioned/typed/eventing/v1" @@ -28,6 +29,7 @@ import ( fn "knative.dev/func/pkg/functions" "knative.dev/pkg/apis" duckv1 "knative.dev/pkg/apis/duck/v1" + gwclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" ) const ( @@ -37,6 +39,11 @@ const ( DefaultReadinessEndpoint = "/health/readiness" DefaultHTTPPort = 8080 + // RouteHostnameAnnotation records the externally-exposed hostname (if + // any) on the function's Service, so lister/describer can read it back + // without re-deriving or re-querying the HTTPRoute. + RouteHostnameAnnotation = "function.knative.dev/route-hostname" + // managedByAnnotation identifies triggers managed by this deployer managedByAnnotation = "func.knative.dev/managed-by" managedByValue = "func-raw-deployer" @@ -47,6 +54,11 @@ type DeployerOpt func(*Deployer) type Deployer struct { verbose bool decorator deployer.DeployDecorator + + // exposureDisabled marks a Deployer embedded by another deployer (keda) + // whose functions must stay cluster-local: an HTTPRoute pointed at the + // raw ClusterIP Service would bypass keda's scale-to-zero interceptor. + exposureDisabled bool } func NewDeployer(opts ...DeployerOpt) *Deployer { @@ -63,6 +75,14 @@ func WithDeployerVerbose(verbose bool) DeployerOpt { } } +// WithDeployerExposureDisabled turns off Gateway API exposure; for deployers +// that embed this Deployer but manage exposure themselves (eg. keda). +func WithDeployerExposureDisabled() DeployerOpt { + return func(d *Deployer) { + d.exposureDisabled = true + } +} + func WithDeployerDecorator(decorator deployer.DeployDecorator) DeployerOpt { return func(d *Deployer) { d.decorator = decorator @@ -132,6 +152,12 @@ func (d *Deployer) Deploy(ctx context.Context, f fn.Function) (fn.DeploymentResu return fn.DeploymentResult{}, err } + // Get the Gateway clientset + gwClient, err := gwclientset.NewForConfig(config) + if err != nil { + return fn.DeploymentResult{}, fmt.Errorf("failed to create gateway-api client: %w", err) + } + // Check if Dapr is installed daprInstalled := false _, err = clientset.CoreV1().Namespaces().Get(ctx, "dapr-system", metav1.GetOptions{}) @@ -160,7 +186,15 @@ func (d *Deployer) Deploy(ctx context.Context, f fn.Function) (fn.DeploymentResu return fn.DeploymentResult{}, fmt.Errorf("failed to validate referenced resources: %w", err) } - svc, err := d.generateService(f, namespace, daprInstalled, existingDeployment) + existingService, svcGetErr := serviceClient.Get(ctx, f.Name, metav1.GetOptions{}) + if svcGetErr != nil { + if !errors.IsNotFound(svcGetErr) { + return fn.DeploymentResult{}, fmt.Errorf("failed to get existing service: %w", svcGetErr) + } + existingService = nil + } + + svc, err := d.generateService(f, namespace, daprInstalled, existingDeployment, existingService) if err != nil { return fn.DeploymentResult{}, fmt.Errorf("failed to generate service resources: %w", err) } @@ -172,19 +206,17 @@ func (d *Deployer) Deploy(ctx context.Context, f fn.Function) (fn.DeploymentResu return fn.DeploymentResult{}, fmt.Errorf("failed to update deployment: %w", err) } - existingService, err := serviceClient.Get(ctx, f.Name, metav1.GetOptions{}) - if err == nil { + // update/create service + if svcGetErr == nil { svc.ResourceVersion = existingService.ResourceVersion if _, err = serviceClient.Update(ctx, svc, metav1.UpdateOptions{}); err != nil { return fn.DeploymentResult{}, fmt.Errorf("failed to update service: %w", err) } - } else if errors.IsNotFound(err) { - // Service doesn't exist, create it + } else { + // Confirmed IsNotFound above the generateService() if _, err = serviceClient.Create(ctx, svc, metav1.CreateOptions{}); err != nil { return fn.DeploymentResult{}, fmt.Errorf("failed to create service: %w", err) } - } else { - return fn.DeploymentResult{}, fmt.Errorf("failed to get existing service: %w", err) } status = fn.Updated @@ -214,7 +246,7 @@ func (d *Deployer) Deploy(ctx context.Context, f fn.Function) (fn.DeploymentResu return fn.DeploymentResult{}, fmt.Errorf("failed to create deployment: %w", err) } - svc, err := d.generateService(f, namespace, daprInstalled, deployment) + svc, err := d.generateService(f, namespace, daprInstalled, deployment, nil) if err != nil { return fn.DeploymentResult{}, fmt.Errorf("failed to generate service resources: %w", err) } @@ -233,6 +265,13 @@ func (d *Deployer) Deploy(ctx context.Context, f fn.Function) (fn.DeploymentResu return fn.DeploymentResult{}, fmt.Errorf("deployment did not become ready: %w", err) } + // External exposure via Gateway API HTTPRoute; see reconcileExposure() for + // the create/attach vs remove decision. + url, exposed, err := d.reconcileExposure(ctx, f, namespace, clientset, gwClient) + if err != nil { + return fn.DeploymentResult{}, err + } + // Sync triggers eventingClient, err := newEventingClient(config, namespace) if err != nil { @@ -242,15 +281,181 @@ func (d *Deployer) Deploy(ctx context.Context, f fn.Function) (fn.DeploymentResu return fn.DeploymentResult{}, fmt.Errorf("failed to sync triggers: %w", err) } - url := fmt.Sprintf("http://%s.%s.svc", f.Name, namespace) - return fn.DeploymentResult{ Status: status, URL: url, Namespace: namespace, + Exposed: exposed, }, nil } +// reconcileExposure keeps a raw-deployer function's external exposure (an +// HTTPRoute attached to a Gateway) in sync with f.Deploy.Expose, symmetric +// for both directions: +// - create/attach, when exposure is wanted (default or gateway* form) +// - remove, when it isn't - when Deployer has exposure disabled or the +// function explicitly opted out (expose:none). +// +// Removal is unconditional whenever exposure isn't currently wanted, so a +// stale HTTPRoute from a prior raw deploy never survives a deployer switch. +// The returned bool is true only when the exposure chain actually ran and +// returned an externally-reachable URL (fn.DeploymentResult.Exposed). +func (d *Deployer) reconcileExposure(ctx context.Context, f fn.Function, namespace string, clientset kubernetes.Interface, gwClient gwclientset.Interface) (string, bool, error) { + defaultURL := fmt.Sprintf("http://%s.%s.svc", f.Name, namespace) + + tech, ref, err := fn.ParseExpose(f.Deploy.Expose) + if err != nil { + return "", false, err + } + + if d.exposureDisabled { + if err := d.removeExposure(ctx, clientset, gwClient, namespace, f.Name, false); err != nil { + return "", false, err + } + return defaultURL, false, nil + } + + if tech == "none" { + // enforce=true: the user explicitly demanded NONexposure + if err := d.removeExposure(ctx, clientset, gwClient, namespace, f.Name, true); err != nil { + return "", false, err + } + return defaultURL, false, nil + } + + url, err := d.ensureExposure(ctx, f, namespace, clientset, gwClient, ref) + if err != nil { + return "", false, fmt.Errorf("external exposure failed: %w", err) + } + return url, true, nil +} + +// removeExposure deletes the managed HTTPRoute (never a user-authored route +// sharing the function's name) and clears the recorded exposure state. +// Missing Gateway API CRDs need no special-casing: the GET reports NotFound +// either way, meaning nothing to remove. +// +// enforce selects the failure posture: +// - true (expose:none): failing to verify/remove is a hard error; +// - false (deployer switched away from raw): an RBAC 403 on the route +// GET/DELETE prints a warning and the deploy continues, since keda +// users without Gateway API permissions must stay green. +func (d *Deployer) removeExposure(ctx context.Context, clientset kubernetes.Interface, gwClient gwclientset.Interface, namespace, name string, enforce bool) error { + if _, err := RemoveManagedHTTPRoute(ctx, gwClient, namespace, name); err != nil { + if !enforce && errors.IsForbidden(err) { + fmt.Fprintf(os.Stderr, "⚠️ cannot remove HTTPRoute %q (forbidden) - leaving it in place\n", name) + } else { + return fmt.Errorf("failed to remove HTTPRoute: %w", err) + } + } + + if err := writeRouteHostnameAnnotation(ctx, clientset, namespace, name, ""); err != nil { + if !enforce { + fmt.Fprintf(os.Stderr, "⚠️ failed to clear exposure state: %v\n", err) + return nil + } + return fmt.Errorf("failed to clear exposure state: %w", err) + } + return nil +} + +func (d *Deployer) ensureExposure(ctx context.Context, f fn.Function, namespace string, clientset kubernetes.Interface, gwClient gwclientset.Interface, ref string) (string, error) { + available, err := GatewayAPIAvailable(clientset) + if err != nil { + return "", fmt.Errorf("failed to check Gateway API availability: %w\n\n%s", err, remediateNoGatewayController) + } + if !available { + return "", fmt.Errorf("the Gateway API is not installed on this cluster. %s", remediateNoGatewayController) + } + + // One memoized getter shared by gateway resolution AND the minting-time + // listener selection below, so both decide on a single label snapshot. + getLabels := newNSLabelGetter(ctx, clientset, namespace) + + gw, err := ResolveGateway(ctx, gwClient, namespace, ref, getLabels) + if err != nil { + return "", err + } + + listener, err := selectListener(gw, namespace, getLabels) + if err != nil { + return "", err + } + if listener == nil { + // Unreachable while both passes share the one memoized getter above + // (same snapshot -> same admitting listener); this tripwire fires + // only if a future caller breaks that discipline. + return "", fmt.Errorf("gateway %s/%s no longer has a listener admitting namespace %q", gw.Namespace, gw.Name, namespace) + } + + hostname, err := ResolveHostname(f, namespace, gw, listener) + if err != nil { + return "", err + } + + // Get deployment for owner reference + deployment, err := clientset.AppsV1().Deployments(namespace).Get(ctx, f.Name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get deployment for owner reference: %w", err) + } + + route, err := GenerateHTTPRoute(f, f.Name, 80, hostname, deployment, gw, d.decorator, KubernetesDeployerName) + if err != nil { + return "", fmt.Errorf("failed to generate HTTPRoute: %w", err) + } + + fmt.Fprintf(os.Stderr, "🌐 Creating HTTPRoute for Gateway %s/%s -> %s\n", gw.Namespace, gw.Name, hostname) + + if err := EnsureHTTPRoute(ctx, gwClient, namespace, route); err != nil { + return "", err + } + + // Wait for the Gateway to accept the route - enforced, never downgraded to a warning. + if err := WaitForRouteAccepted(ctx, gwClient, namespace, f.Name, gw.Name, gw.Namespace, 30*time.Second); err != nil { + return "", fmt.Errorf("HTTPRoute was not accepted: %w", err) + } + + if err := writeRouteHostnameAnnotation(ctx, clientset, namespace, f.Name, hostname); err != nil { + return "", err + } + + return fmt.Sprintf("http://%s", hostname), nil +} + +// writeRouteHostnameAnnotation records (hostname != "") or clears +// (hostname == "") the exposed hostname on the function's Service: no-op +// when already current, retried on write conflicts. A missing Service is +// tolerated only when clearing; recording against one that doesn't exist +// is a real error. +func writeRouteHostnameAnnotation(ctx context.Context, clientset kubernetes.Interface, namespace, name, hostname string) error { + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + svc, err := clientset.CoreV1().Services(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return err + } + if svc.Annotations[RouteHostnameAnnotation] == hostname { + return nil + } + if hostname == "" { + delete(svc.Annotations, RouteHostnameAnnotation) + } else { + if svc.Annotations == nil { + svc.Annotations = map[string]string{} + } + svc.Annotations[RouteHostnameAnnotation] = hostname + } + _, err = clientset.CoreV1().Services(namespace).Update(ctx, svc, metav1.UpdateOptions{}) + return err + }) + if err != nil { + if hostname == "" && errors.IsNotFound(err) { + return nil + } + return fmt.Errorf("failed to update exposure state on service %q: %w", name, err) + } + return nil +} + // generateTriggerName creates a deterministic trigger name based on subscription content func generateTriggerName(functionName, broker string, filters map[string]string) string { filterKeys := make([]string, 0, len(filters)) @@ -453,13 +658,23 @@ func (d *Deployer) generateDeployment(f fn.Function, namespace string, daprInsta return deployment, nil } -func (d *Deployer) generateService(f fn.Function, namespace string, daprInstalled bool, deployment *appsv1.Deployment) (*corev1.Service, error) { +// generateService builds the function's Service; existingService is the +// currently-deployed Service on update, nil on create. +func (d *Deployer) generateService(f fn.Function, namespace string, daprInstalled bool, deployment *appsv1.Deployment, existingService *corev1.Service) (*corev1.Service, error) { labels, err := deployer.GenerateCommonLabels(f, d.decorator) if err != nil { return nil, err } annotations := deployer.GenerateCommonAnnotations(f, d.decorator, daprInstalled, KubernetesDeployerName) + // re-apply the hostname annotation, contrary to the rest of annotations + // which are "always regenerate" -- the hostname is cluster-derived, not + // in func.yaml - might come from Gateway discovery/its wildcard listener + // hostname so only the exposure step can recompute it, and it does so + // after this Service write. + if existingService != nil && existingService.Annotations[RouteHostnameAnnotation] != "" { + annotations[RouteHostnameAnnotation] = existingService.Annotations[RouteHostnameAnnotation] + } service := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ diff --git a/pkg/k8s/deployer_test.go b/pkg/k8s/deployer_test.go index 61fb3658e1..fec57358ed 100644 --- a/pkg/k8s/deployer_test.go +++ b/pkg/k8s/deployer_test.go @@ -489,3 +489,14 @@ func Test_ProcessVolumes_ValidPath(t *testing.T) { t.Errorf("expected mount path /etc/secret, got %s", mounts[0].MountPath) } } + +// Test_WithDeployerExposureDisabled: exposure is on by default (raw) and off +// with others +func Test_WithDeployerExposureDisabled(t *testing.T) { + if NewDeployer().exposureDisabled { + t.Error("expected exposure enabled on a default Deployer") + } + if !NewDeployer(WithDeployerExposureDisabled()).exposureDisabled { + t.Error("expected exposure disabled with WithDeployerExposureDisabled") + } +} diff --git a/pkg/k8s/describer.go b/pkg/k8s/describer.go index 14f8468fdf..c8257b9eba 100644 --- a/pkg/k8s/describer.go +++ b/pkg/k8s/describer.go @@ -78,7 +78,19 @@ func (d *Describer) Describe(ctx context.Context, name, namespace string) (fn.In } } - primaryRouteURL := fmt.Sprintf("http://%s.%s.svc", name, namespace) // TODO: get correct scheme? + internalURL := fmt.Sprintf("http://%s.%s.svc", name, namespace) + primaryRouteURL := internalURL + + // External hostname (if exposed) was recorded on the Service by Deploy() + // at exposure time - no extra API call or client needed here. + if hostname, ok := service.Annotations[RouteHostnameAnnotation]; ok && hostname != "" { + primaryRouteURL = fmt.Sprintf("http://%s", hostname) + } + // an exposed function stays reachable in-cluster too + routes := []string{primaryRouteURL} + if primaryRouteURL != internalURL { + routes = append(routes, internalURL) + } // get image image := "" @@ -104,7 +116,7 @@ func (d *Describer) Describe(ctx context.Context, name, namespace string) (fn.In Deployer: KubernetesDeployerName, Labels: deployment.Labels, Route: primaryRouteURL, - Routes: []string{primaryRouteURL}, + Routes: routes, Image: image, Middleware: fn.Middleware{ Version: middlewareVersion, diff --git a/pkg/k8s/exposure_test.go b/pkg/k8s/exposure_test.go new file mode 100644 index 0000000000..dac882cfcd --- /dev/null +++ b/pkg/k8s/exposure_test.go @@ -0,0 +1,305 @@ +package k8s + +import ( + "context" + goerrors "errors" + "strings" + "testing" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + fakediscovery "k8s.io/client-go/discovery/fake" + fakeclientset "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" + fn "knative.dev/func/pkg/functions" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" +) + +// markGatewayAPIInstalled makes the fake cluster's discovery report the +// Gateway API HTTPRoute resource as installed, so GatewayAPIAvailable() +// returns (true, nil) against it. +func markGatewayAPIInstalled(kubeFake *fakeclientset.Clientset) { + kubeFake.Discovery().(*fakediscovery.FakeDiscovery).Resources = []*metav1.APIResourceList{ + { + GroupVersion: gatewayAPIGroupVersion, + APIResources: []metav1.APIResource{{Kind: "HTTPRoute"}}, + }, + } +} + +// --- discovery-miss: no provisioning, hard actionable error ----------------- + +// TestEnsureExposure_NoEligibleGatewayIsHardError proves that func never +// provisions a Gateway: a cluster-wide discovery miss (zero +// eligible Gateways, zero GatewayClasses or not) is unconditionally a hard +// error naming the remaining options (pin, ask an admin using the manifest +// example, or opt out). +func TestEnsureExposure_NoEligibleGatewayIsHardError(t *testing.T) { + ctx := context.Background() + kubeFake := fakeclientset.NewClientset() + markGatewayAPIInstalled(kubeFake) + gwFake := newGwFake() // zero Gateways, zero GatewayClasses + + d := NewDeployer() + f := fn.Function{Name: "myfunc", Deploy: fn.DeploySpec{Deployer: KubernetesDeployerName}} + + _, err := d.ensureExposure(ctx, f, "default", kubeFake, gwFake, "") + if err == nil { + t.Fatal("expected a hard error when no eligible Gateway is found, got nil") + } + if !strings.Contains(err.Error(), "no eligible gateway found on this cluster") { + t.Errorf("expected the discovery-miss message, got: %v", err) + } + if !strings.Contains(err.Error(), "func deploy --expose=gateway:/") { + t.Errorf("expected the pin-a-Gateway option, got: %v", err) + } + if !strings.Contains(err.Error(), "gatewayClassName: ") { + t.Errorf("expected the minimal ready-to-apply manifest example, got: %v", err) + } + if !strings.Contains(err.Error(), "func deploy --expose=none") { + t.Errorf("expected the cluster-local opt-out option, got: %v", err) + } +} + +// --- annotation lifecycle --------------------------------------------------- + +func TestWriteRouteHostnameAnnotation_Set(t *testing.T) { + ctx := context.Background() + kubeFake := fakeclientset.NewClientset(&corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "myfunc", Namespace: "default"}, + }) + + if err := writeRouteHostnameAnnotation(ctx, kubeFake, "default", "myfunc", "myfunc.default.example.com"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + svc, err := kubeFake.CoreV1().Services("default").Get(ctx, "myfunc", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + if svc.Annotations[RouteHostnameAnnotation] != "myfunc.default.example.com" { + t.Errorf("expected annotation to be set, got %v", svc.Annotations) + } +} + +func TestWriteRouteHostnameAnnotation_Clear(t *testing.T) { + ctx := context.Background() + kubeFake := fakeclientset.NewClientset(&corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myfunc", + Namespace: "default", + Annotations: map[string]string{RouteHostnameAnnotation: "myfunc.default.example.com"}, + }, + }) + + if err := writeRouteHostnameAnnotation(ctx, kubeFake, "default", "myfunc", ""); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + svc, err := kubeFake.CoreV1().Services("default").Get(ctx, "myfunc", metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + if _, ok := svc.Annotations[RouteHostnameAnnotation]; ok { + t.Errorf("expected annotation to be cleared, got %v", svc.Annotations) + } +} + +func TestWriteRouteHostnameAnnotation_ClearMissingServiceIsNotError(t *testing.T) { + ctx := context.Background() + kubeFake := fakeclientset.NewClientset() + + if err := writeRouteHostnameAnnotation(ctx, kubeFake, "default", "missing", ""); err != nil { + t.Fatalf("expected nil error for a missing service, got: %v", err) + } +} + +func TestWriteRouteHostnameAnnotation_SetMissingServiceIsError(t *testing.T) { + ctx := context.Background() + kubeFake := fakeclientset.NewClientset() + + if err := writeRouteHostnameAnnotation(ctx, kubeFake, "default", "missing", "missing.default.example.com"); err == nil { + t.Fatal("expected an error recording a hostname against a nonexistent service, got nil") + } +} + +func Test_generateService_CarriesOverRouteHostnameAnnotation(t *testing.T) { + d := &Deployer{} + f := fn.Function{Name: "myfunc"} + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "myfunc", Namespace: "default", UID: types.UID("uid")}, + } + existing := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{RouteHostnameAnnotation: "myfunc.default.example.com"}, + }, + } + + svc, err := d.generateService(f, "default", false, deployment, existing) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if svc.Annotations[RouteHostnameAnnotation] != "myfunc.default.example.com" { + t.Errorf("expected the annotation to be carried over from the existing service, got %v", svc.Annotations) + } +} + +func Test_generateService_CreateHasNoExposureAnnotation(t *testing.T) { + d := &Deployer{} + f := fn.Function{Name: "myfunc"} + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "myfunc", Namespace: "default", UID: types.UID("uid")}, + } + + svc, err := d.generateService(f, "default", false, deployment, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if _, ok := svc.Annotations[RouteHostnameAnnotation]; ok { + t.Errorf("expected no exposure annotation on a fresh create (nil existingService), got %v", svc.Annotations) + } +} + +// --- reconcileExposure()'s Exposed signal ------------------------------------ +// +// fn.DeploymentResult.Exposed drives client.go's conditional deploy-success +// wording ("exposed at URL" vs "reachable in-cluster only"); these prove the +// raw deployer sets it correctly for the three reconcileExposure() branches. + +func TestReconcileExposure_ExposedSignal(t *testing.T) { + ctx := context.Background() + + t.Run("expose:none sets Exposed=false with the cluster-local URL", func(t *testing.T) { + kubeFake := fakeclientset.NewClientset() + gwFake := newGwFake() + d := NewDeployer() + f := fn.Function{Name: "myfunc", Deploy: fn.DeploySpec{Deployer: KubernetesDeployerName, Expose: "none"}} + + url, exposed, err := d.reconcileExposure(ctx, f, "default", kubeFake, gwFake) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exposed { + t.Error("expected Exposed=false for expose:none") + } + if url != "http://myfunc.default.svc" { + t.Errorf("expected the cluster-local URL, got %q", url) + } + }) + + t.Run("exposure-disabled deployer (embedded keda) sets Exposed=false", func(t *testing.T) { + kubeFake := fakeclientset.NewClientset() + gwFake := newGwFake() + d := NewDeployer(WithDeployerExposureDisabled()) + f := fn.Function{Name: "myfunc", Deploy: fn.DeploySpec{Deployer: "keda"}} + + url, exposed, err := d.reconcileExposure(ctx, f, "default", kubeFake, gwFake) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exposed { + t.Error("expected Exposed=false for the embedded/deployer-switch path") + } + if url != "http://myfunc.default.svc" { + t.Errorf("expected the cluster-local URL, got %q", url) + } + }) + + t.Run("successful gateway exposure sets Exposed=true", func(t *testing.T) { + // the exposure chain needs both of these to already exist: the + // Deployment becomes the HTTPRoute's owner, and the Service receives + // the hostname annotation at the very end + kubeFake := fakeclientset.NewClientset( + &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: "myfunc", Namespace: "default"}}, + &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "myfunc", Namespace: "default"}}, + ) + // set GatewayAPI as installed - groupVersion + API resource HTTPRoute + markGatewayAPIInstalled(kubeFake) + + gwFake := newGwFake() + // put gateway into a in-memory bucket + seedGateway(t, gwFake, withIPAddresses(newGateway("gw", "infra", true, httpListenerFrom(gwv1.NamespacesFromAll)), "172.18.0.5")) + + // This reactor plays the gateway controller, which a fake cluster + // doesn't have. The route gets fetched twice: first EnsureHTTPRoute() + // asks "does it exist yet?" - we return false -> stay out of that one + // so the store honestly answers "no HTTPRoute resource in bucket" and + // the code really creates the route. Then WaitForRouteAccepted() starts + // polling for a verdict; those reads we answer ourselves with an + // already-accepted route, exactly what a live controller would have + // written, so the test doesn't sit out the acceptance timeout. + getCalls := 0 + gwFake.PrependReactor("get", "httproutes", func(action ktesting.Action) (bool, runtime.Object, error) { + getCalls++ + if getCalls == 1 { + return false, nil, nil + } + // forge the route as a GET would see it after a live controller + // accepted it + return true, newHTTPRoute("myfunc", "default", 1, + parentStatus("gw", "infra", metav1.ConditionTrue, "Accepted", "ok", 0)), nil + }) + + d := NewDeployer() + f := fn.Function{Name: "myfunc", Deploy: fn.DeploySpec{Deployer: KubernetesDeployerName, Expose: "gateway"}} + + // From here it's all real production code. reconcileExposure() sees + // expose=gateway and hands off to ensureExposure(), which walks the + // whole chain: checks the Gateway API is installed (faked above), + // discovers the seeded gateway, picks its listener and mints the + // sslip hostname from the IP, creates the HTTPRoute (the reactor's + // first read, then a real create), waits for acceptance (the + // reactor's later reads), and finally stamps the hostname onto the + // Service. + url, exposed, err := d.reconcileExposure(ctx, f, "default", kubeFake, gwFake) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !exposed { + t.Error("expected Exposed=true for a successful gateway exposure") + } + if !strings.Contains(url, "172.18.0.5") { + t.Errorf("expected the sslip URL derived from the Gateway's IP, got %q", url) + } + }) +} + +// --- removeExposure(): Forbidden semantics differ by enforce ----------------- + +// TestRemoveExposure_ForbiddenSemantics proves the two removal directions +// treat a Forbidden GET on the HTTPRoute differently: the keda-switch path +// (enforce=false) was Gateway-API-RBAC-free before this feature existed and +// must stay green, so it warns and continues; the expose:none path +// (enforce=true) is the user's explicit unexposure demand, so an inability +// to verify/remove is the honest hard-error outcome. +func TestRemoveExposure_ForbiddenSemantics(t *testing.T) { + ctx := context.Background() + forbiddenGwFake := func() gwclientset.Interface { + gwFake := newGwFake() + gwFake.PrependReactor("get", "httproutes", func(action ktesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden(gwv1.Resource("httproutes"), "myfunc", goerrors.New("rbac")) + }) + return gwFake + } + + t.Run("keda-switch path (enforce=false): Forbidden warns and continues", func(t *testing.T) { + d := NewDeployer() + err := d.removeExposure(ctx, fakeclientset.NewClientset(), forbiddenGwFake(), "default", "myfunc", false) + if err != nil { + t.Fatalf("expected nil error (warn-and-continue) for Forbidden under enforce=false, got: %v", err) + } + }) + + t.Run("expose:none path (enforce=true): Forbidden is a hard error", func(t *testing.T) { + d := NewDeployer() + err := d.removeExposure(ctx, fakeclientset.NewClientset(), forbiddenGwFake(), "default", "myfunc", true) + if err == nil { + t.Fatal("expected a hard error for Forbidden under enforce=true, got nil") + } + }) +} diff --git a/pkg/k8s/gateway_test.go b/pkg/k8s/gateway_test.go new file mode 100644 index 0000000000..2d93543769 --- /dev/null +++ b/pkg/k8s/gateway_test.go @@ -0,0 +1,1044 @@ +package k8s + +import ( + "context" + goerrors "errors" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + ktesting "k8s.io/client-go/testing" + "knative.dev/func/pkg/deployer" + fn "knative.dev/func/pkg/functions" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" + gwfake "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned/fake" +) + +// --- fixture helpers -------------------------------------------------------- +// +// Two traps in gwfake's object store (the in-memory stand-in for the API +// server) shape these helpers: +// - Seed via Create() on the typed sub-clients, never via +// NewSimpleClientset(objs...): the constructor guesses each object's +// storage bucket from its Kind and mis-pluralizes Gateway to +// "gatewaies", so the object lands where no typed read ever looks - +// no error anywhere -> cluster reports zero Gateways. +// - The deprecated NewSimpleClientset stays: NewClientset's tracker +// resolves every operation through the generated apply-configuration +// schema, which gateway-api doesn't ship for these types (broken +// through at least v1.6.0) - everything fails with "no matches for +// ... Resource=gateways", even Create(). + +// newGwFake builds the gateway-api fake every test in this package uses. The +// deprecated constructor is intentional (see the fixture note above), so the +// lint suppression lives in exactly one place. +func newGwFake() *gwfake.Clientset { + return gwfake.NewSimpleClientset() //nolint:staticcheck // NewClientset is broken for gateway-api types (no generated apply configurations) through at least v1.6.0 +} + +// create new gateway on "cluster" (bucket) because we pass in fakeGW interface +func seedGateway(t *testing.T, cs gwclientset.Interface, gw *gwv1.Gateway) { + t.Helper() + if _, err := cs.GatewayV1().Gateways(gw.Namespace).Create(context.Background(), gw, metav1.CreateOptions{}); err != nil { + t.Fatalf("failed to seed gateway %s/%s: %v", gw.Namespace, gw.Name, err) + } +} + +// create new route on "cluster" (bucket) because we pass in fakeGW interface +func seedHTTPRoute(t *testing.T, cs gwclientset.Interface, route *gwv1.HTTPRoute) { + t.Helper() + if _, err := cs.GatewayV1().HTTPRoutes(route.Namespace).Create(context.Background(), route, metav1.CreateOptions{}); err != nil { + t.Fatalf("failed to seed httproute %s/%s: %v", route.Namespace, route.Name, err) + } +} + +// staticNSLabels builds an nsLabelGetter stub returning fixed labels +func staticNSLabels(l map[string]string) nsLabelGetter { + return func() (labels.Set, error) { return labels.Set(l), nil } +} + +// create new gateway in memory +func newGateway(name, ns string, programmed bool, listeners ...gwv1.Listener) *gwv1.Gateway { + gw := &gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: gwv1.GatewaySpec{Listeners: listeners}, + } + if programmed { + gw.Status.Conditions = []metav1.Condition{ + {Type: string(gwv1.GatewayConditionProgrammed), Status: metav1.ConditionTrue, Reason: "Programmed", Message: "ok"}, + } + } + return gw +} + +// allowedRoutes.namespace.from - "routes from which namespaces may attach +// to this listener" returned as GW object listener +func httpListenerFrom(from gwv1.FromNamespaces) gwv1.Listener { + f := from + return gwv1.Listener{ + Name: "http", + Protocol: gwv1.HTTPProtocolType, + Port: 80, + AllowedRoutes: &gwv1.AllowedRoutes{ + Namespaces: &gwv1.RouteNamespaces{From: &f}, + }, + } +} + +// httpListenerUnsetFrom builds a listener with allowedRoutes.namespaces.from +// entirely unset, to exercise the Gateway API default (Same). +func httpListenerUnsetFrom() gwv1.Listener { + return gwv1.Listener{Name: "http", Protocol: gwv1.HTTPProtocolType, Port: 80} +} + +// listener requires extra labels on namespace to allow routes to attach to it +func httpListenerSelector(matchLabels map[string]string) gwv1.Listener { + from := gwv1.NamespacesFromSelector + return gwv1.Listener{ + Name: "http", + Protocol: gwv1.HTTPProtocolType, + Port: 80, + AllowedRoutes: &gwv1.AllowedRoutes{ + Namespaces: &gwv1.RouteNamespaces{ + From: &from, + Selector: &metav1.LabelSelector{MatchLabels: matchLabels}, + }, + }, + } +} + +// listener with only allowed kinds 'kinds' to attach to it +func httpListenerKinds(from gwv1.FromNamespaces, kinds ...string) gwv1.Listener { + l := httpListenerFrom(from) + rgks := make([]gwv1.RouteGroupKind, len(kinds)) + for i, k := range kinds { + rgks[i] = gwv1.RouteGroupKind{Kind: gwv1.Kind(k)} + } + l.AllowedRoutes.Kinds = rgks + return l +} + +func withHostname(l gwv1.Listener, hostname string) gwv1.Listener { + h := gwv1.Hostname(hostname) + l.Hostname = &h + return l +} + +func withIPAddresses(gw *gwv1.Gateway, ips ...string) *gwv1.Gateway { + ipType := gwv1.IPAddressType + addrs := make([]gwv1.GatewayStatusAddress, len(ips)) + for i, ip := range ips { + addrs[i] = gwv1.GatewayStatusAddress{Type: &ipType, Value: ip} + } + gw.Status.Addresses = addrs + return gw +} + +func withHostnameAddress(gw *gwv1.Gateway, hostname string) *gwv1.Gateway { + hostType := gwv1.HostnameAddressType + gw.Status.Addresses = []gwv1.GatewayStatusAddress{{Type: &hostType, Value: hostname}} + return gw +} + +func TestParseGatewayRef(t *testing.T) { + tests := []struct { + ref string + wantNs string + wantName string // empty for the trailing-slash (namespace-only) form + wantErr bool + }{ + {ref: "infra/func-gateway", wantNs: "infra", wantName: "func-gateway"}, + {ref: "infra/", wantNs: "infra", wantName: ""}, + {ref: "func-gateway", wantErr: true}, + {ref: "/func-gateway", wantErr: true}, + {ref: "", wantErr: true}, + } + for _, test := range tests { + t.Run(test.ref, func(t *testing.T) { + ns, name, err := ParseGatewayRef(test.ref) + if test.wantErr { + if err == nil { + t.Fatalf("expected error for ref %q, got nil", test.ref) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ns != test.wantNs || name != test.wantName { + t.Errorf("ParseGatewayRef(%q) = (%q, %q), want (%q, %q)", + test.ref, ns, name, test.wantNs, test.wantName) + } + }) + } +} + +func TestResolveGateway_ClusterWideDiscovery(t *testing.T) { + ctx := context.Background() + getLabels := staticNSLabels(nil) + + t.Run("zero eligible gateways is a hard error with remediation", func(t *testing.T) { + gwFake := newGwFake() + _, err := ResolveGateway(ctx, gwFake, "default", "", getLabels) + if err == nil || !strings.Contains(err.Error(), "no eligible gateway found on this cluster") { + t.Fatalf("expected no-eligible-gateway error, got %v", err) + } + }) + + t.Run("one eligible gateway is selected", func(t *testing.T) { + gwFake := newGwFake() + seedGateway(t, gwFake, newGateway("shared", "infra", true, httpListenerFrom(gwv1.NamespacesFromAll))) + gw, err := ResolveGateway(ctx, gwFake, "default", "", getLabels) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gw.Name != "shared" || gw.Namespace != "infra" { + t.Errorf("got %s/%s, want infra/shared", gw.Namespace, gw.Name) + } + }) + + t.Run("Programmed=False gateway is filtered out", func(t *testing.T) { + gwFake := newGwFake() + seedGateway(t, gwFake, newGateway("not-ready", "infra", false, httpListenerFrom(gwv1.NamespacesFromAll))) + _, err := ResolveGateway(ctx, gwFake, "default", "", getLabels) + if err == nil || !strings.Contains(err.Error(), "no eligible gateway found on this cluster") { + t.Fatalf("expected no-eligible-gateway error (Programmed=False filtered), got %v", err) + } + }) + + t.Run("multiple eligible gateways is a hard error listing candidates", func(t *testing.T) { + gwFake := newGwFake() + seedGateway(t, gwFake, newGateway("gw1", "infra", true, httpListenerFrom(gwv1.NamespacesFromAll))) + seedGateway(t, gwFake, newGateway("gw2", "other", true, httpListenerFrom(gwv1.NamespacesFromAll))) + _, err := ResolveGateway(ctx, gwFake, "default", "", getLabels) + if err == nil { + t.Fatal("expected error for multiple candidates, got nil") + } + if !strings.Contains(err.Error(), "infra/gw1") || !strings.Contains(err.Error(), "other/gw2") { + t.Errorf("expected error to list both candidates, got: %v", err) + } + }) + + t.Run("Same admission excludes cross-namespace gateway", func(t *testing.T) { + gwFake := newGwFake() + seedGateway(t, gwFake, newGateway("gw", "infra", true, httpListenerFrom(gwv1.NamespacesFromSame))) + _, err := ResolveGateway(ctx, gwFake, "default", "", getLabels) + if err == nil || !strings.Contains(err.Error(), "no eligible gateway found on this cluster") { + t.Fatalf("expected no-eligible-gateway error (Same excludes cross-ns), got %v", err) + } + }) + + t.Run("Same admission (default, unset from) admits same-namespace gateway", func(t *testing.T) { + gwFake := newGwFake() + seedGateway(t, gwFake, newGateway("gw", "default", true, httpListenerUnsetFrom())) + gw, err := ResolveGateway(ctx, gwFake, "default", "", getLabels) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gw.Name != "gw" || gw.Namespace != "default" { + t.Errorf("got %s/%s, want default/gw", gw.Namespace, gw.Name) + } + }) + + t.Run("Selector admission matching namespace labels", func(t *testing.T) { + labeled := staticNSLabels(map[string]string{"team": "a"}) + gwFake := newGwFake() + seedGateway(t, gwFake, newGateway("gw", "infra", true, httpListenerSelector(map[string]string{"team": "a"}))) + gw, err := ResolveGateway(ctx, gwFake, "default", "", labeled) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gw.Name != "gw" || gw.Namespace != "infra" { + t.Errorf("got %s/%s, want infra/gw", gw.Namespace, gw.Name) + } + }) + + t.Run("Selector admission not matching namespace labels excludes gateway", func(t *testing.T) { + labeled := staticNSLabels(map[string]string{"team": "b"}) + gwFake := newGwFake() + seedGateway(t, gwFake, newGateway("gw", "infra", true, httpListenerSelector(map[string]string{"team": "a"}))) + _, err := ResolveGateway(ctx, gwFake, "default", "", labeled) + if err == nil || !strings.Contains(err.Error(), "no eligible gateway found on this cluster") { + t.Fatalf("expected no-eligible-gateway error (selector mismatch), got %v", err) + } + }) + + t.Run("allowedRoutes.kinds excluding HTTPRoute filters listener out", func(t *testing.T) { + gwFake := newGwFake() + seedGateway(t, gwFake, newGateway("gw", "infra", true, httpListenerKinds(gwv1.NamespacesFromAll, "GRPCRoute"))) + _, err := ResolveGateway(ctx, gwFake, "default", "", getLabels) + if err == nil || !strings.Contains(err.Error(), "no eligible gateway found on this cluster") { + t.Fatalf("expected no-eligible-gateway error (kinds excludes HTTPRoute), got %v", err) + } + }) + + t.Run("allowedRoutes.kinds including HTTPRoute admits listener", func(t *testing.T) { + gwFake := newGwFake() + seedGateway(t, gwFake, newGateway("gw", "infra", true, httpListenerKinds(gwv1.NamespacesFromAll, "HTTPRoute"))) + gw, err := ResolveGateway(ctx, gwFake, "default", "", getLabels) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gw.Name != "gw" || gw.Namespace != "infra" { + t.Errorf("got %s/%s, want infra/gw", gw.Namespace, gw.Name) + } + }) +} + +func TestResolveGateway_NamespaceRestricted(t *testing.T) { + ctx := context.Background() + getLabels := staticNSLabels(nil) + + t.Run("restricts search to given namespace", func(t *testing.T) { + gwFake := newGwFake() + seedGateway(t, gwFake, newGateway("gw", "infra", true, httpListenerFrom(gwv1.NamespacesFromAll))) + seedGateway(t, gwFake, newGateway("other", "other-ns", true, httpListenerFrom(gwv1.NamespacesFromAll))) + gw, err := ResolveGateway(ctx, gwFake, "default", "infra/", getLabels) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gw.Name != "gw" || gw.Namespace != "infra" { + t.Errorf("got %s/%s, want infra/gw", gw.Namespace, gw.Name) + } + }) + + t.Run("zero found in restricted namespace is a hard error naming the namespace, not provisioning", func(t *testing.T) { + gwFake := newGwFake() + _, err := ResolveGateway(ctx, gwFake, "default", "infra/", getLabels) + if err == nil { + t.Fatal("expected error, got nil") + } + if strings.Contains(err.Error(), "on this cluster") { + t.Fatalf("namespace-restricted miss must name the searched namespace, not claim a cluster-wide miss: %v", err) + } + if !strings.Contains(err.Error(), `no eligible gateway found in namespace "infra"`) { + t.Errorf("expected the error to name the searched namespace, got: %v", err) + } + if !strings.Contains(err.Error(), "func deploy --expose=none") { + t.Errorf("expected the cluster-local opt-out option, got: %v", err) + } + }) + + t.Run("multiple in restricted namespace is a hard error", func(t *testing.T) { + gwFake := newGwFake() + seedGateway(t, gwFake, newGateway("gw1", "infra", true, httpListenerFrom(gwv1.NamespacesFromAll))) + seedGateway(t, gwFake, newGateway("gw2", "infra", true, httpListenerFrom(gwv1.NamespacesFromAll))) + _, err := ResolveGateway(ctx, gwFake, "default", "infra/", getLabels) + if err == nil { + t.Fatal("expected error for multiple candidates, got nil") + } + }) +} + +func TestResolveGateway_ExplicitRef(t *testing.T) { + ctx := context.Background() + getLabels := staticNSLabels(nil) + + t.Run("nonexistent gateway is a hard error", func(t *testing.T) { + gwFake := newGwFake() + _, err := ResolveGateway(ctx, gwFake, "default", "infra/missing", getLabels) + if err == nil || !strings.Contains(err.Error(), "does not exist") { + t.Fatalf("expected 'does not exist' error, got %v", err) + } + }) + + t.Run("not Programmed is a hard error naming the check", func(t *testing.T) { + gwFake := newGwFake() + seedGateway(t, gwFake, newGateway("gw", "infra", false, httpListenerFrom(gwv1.NamespacesFromAll))) + _, err := ResolveGateway(ctx, gwFake, "default", "infra/gw", getLabels) + if err == nil || !strings.Contains(err.Error(), "Programmed") { + t.Fatalf("expected error naming Programmed check, got %v", err) + } + }) + + t.Run("no admitting listener is a hard error naming the check", func(t *testing.T) { + gwFake := newGwFake() + seedGateway(t, gwFake, newGateway("gw", "infra", true, httpListenerFrom(gwv1.NamespacesFromSame))) + _, err := ResolveGateway(ctx, gwFake, "default", "infra/gw", getLabels) + if err == nil || !strings.Contains(err.Error(), "listener") { + t.Fatalf("expected error naming listener check, got %v", err) + } + }) + + t.Run("valid explicit ref succeeds", func(t *testing.T) { + gwFake := newGwFake() + seedGateway(t, gwFake, newGateway("gw", "infra", true, httpListenerFrom(gwv1.NamespacesFromAll))) + gw, err := ResolveGateway(ctx, gwFake, "default", "infra/gw", getLabels) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gw.Name != "gw" || gw.Namespace != "infra" { + t.Errorf("got %s/%s, want infra/gw", gw.Namespace, gw.Name) + } + }) +} + +func TestResolveHostname(t *testing.T) { + f := fn.Function{Name: "myfunc"} + + t.Run("f.Domain set mints name.ns.domain", func(t *testing.T) { + gw := newGateway("gw", "infra", true) + listener := httpListenerFrom(gwv1.NamespacesFromAll) + fWithDomain := f + fWithDomain.Domain = "example.com" + host, err := ResolveHostname(fWithDomain, "default", gw, &listener) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if host != "myfunc.default.example.com" { + t.Errorf("got %q, want myfunc.default.example.com", host) + } + }) + + t.Run("f.Domain must intersect listener hostname or hard error", func(t *testing.T) { + gw := newGateway("gw", "infra", true) + listener := withHostname(httpListenerFrom(gwv1.NamespacesFromAll), "other.example.net") + fWithDomain := f + fWithDomain.Domain = "example.com" + _, err := ResolveHostname(fWithDomain, "default", gw, &listener) + if err == nil { + t.Fatal("expected hard error for non-intersecting listener hostname, got nil") + } + }) + + t.Run("f.Domain intersecting exact listener hostname succeeds", func(t *testing.T) { + gw := newGateway("gw", "infra", true) + listener := withHostname(httpListenerFrom(gwv1.NamespacesFromAll), "myfunc.default.example.com") + fWithDomain := f + fWithDomain.Domain = "example.com" + host, err := ResolveHostname(fWithDomain, "default", gw, &listener) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if host != "myfunc.default.example.com" { + t.Errorf("got %q, want myfunc.default.example.com", host) + } + }) + + t.Run("wildcard listener hostname mints single-label hostname", func(t *testing.T) { + gw := newGateway("gw", "infra", true) + listener := withHostname(httpListenerFrom(gwv1.NamespacesFromAll), "*.example.com") + host, err := ResolveHostname(f, "default", gw, &listener) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if host != "myfunc-default.example.com" { + t.Errorf("got %q, want myfunc-default.example.com", host) + } + }) + + t.Run("IP gateway address mints sslip.io hostname", func(t *testing.T) { + gw := withIPAddresses(newGateway("gw", "infra", true), "172.18.0.5") + listener := httpListenerFrom(gwv1.NamespacesFromAll) + host, err := ResolveHostname(f, "default", gw, &listener) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if host != "myfunc-default.172.18.0.5.sslip.io" { + t.Errorf("got %q, want myfunc-default.172.18.0.5.sslip.io", host) + } + }) + + t.Run("DNS-name gateway address is a hard error asking for --domain", func(t *testing.T) { + gw := withHostnameAddress(newGateway("gw", "infra", true), "abc.elb.amazonaws.com") + listener := httpListenerFrom(gwv1.NamespacesFromAll) + _, err := ResolveHostname(f, "default", gw, &listener) + if err == nil { + t.Fatal("expected hard error for DNS-name gateway address, got nil") + } + if !strings.Contains(err.Error(), "--domain") { + t.Errorf("expected error to mention --domain, got: %v", err) + } + }) + + t.Run("no domain, no wildcard, no address is a hard error", func(t *testing.T) { + gw := newGateway("gw", "infra", true) + listener := httpListenerFrom(gwv1.NamespacesFromAll) + _, err := ResolveHostname(f, "default", gw, &listener) + if err == nil { + t.Fatal("expected hard error, got nil") + } + }) +} + +// TestGatewayExternalIP proves the IPv4/IPv6 sslip minting preference: +// IPv4 is always preferred when present, and an IPv6-only address is +// dash-encoded (never a raw colon, which is not a valid hostname character). +func TestGatewayExternalIP(t *testing.T) { + t.Run("IPv6 first, IPv4 later: IPv4 is preferred", func(t *testing.T) { + gw := withIPAddresses(newGateway("gw", "infra", true), "2a01:4f8::1", "172.18.0.5") + if got := gatewayExternalIP(gw); got != "172.18.0.5" { + t.Errorf("gatewayExternalIP() = %q, want the IPv4 address 172.18.0.5", got) + } + }) + + t.Run("IPv6-only mints a dash-encoded valid DNS label", func(t *testing.T) { + gw := withIPAddresses(newGateway("gw", "infra", true), "2a01:4f8::1") + got := gatewayExternalIP(gw) + if strings.Contains(got, ":") { + t.Fatalf("gatewayExternalIP() = %q, must not contain ':'", got) + } + if got != "2a01-4f8--1" { + t.Errorf("gatewayExternalIP() = %q, want 2a01-4f8--1", got) + } + }) + + t.Run("IPv6-only mints a full hostname with no colon end-to-end", func(t *testing.T) { + gw := withIPAddresses(newGateway("gw", "infra", true), "2a01:4f8::1") + listener := httpListenerFrom(gwv1.NamespacesFromAll) + f := fn.Function{Name: "myfunc"} + host, err := ResolveHostname(f, "default", gw, &listener) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(host, ":") { + t.Errorf("minted hostname %q must not contain ':'", host) + } + if host != "myfunc-default.2a01-4f8--1.sslip.io" { + t.Errorf("got %q, want myfunc-default.2a01-4f8--1.sslip.io", host) + } + }) + + t.Run("IPv4-mapped IPv6 address is canonicalized to dotted-quad", func(t *testing.T) { + gw := withIPAddresses(newGateway("gw", "infra", true), "::ffff:192.0.2.1") + got := gatewayExternalIP(gw) + if strings.Contains(got, ":") { + t.Fatalf("gatewayExternalIP() = %q, must not contain ':'", got) + } + if got != "192.0.2.1" { + t.Errorf("gatewayExternalIP() = %q, want 192.0.2.1", got) + } + }) +} + +// TestResolveHostname_MultiListenerUsesAdmittingListener proves selectListener() +// and ResolveHostname() operate on the SAME listener: with a non-admitting +// listener first and an admitting one second, the minted hostname must come +// from the second (admitting) listener, not the first. +func TestResolveHostname_MultiListenerUsesAdmittingListener(t *testing.T) { + nonAdmitting := withHostname(httpListenerFrom(gwv1.NamespacesFromSame), "*.not-admitting.example.com") + admitting := withHostname(httpListenerFrom(gwv1.NamespacesFromAll), "*.admitting.example.com") + gw := newGateway("gw", "infra", true, nonAdmitting, admitting) + + listener, err := selectListener(gw, "default", staticNSLabels(nil)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if listener == nil { + t.Fatal("expected an admitting listener, got nil") + } + + f := fn.Function{Name: "myfunc"} + host, err := ResolveHostname(f, "default", gw, listener) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if host != "myfunc-default.admitting.example.com" { + t.Errorf("got %q, want myfunc-default.admitting.example.com (minted from the admitting listener, not the first)", host) + } +} + +// TestSelectListener_UnevaluatableIsNonAdmitting proves an admission check +// failure (e.g. an RBAC error resolving a Selector) does not abort +// evaluation: it is treated as non-admitting and a later listener still gets +// a chance to admit. +func TestSelectListener_UnevaluatableIsNonAdmitting(t *testing.T) { + forbidden := httpListenerSelector(map[string]string{"team": "payments"}) + fallback := httpListenerFrom(gwv1.NamespacesFromAll) + gw := newGateway("gw", "infra", true, forbidden, fallback) + + forbiddenLabels := func() (labels.Set, error) { + return nil, apierrors.NewForbidden(corev1.Resource("namespaces"), "default", goerrors.New("rbac")) + } + + listener, err := selectListener(gw, "default", forbiddenLabels) + if err != nil { + t.Fatalf("unexpected error: %v (an RBAC error on one listener must not abort evaluation of later listeners)", err) + } + if listener == nil { + t.Fatal("expected the from:All listener to admit despite the earlier RBAC error") + } +} + +// TestSelectListener_NoListenerAdmitsSurfacesSwallowedError proves that when +// every listener fails to admit AND at least one admission check itself +// failed, that swallowed error surfaces as context rather than being +// silently dropped. +func TestSelectListener_NoListenerAdmitsSurfacesSwallowedError(t *testing.T) { + forbidden := httpListenerSelector(map[string]string{"team": "payments"}) + gw := newGateway("gw", "infra", true, forbidden) + + forbiddenLabels := func() (labels.Set, error) { + return nil, apierrors.NewForbidden(corev1.Resource("namespaces"), "default", goerrors.New("rbac")) + } + + listener, err := selectListener(gw, "default", forbiddenLabels) + if listener != nil { + t.Fatalf("expected no admitting listener, got %+v", listener) + } + if err == nil { + t.Fatal("expected the swallowed RBAC error to surface since no listener admitted") + } + if !strings.Contains(err.Error(), "rbac") { + t.Errorf("expected the swallowed error as context, got: %v", err) + } +} + +// TestSelectListener_HostnamePreference proves selectListener()'s hostname +// preference (M4): among admitting listeners, one with a usable (wildcard) +// hostname is preferred regardless of position, falling back to the first +// admitting listener (the IP/sslip path) only when none has one. +func TestSelectListener_HostnamePreference(t *testing.T) { + f := fn.Function{Name: "myfunc"} + getLabels := staticNSLabels(nil) + + t.Run("bare listener before wildcard mints from the wildcard", func(t *testing.T) { + bare := httpListenerFrom(gwv1.NamespacesFromAll) + wildcard := withHostname(httpListenerFrom(gwv1.NamespacesFromAll), "*.example.com") + gw := newGateway("gw", "infra", true, bare, wildcard) + + listener, err := selectListener(gw, "default", getLabels) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + host, err := ResolveHostname(f, "default", gw, listener) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if host != "myfunc-default.example.com" { + t.Errorf("got %q, want myfunc-default.example.com (from the wildcard, not the earlier bare listener)", host) + } + }) + + t.Run("wildcard-only", func(t *testing.T) { + wildcard := withHostname(httpListenerFrom(gwv1.NamespacesFromAll), "*.example.com") + gw := newGateway("gw", "infra", true, wildcard) + + listener, err := selectListener(gw, "default", getLabels) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + host, err := ResolveHostname(f, "default", gw, listener) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if host != "myfunc-default.example.com" { + t.Errorf("got %q, want myfunc-default.example.com", host) + } + }) + + t.Run("bare-only falls back to the first admitting listener (IP/sslip path)", func(t *testing.T) { + bare := httpListenerFrom(gwv1.NamespacesFromAll) + gw := withIPAddresses(newGateway("gw", "infra", true, bare), "172.18.0.5") + + listener, err := selectListener(gw, "default", getLabels) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + host, err := ResolveHostname(f, "default", gw, listener) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if host != "myfunc-default.172.18.0.5.sslip.io" { + t.Errorf("got %q, want myfunc-default.172.18.0.5.sslip.io", host) + } + }) + + t.Run("mixed order: wildcard before bare still mints from the wildcard", func(t *testing.T) { + wildcard := withHostname(httpListenerFrom(gwv1.NamespacesFromAll), "*.example.com") + bare := httpListenerFrom(gwv1.NamespacesFromAll) + gw := newGateway("gw", "infra", true, wildcard, bare) + + listener, err := selectListener(gw, "default", getLabels) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + host, err := ResolveHostname(f, "default", gw, listener) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if host != "myfunc-default.example.com" { + t.Errorf("got %q, want myfunc-default.example.com", host) + } + }) +} + +func newHTTPRoute(name, ns string, generation int64, parents ...gwv1.RouteParentStatus) *gwv1.HTTPRoute { + return &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns, Generation: generation}, + Status: gwv1.HTTPRouteStatus{RouteStatus: gwv1.RouteStatus{Parents: parents}}, + } +} + +func parentStatus(gwName, gwNamespace string, status metav1.ConditionStatus, reason, message string, observedGeneration int64) gwv1.RouteParentStatus { + var nsPtr *gwv1.Namespace + if gwNamespace != "" { + n := gwv1.Namespace(gwNamespace) + nsPtr = &n + } + return gwv1.RouteParentStatus{ + ParentRef: gwv1.ParentReference{Name: gwv1.ObjectName(gwName), Namespace: nsPtr}, + ControllerName: "example.com/controller", + Conditions: []metav1.Condition{ + { + Type: string(gwv1.RouteConditionAccepted), + Status: status, + Reason: reason, + Message: message, + ObservedGeneration: observedGeneration, + }, + }, + } +} + +func TestParentRefMatches(t *testing.T) { + // test that HTTPRoute holds correct GW info + gwKind := gwv1.Kind("Gateway") + svcKind := gwv1.Kind("Service") + gwGroup := gwv1.Group(gwv1.GroupName) + coreGroup := gwv1.Group("") + + tests := []struct { + name string + ref gwv1.ParentReference + want bool + }{ + {"unset Kind/Group defaults to Gateway", gwv1.ParentReference{Name: "gw"}, true}, + {"explicit Gateway Kind + gateway-api Group", gwv1.ParentReference{Name: "gw", Kind: &gwKind, Group: &gwGroup}, true}, + {"Service Kind is rejected", gwv1.ParentReference{Name: "gw", Kind: &svcKind}, false}, + {"core Group is rejected", gwv1.ParentReference{Name: "gw", Group: &coreGroup}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parentRefMatches(tt.ref, "gw", "default", "default"); got != tt.want { + t.Errorf("parentRefMatches(%+v) = %v, want %v", tt.ref, got, tt.want) + } + }) + } +} + +func TestRemoveManagedHTTPRoute(t *testing.T) { + ctx := context.Background() + + t.Run("route not found is a no-op", func(t *testing.T) { + gwFake := newGwFake() + removed, err := RemoveManagedHTTPRoute(ctx, gwFake, "default", "myfunc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if removed { + t.Error("expected removed=false when the route doesn't exist") + } + }) + + t.Run("managed route (label AND raw deployer annotation) is deleted", func(t *testing.T) { + gwFake := newGwFake() + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myfunc", Namespace: "default", + Labels: map[string]string{"boson.dev/function": "true"}, + Annotations: map[string]string{deployer.DeployerNameAnnotation: KubernetesDeployerName}, + }, + } + seedHTTPRoute(t, gwFake, route) + + removed, err := RemoveManagedHTTPRoute(ctx, gwFake, "default", "myfunc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !removed { + t.Error("expected removed=true for a func-managed route") + } + if _, err := gwFake.GatewayV1().HTTPRoutes("default").Get(ctx, "myfunc", metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Errorf("expected the route to be gone, got err=%v", err) + } + }) + + t.Run("label-only route (no deployer annotation) is now unmanaged and left in place", func(t *testing.T) { + gwFake := newGwFake() + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myfunc", Namespace: "default", + Labels: map[string]string{"boson.dev/function": "true"}, + }, + } + seedHTTPRoute(t, gwFake, route) + + removed, err := RemoveManagedHTTPRoute(ctx, gwFake, "default", "myfunc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if removed { + t.Error("expected removed=false: the label alone no longer proves func manages this route") + } + }) + + t.Run("foreign-annotation route (deployer=keda, with label) is now unmanaged and left in place", func(t *testing.T) { + gwFake := newGwFake() + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myfunc", Namespace: "default", + Labels: map[string]string{"boson.dev/function": "true"}, + Annotations: map[string]string{deployer.DeployerNameAnnotation: "keda"}, + }, + } + seedHTTPRoute(t, gwFake, route) + + removed, err := RemoveManagedHTTPRoute(ctx, gwFake, "default", "myfunc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if removed { + t.Error("expected removed=false: a foreign deployer annotation must not be treated as raw-managed") + } + }) + + t.Run("CRITICAL: unmanaged route with the same name is left in place", func(t *testing.T) { + gwFake := newGwFake() + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myfunc", Namespace: "default", + // No boson.dev/function label, no deployer annotation - + // e.g. a manually wired keda-interceptor route. + Labels: map[string]string{"app": "manual-keda-route"}, + }, + } + seedHTTPRoute(t, gwFake, route) + + removed, err := RemoveManagedHTTPRoute(ctx, gwFake, "default", "myfunc") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if removed { + t.Fatal("must NOT delete an unmanaged HTTPRoute that happens to share the function's name") + } + if _, err := gwFake.GatewayV1().HTTPRoutes("default").Get(ctx, "myfunc", metav1.GetOptions{}); err != nil { + t.Errorf("expected the unmanaged route to still exist, got err=%v", err) + } + }) +} + +func TestIsManagedHTTPRoute(t *testing.T) { + tests := []struct { + name string + route *gwv1.HTTPRoute + want bool + }{ + { + "label AND raw deployer annotation", + &gwv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"boson.dev/function": "true"}, + Annotations: map[string]string{deployer.DeployerNameAnnotation: "raw"}, + }}, + true, + }, + { + "boson.dev/function label only (no deployer annotation) is now unmanaged", + &gwv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"boson.dev/function": "true"}}}, + false, + }, + { + "raw deployer annotation only (no label) is now unmanaged", + &gwv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{deployer.DeployerNameAnnotation: "raw"}}}, + false, + }, + { + "label + foreign deployer annotation is unmanaged", + &gwv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"boson.dev/function": "true"}, + Annotations: map[string]string{deployer.DeployerNameAnnotation: "keda"}, + }}, + false, + }, + {"neither", &gwv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "something-else"}}}, false}, + {"no metadata at all", &gwv1.HTTPRoute{}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isManagedHTTPRoute(tt.route); got != tt.want { + t.Errorf("isManagedHTTPRoute() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestWaitForRouteAccepted(t *testing.T) { + ctx := context.Background() + + t.Run("Accepted=False fails immediately with reason and message", func(t *testing.T) { + route := newHTTPRoute("myfunc", "default", 1, + parentStatus("gw", "", metav1.ConditionFalse, "NotAllowedByListeners", "no listener admits this route", 1)) + gwFake := newGwFake() + seedHTTPRoute(t, gwFake, route) + + start := time.Now() + err := WaitForRouteAccepted(ctx, gwFake, "default", "myfunc", "gw", "default", 30*time.Second) + elapsed := time.Since(start) + if err == nil { + t.Fatal("expected error for Accepted=False, got nil") + } + if !strings.Contains(err.Error(), "NotAllowedByListeners") { + t.Errorf("expected error to include reason, got: %v", err) + } + if elapsed > 2*time.Second { + t.Errorf("Accepted=False should fail fast, took %s", elapsed) + } + }) + + t.Run("Accepted=True succeeds", func(t *testing.T) { + route := newHTTPRoute("myfunc", "default", 1, + parentStatus("gw", "", metav1.ConditionTrue, "Accepted", "ok", 1)) + gwFake := newGwFake() + seedHTTPRoute(t, gwFake, route) + + if err := WaitForRouteAccepted(ctx, gwFake, "default", "myfunc", "gw", "default", 30*time.Second); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("Accepted=Unknown then True succeeds (route still loading)", func(t *testing.T) { + gwFake := newGwFake() + seedHTTPRoute(t, gwFake, newHTTPRoute("myfunc", "default", 1, + parentStatus("gw", "", metav1.ConditionUnknown, "Pending", "waiting for controller", 1))) + + getCalls := 0 + gwFake.PrependReactor("get", "httproutes", func(action ktesting.Action) (bool, runtime.Object, error) { + getCalls++ + if getCalls == 1 { + return true, newHTTPRoute("myfunc", "default", 1, + parentStatus("gw", "", metav1.ConditionUnknown, "Pending", "waiting for controller", 1)), nil + } + return true, newHTTPRoute("myfunc", "default", 1, + parentStatus("gw", "", metav1.ConditionTrue, "Accepted", "ok", 1)), nil + }) + + if err := WaitForRouteAccepted(ctx, gwFake, "default", "myfunc", "gw", "default", 5*time.Second); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("Accepted=Unknown polls until timeout, not fail-fast", func(t *testing.T) { + route := newHTTPRoute("myfunc", "default", 1, + parentStatus("gw", "", metav1.ConditionUnknown, "Pending", "waiting for controller", 1)) + gwFake := newGwFake() + seedHTTPRoute(t, gwFake, route) + + err := WaitForRouteAccepted(ctx, gwFake, "default", "myfunc", "gw", "default", 0) + if err == nil { + t.Fatal("expected timeout error, got nil") + } + if !strings.Contains(err.Error(), "was not accepted") { + t.Errorf("expected the generic timeout message (Unknown must not fail fast), got: %v", err) + } + }) + + t.Run("parentRef with no namespace defaults to the route's own namespace", func(t *testing.T) { + // parentRef.Namespace unset ("") - must still match gwNamespace == route namespace. + route := newHTTPRoute("myfunc", "default", 1, + parentStatus("gw", "", metav1.ConditionTrue, "Accepted", "ok", 1)) + gwFake := newGwFake() + seedHTTPRoute(t, gwFake, route) + + if err := WaitForRouteAccepted(ctx, gwFake, "default", "myfunc", "gw", "default", 30*time.Second); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("parentRef from a different Gateway is ignored (foreign status)", func(t *testing.T) { + // Only a stale/foreign parent (different Gateway) reports status; + // the resolved Gateway "gw" has no entry yet - must time out, not + // false-succeed off the foreign parent. + route := newHTTPRoute("myfunc", "default", 1, + parentStatus("other-gw", "other-ns", metav1.ConditionTrue, "Accepted", "ok", 1)) + gwFake := newGwFake() + seedHTTPRoute(t, gwFake, route) + + err := WaitForRouteAccepted(ctx, gwFake, "default", "myfunc", "gw", "default", 0) + if err == nil { + t.Fatal("expected timeout error (foreign parent must not satisfy), got nil") + } + }) + + t.Run("stale observedGeneration is ignored (mid Gateway-switch race)", func(t *testing.T) { + // Status reflects an older spec generation than the route's current + // generation (e.g. a Gateway switch just happened) - must not be + // trusted even though it says Accepted=True. + route := newHTTPRoute("myfunc", "default", 2, + parentStatus("gw", "", metav1.ConditionTrue, "Accepted", "ok", 1)) + gwFake := newGwFake() + seedHTTPRoute(t, gwFake, route) + + err := WaitForRouteAccepted(ctx, gwFake, "default", "myfunc", "gw", "default", 0) + if err == nil { + t.Fatal("expected timeout error (stale generation must not satisfy), got nil") + } + }) + + t.Run("observedGeneration=0 (unreported) is accepted", func(t *testing.T) { + // Some controllers never populate ObservedGeneration (zero value) - + // a hard equality requirement would lock them out entirely, turning + // a real Accepted=True (or Accepted=False) into a blind timeout. + route := newHTTPRoute("myfunc", "default", 5, + parentStatus("gw", "", metav1.ConditionTrue, "Accepted", "ok", 0)) + gwFake := newGwFake() + seedHTTPRoute(t, gwFake, route) + + if err := WaitForRouteAccepted(ctx, gwFake, "default", "myfunc", "gw", "default", 30*time.Second); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("a Service-kind parent with the same name is ignored (mesh status)", func(t *testing.T) { + // A mesh implementation (e.g. Istio ambient) can report route status + // against a Service parent that happens to share the Gateway's name - + // must not be mistaken for our Gateway parent. + serviceKind := gwv1.Kind("Service") + coreGroup := gwv1.Group("") + parent := parentStatus("gw", "", metav1.ConditionTrue, "Accepted", "ok", 1) + parent.ParentRef.Kind = &serviceKind + parent.ParentRef.Group = &coreGroup + route := newHTTPRoute("myfunc", "default", 1, parent) + gwFake := newGwFake() + seedHTTPRoute(t, gwFake, route) + + err := WaitForRouteAccepted(ctx, gwFake, "default", "myfunc", "gw", "default", 0) + if err == nil { + t.Fatal("expected timeout error (Service-kind parent must not satisfy), got nil") + } + }) + + t.Run("no status times out", func(t *testing.T) { + route := newHTTPRoute("myfunc", "default", 1) + gwFake := newGwFake() + seedHTTPRoute(t, gwFake, route) + + err := WaitForRouteAccepted(ctx, gwFake, "default", "myfunc", "gw", "default", 0) + if err == nil { + t.Fatal("expected timeout error, got nil") + } + }) + + t.Run("context cancellation is honored", func(t *testing.T) { + route := newHTTPRoute("myfunc", "default", 1) + gwFake := newGwFake() + seedHTTPRoute(t, gwFake, route) + + cancelCtx, cancel := context.WithCancel(ctx) + cancel() + start := time.Now() + err := WaitForRouteAccepted(cancelCtx, gwFake, "default", "myfunc", "gw", "default", 30*time.Second) + elapsed := time.Since(start) + if err == nil { + t.Fatal("expected error from cancelled context, got nil") + } + if elapsed > 2*time.Second { + t.Errorf("cancelled context should return promptly, took %s", elapsed) + } + }) +} diff --git a/pkg/k8s/httproute.go b/pkg/k8s/httproute.go new file mode 100644 index 0000000000..b9dec2101e --- /dev/null +++ b/pkg/k8s/httproute.go @@ -0,0 +1,670 @@ +package k8s + +import ( + "context" + "fmt" + "net" + "os" + "sort" + "strings" + "sync" + "time" + + appsv1 "k8s.io/api/apps/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/util/retry" + "knative.dev/func/pkg/deployer" + fn "knative.dev/func/pkg/functions" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwclientset "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned" +) + +const ( + // gatewayAPIGroupVersion is checked via discovery to detect whether the + // Gateway API CRDs (any implementation) are installed on the cluster. + gatewayAPIGroupVersion = "gateway.networking.k8s.io/v1" + + // sslipDomain is the magic-DNS suffix used to mint a hostname directly + // from a Gateway's IP address, with no DNS configuration required. + sslipDomain = "sslip.io" + + // remediateNoGatewayController is the remediation text for the "no + // Gateway API CRDs installed" hard error. + remediateNoGatewayController = "Install a Gateway API implementation (e.g. Contour, Envoy Gateway, Istio), " + + "pass --expose=gateway:/ to use an existing Gateway, or deploy only cluster-local with --expose=none" +) + +// ParseGatewayRef splits a "namespace/name" or "namespace/" ref (the part of +// --expose=gateway:) into parts. name is empty for the trailing-slash +// form ("namespace/"), meaning auto-discovery should be restricted to that +// namespace rather than an exact Gateway being pinned; a Gateway name can +// never legitimately be empty, so the two forms cannot be confused. +func ParseGatewayRef(ref string) (namespace, name string, err error) { + namespace, name, found := strings.Cut(ref, "/") + if !found { + return "", "", fmt.Errorf( + "--expose=gateway:%s is not valid; use \"gateway:namespace/name\" to pin a Gateway or \"gateway:namespace/\" to restrict discovery to a namespace", ref) + } + if namespace == "" { + return "", "", fmt.Errorf( + "--expose=gateway:%s is not valid; namespace is required (\"gateway:namespace/name\" or \"gateway:namespace/\")", ref) + } + return namespace, name, nil +} + +// GatewayAPIAvailable checks if Gateway API CRDs are installed on the +// cluster, via a targeted discovery call for the exact group/version this +// package uses. A cluster that genuinely lacks the CRDs reports NotFound, +// which is reported as (false, nil); any other discovery error (RBAC, +// network, a broken aggregated APIService elsewhere) is a real error and +// must not be silently treated as "not installed". +func GatewayAPIAvailable(clientset kubernetes.Interface) (bool, error) { + resources, err := clientset.Discovery().ServerResourcesForGroupVersion(gatewayAPIGroupVersion) + if err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("failed to check for Gateway API availability: %w", err) + } + for _, r := range resources.APIResources { + if r.Kind == "HTTPRoute" { + return true, nil + } + } + return false, nil +} + +// ResolveGateway resolves the Gateway to attach a function's HTTPRoute to. +// +// - ref is "namespace/name": that exact Gateway is preflighted (exists, +// Programmed=True, a listener admits fnNamespace); any failed check is a +// hard error naming the check. +// - ref is "namespace/" (trailing slash): auto-discovery is restricted to +// that namespace, applying the same filters; exactly one match is used, +// zero or multiple is a hard error. +// - ref is "": Gateways are discovered cluster-wide (all namespaces) with +// the same filters; exactly one match is used (and announced), multiple +// is a hard error listing candidates, and zero is a hard error with +// remediation options (see noEligibleGatewayError()). +// +// getLabels supplies the function namespace's labels for Selector-based +// admission; see nsLabelGetter for the memoization contract. +func ResolveGateway(ctx context.Context, gwClient gwclientset.Interface, fnNamespace, ref string, getLabels nsLabelGetter) (*gwv1.Gateway, error) { + if ref != "" { + refNs, refName, perr := ParseGatewayRef(ref) + if perr != nil { + return nil, perr + } + + // exact Gateway match + if refName != "" { + gw, gerr := gwClient.GatewayV1().Gateways(refNs).Get(ctx, refName, metav1.GetOptions{}) + if gerr != nil { + if apierrors.IsNotFound(gerr) { + return nil, fmt.Errorf("--expose=gateway:%s/%s: Gateway does not exist", refNs, refName) + } + return nil, fmt.Errorf("--expose=gateway:%s/%s: failed to get Gateway: %w", refNs, refName, gerr) + } + if !isGatewayProgrammed(gw) { + return nil, fmt.Errorf("--expose=gateway:%s/%s: Gateway is not Programmed=True", refNs, refName) + } + listener, aerr := selectListener(gw, fnNamespace, getLabels) + if aerr != nil { + return nil, fmt.Errorf("--expose=gateway:%s/%s: %w", refNs, refName, aerr) + } + if listener == nil { + return nil, fmt.Errorf("--expose=gateway:%s/%s: no HTTP/HTTPS listener admits namespace %q", refNs, refName, fnNamespace) + } + return gw, nil + } + + // "namespace/" - restrict discovery to refNs. + candidates, cerr := listEligibleGateways(ctx, gwClient, refNs, fnNamespace, getLabels) + if cerr != nil { + return nil, cerr + } + switch len(candidates) { + case 0: + return nil, noEligibleGatewayError(refNs) + case 1: + return candidates[0], nil + default: + return nil, fmt.Errorf("multiple eligible Gateways in namespace %q: %s - pin one with --expose=gateway:/", + refNs, gatewayNames(candidates)) + } + } + + // Unset: cluster-wide discovery across all namespaces. + candidates, cerr := listEligibleGateways(ctx, gwClient, metav1.NamespaceAll, fnNamespace, getLabels) + if cerr != nil { + return nil, cerr + } + switch len(candidates) { + case 0: + return nil, noEligibleGatewayError("") + case 1: + gw := candidates[0] + fmt.Fprintf(os.Stderr, "using gateway %s/%s\n", gw.Namespace, gw.Name) + return gw, nil + default: + return nil, fmt.Errorf("multiple eligible Gateways found cluster-wide: %s - pin one with --expose=gateway:/", + gatewayNames(candidates)) + } +} + +func gatewayNames(candidates []*gwv1.Gateway) string { + names := make([]string, len(candidates)) + for i, gw := range candidates { + names[i] = fmt.Sprintf("%s/%s", gw.Namespace, gw.Name) + } + return strings.Join(names, ", ") +} + +// listEligibleGateways lists Gateways in 'ns' (when calling this function +// send metav1.NamespaceAll as 'ns' for cluster-wide) filtered to Programmed=True +// with an HTTP/HTTPS listener that admits fnNamespace. Results are sorted by +// namespace/name for deterministic error messages and single-match selection. +func listEligibleGateways(ctx context.Context, gwClient gwclientset.Interface, ns, fnNamespace string, getLabels nsLabelGetter) ([]*gwv1.Gateway, error) { + list, err := gwClient.GatewayV1().Gateways(ns).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list Gateways: %w", err) + } + + var candidates []*gwv1.Gateway + for i := range list.Items { + gw := &list.Items[i] + if !isGatewayProgrammed(gw) { + continue + } + listener, lerr := selectListener(gw, fnNamespace, getLabels) + if lerr != nil { + return nil, fmt.Errorf("gateway %s/%s: %w", gw.Namespace, gw.Name, lerr) + } + if listener != nil { + candidates = append(candidates, gw) + } + } + + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].Namespace != candidates[j].Namespace { + return candidates[i].Namespace < candidates[j].Namespace + } + return candidates[i].Name < candidates[j].Name + }) + + return candidates, nil +} + +func isGatewayProgrammed(gw *gwv1.Gateway) bool { + return meta.IsStatusConditionTrue(gw.Status.Conditions, string(gwv1.GatewayConditionProgrammed)) +} + +// selectListener is the single source of truth for both listener eligibility +// and hostname minting. It considers every listener on gw that is HTTP/HTTPS +// and allows the HTTPRoute kind (allowedRoutes.kinds, if set), and collects +// those that admit fnNamespace (allowedRoutes.namespaces). An admission check +// that itself fails (e.g. an RBAC error) is treated as NON-ADMITTING rather +// than aborting the scan, so a later listener still gets +// a chance - "no namespace RBAC unless a Selector decides the outcome". +// +// Eligibility is "at least one admitting listener"; nil, nil means none +// admitted and every admission check succeeded, while nil, err means none +// admitted and at least one admission check failed (surfaced as context for +// the caller's failure). Among admitting listeners, one bearing a usable +// (wildcard) hostname is preferred for minting - regardless of position - +// falling back to the first admitting listener (the IP/sslip path) +// otherwise. +func selectListener(gw *gwv1.Gateway, fnNamespace string, getLabels nsLabelGetter) (*gwv1.Listener, error) { + var admitting []*gwv1.Listener + var admitErr error + + for i := range gw.Spec.Listeners { + listener := &gw.Spec.Listeners[i] + if listener.Protocol != gwv1.HTTPProtocolType && listener.Protocol != gwv1.HTTPSProtocolType { + continue + } + if !listenerAllowsHTTPRouteKind(listener) { + continue + } + admits, err := admitsNamespace(listener, fnNamespace, gw.Namespace, getLabels) + // if err, don't fail to allow following listeners be potentially accepted + if err != nil { + if admitErr == nil { + admitErr = err + } + continue + } + if admits { + admitting = append(admitting, listener) + } + } + + if len(admitting) == 0 { + return nil, admitErr + } + for _, listener := range admitting { + // prefer listener that has wildcard hostname eg. "*.apps.com" + if listener.Hostname != nil && strings.HasPrefix(string(*listener.Hostname), "*.") { + return listener, nil + } + } + return admitting[0], nil +} + +// listenerAllowsHTTPRouteKind reports whether allowedRoutes.kinds (if set) +// includes HTTPRoute. An unset/empty kinds list means all kinds supported by +// the listener's protocol are allowed, which includes HTTPRoute for HTTP/HTTPS. +func listenerAllowsHTTPRouteKind(listener *gwv1.Listener) bool { + if listener.AllowedRoutes == nil || len(listener.AllowedRoutes.Kinds) == 0 { + return true + } + for _, k := range listener.AllowedRoutes.Kinds { + if k.Kind == "HTTPRoute" { + return true + } + } + return false +} + +// admitsNamespace evaluates allowedRoutes.namespaces.from for a listener: +// All admits any namespace; Same requires fnNamespace == gwNamespace; +// Selector matches fnNamespace's labels against the given LabelSelector. +func admitsNamespace(listener *gwv1.Listener, fnNamespace, gwNamespace string, getLabels nsLabelGetter) (bool, error) { + from := gwv1.NamespacesFromSame // Gateway API default + if listener.AllowedRoutes != nil && listener.AllowedRoutes.Namespaces != nil && listener.AllowedRoutes.Namespaces.From != nil { + from = *listener.AllowedRoutes.Namespaces.From + } + switch from { + case gwv1.NamespacesFromAll: + return true, nil + case gwv1.NamespacesFromSame: + return fnNamespace == gwNamespace, nil + case gwv1.NamespacesFromSelector: + // case says: listener admits routes from namespaces which match its + // selector eg. allowedRoutes.namespaces.selector.matchLabels {team: a} + if listener.AllowedRoutes.Namespaces.Selector == nil { + return false, nil + } + sel, err := metav1.LabelSelectorAsSelector(listener.AllowedRoutes.Namespaces.Selector) + if err != nil { + return false, fmt.Errorf("invalid allowedRoutes.namespaces.selector: %w", err) + } + nsLabels, err := getLabels() + if err != nil { + return false, err + } + return sel.Matches(nsLabels), nil + default: + return fnNamespace == gwNamespace, nil // safe default for unknown/future values + } +} + +// nsLabelGetter returns the labels of the function's namespace, consulted by +// Selector-based allowedRoutes admission checks. Implementations MUST be +// memoized - construct via newNSLabelGetter() - because admission may call +// the getter once per listener across every gateway in a resolution, and the +// exposure flow shares ONE getter across gateway resolution and hostname +// minting so both decide on a single label snapshot. A non-memoized getter +// would re-issue the namespace GET per listener and re-open the read-skew +// window between resolve and mint. +type nsLabelGetter func() (labels.Set, error) + +// newNSLabelGetter builds the canonical nsLabelGetter: a lazy, memoized +// fetch of ns's labels. No API call happens until the first invocation (so +// clusters that never hit a Selector admission never need "get namespaces" +// permission), and both the labels and any fetch error are memoized so the +// cluster is asked at most once per getter. +func newNSLabelGetter(ctx context.Context, clientset kubernetes.Interface, ns string) nsLabelGetter { + return sync.OnceValues(func() (labels.Set, error) { + nsObj, err := clientset.CoreV1().Namespaces().Get(ctx, ns, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get namespace %q for allowedRoutes selector match: %w", ns, err) + } + return labels.Set(nsObj.Labels), nil + }) +} + +// ResolveHostname determines the HTTPRoute hostname for a function, given the +// resolved Gateway and its admitting listener (as selected by +// ResolveGateway/selectListener() - the same listener admission is used for +// both eligibility and hostname minting). Chain (first match wins): +// 1. f.Domain set -> ".."; if the listener sets +// its own hostname, the minted hostname must intersect it, else a hard +// error (so two functions sharing --domain never collide). +// 2. Listener has a wildcard hostname "*.dom" -> "-.dom" (a single +// DNS label, which the wildcard matches). +// 3. Gateway has an IP address (net.ParseIP) -> "-..sslip.io", +// preferring IPv4; an IPv6-only address is dash-encoded for sslip.io +// (":" -> "-") since a raw colon is not a valid hostname character. +// A DNS-name address (e.g. a cloud LoadBalancer) cannot mint a subdomain. +// +// Anything else is a hard error asking for --domain. +func ResolveHostname(f fn.Function, namespace string, gw *gwv1.Gateway, listener *gwv1.Listener) (string, error) { + var listenerHostname string + if listener.Hostname != nil { + listenerHostname = string(*listener.Hostname) + } + + if f.Domain != "" { + hostname := fmt.Sprintf("%s.%s.%s", f.Name, namespace, f.Domain) + if listenerHostname != "" && !hostnameIntersects(listenerHostname, hostname) { + return "", fmt.Errorf( + "--domain %q mints hostname %q which does not match the Gateway %s/%s listener hostname %q", + f.Domain, hostname, gw.Namespace, gw.Name, listenerHostname) + } + return hostname, nil + } + + if strings.HasPrefix(listenerHostname, "*.") { + baseDomain := listenerHostname[2:] + return fmt.Sprintf("%s-%s.%s", f.Name, namespace, baseDomain), nil + } + + if ip := gatewayExternalIP(gw); ip != "" { + return fmt.Sprintf("%s-%s.%s", f.Name, namespace, ip+"."+sslipDomain), nil + } + + return "", fmt.Errorf( + "cannot determine a hostname for function %q: Gateway %s/%s has no wildcard listener hostname "+ + "and no IP address (its external address may be a DNS name, e.g. a cloud load balancer).\n\n"+ + "Set --domain explicitly to derive a hostname", f.Name, gw.Namespace, gw.Name) +} + +// hostnameIntersects reports whether candidate matches listenerHostname, +// either exactly or against a wildcard ("*.dom") pattern. +func hostnameIntersects(listenerHostname, candidate string) bool { + if listenerHostname == candidate { + return true + } + if strings.HasPrefix(listenerHostname, "*.") { + base := listenerHostname[2:] + return candidate == base || strings.HasSuffix(candidate, "."+base) + } + return false +} + +// gatewayExternalIP returns a status.addresses entry suitable for minting an +// sslip.io hostname, preferring the first IPv4 address; if only IPv6 +// addresses exist, the first is dash-encoded per sslip.io's IPv6 convention +// (every ":" becomes "-", e.g. "2a01:4f8::1" -> "2a01-4f8--1") since a raw +// colon is not a valid hostname character. Returns "" if no address parses +// as an IP (e.g. all addresses are DNS hostnames). +func gatewayExternalIP(gw *gwv1.Gateway) string { + var ipv6 string + for _, addr := range gw.Status.Addresses { + if addr.Value == "" { + continue + } + ip := net.ParseIP(addr.Value) + if ip == nil { + continue + } + if v4 := ip.To4(); v4 != nil { + // Canonicalize: an IPv4-mapped IPv6 address (e.g. + // "::ffff:192.0.2.1") parses as IPv4 here but addr.Value still + // contains colons - the dotted-quad form is what must be minted. + return v4.String() + } + if ipv6 == "" { + ipv6 = addr.Value + } + } + if ipv6 != "" { + return strings.ReplaceAll(ipv6, ":", "-") + } + return "" +} + +// GenerateHTTPRoute creates an HTTPRoute for a function. Labels/annotations +// mirror Deployment/Service generation: common labels plus decorator hooks, +// and common annotations (deployer name, user f.Deploy.Annotations, decorator +// hooks) via the same deployer.GenerateCommon* helpers. +func GenerateHTTPRoute(f fn.Function, svcName string, svcPort int32, hostname string, deployment *appsv1.Deployment, gw *gwv1.Gateway, decorator deployer.DeployDecorator, deployerName string) (*gwv1.HTTPRoute, error) { + labels, err := deployer.GenerateCommonLabels(f, decorator) + if err != nil { + return nil, err + } + annotations := deployer.GenerateCommonAnnotations(f, decorator, false /* dapr n/a for routing */, deployerName) + + parentRef := gwv1.ParentReference{Name: gwv1.ObjectName(gw.Name)} + if gw.Namespace != "" && gw.Namespace != deployment.Namespace { + ns := gwv1.Namespace(gw.Namespace) + parentRef.Namespace = &ns + } + + pathType := gwv1.PathMatchPathPrefix + pathValue := "/" + port := svcPort + + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: f.Name, + Namespace: deployment.Namespace, + Labels: labels, + Annotations: annotations, + OwnerReferences: []metav1.OwnerReference{ + *metav1.NewControllerRef(deployment, appsv1.SchemeGroupVersion.WithKind("Deployment")), + }, + }, + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{parentRef}, + }, + Hostnames: []gwv1.Hostname{gwv1.Hostname(hostname)}, + Rules: []gwv1.HTTPRouteRule{ + { + Matches: []gwv1.HTTPRouteMatch{ + {Path: &gwv1.HTTPPathMatch{Type: &pathType, Value: &pathValue}}, + }, + BackendRefs: []gwv1.HTTPBackendRef{ + { + BackendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{ + Name: gwv1.ObjectName(svcName), + Port: &port, + }, + }, + }, + }, + }, + }, + }, + } + + return route, nil +} + +// EnsureHTTPRoute creates or updates an HTTPRoute, retrying the whole +// get-mutate-update cycle on a 409 conflict (a controller status write can +// race an update from here). +func EnsureHTTPRoute(ctx context.Context, gwClient gwclientset.Interface, ns string, route *gwv1.HTTPRoute) error { + client := gwClient.GatewayV1().HTTPRoutes(ns) + name := route.Name + + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + existing, getErr := client.Get(ctx, name, metav1.GetOptions{}) + if getErr != nil { + if apierrors.IsNotFound(getErr) { + // A prior iteration's Update attempt (below) may have set + // route.ResourceVersion; the apiserver rejects a Create that + // carries one, so it must be cleared before every Create. + route.ResourceVersion = "" + _, createErr := client.Create(ctx, route, metav1.CreateOptions{}) + return createErr + } + return getErr + } + route.ResourceVersion = existing.ResourceVersion + _, updateErr := client.Update(ctx, route, metav1.UpdateOptions{}) + return updateErr + }) + if err != nil { + return fmt.Errorf("failed to ensure HTTPRoute %q: %w", name, err) + } + return nil +} + +// DeleteHTTPRoute removes an HTTPRoute if it exists. +func DeleteHTTPRoute(ctx context.Context, gwClient gwclientset.Interface, ns, name string) error { + err := gwClient.GatewayV1().HTTPRoutes(ns).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to delete HTTPRoute %q: %w", name, err) + } + return nil +} + +// isManagedHTTPRoute reports whether route was created by GenerateHTTPRoute() +// - as opposed to a user-authored or third-party HTTPRoute that happens to +// share the function's name, which must never be deleted out from under the +// user. Both signals are required: a bare boson.dev/function label, or a +// deployer annotation written by some other component, alone does not prove +// func's raw deployer owns the route. +func isManagedHTTPRoute(route *gwv1.HTTPRoute) bool { + return route.Labels["boson.dev/function"] == "true" && + route.Annotations[deployer.DeployerNameAnnotation] == KubernetesDeployerName +} + +// RemoveManagedHTTPRoute deletes the HTTPRoute named 'name' in 'ns' only if +// func owns it (isManagedHTTPRoute()). Returns (removed, error): +// - not found (route absent, or the CRDs aren't installed) -> (false, nil) +// - found but not managed -> (false, nil), warning printed, route kept +// - found and managed, deleted -> (true, nil) +func RemoveManagedHTTPRoute(ctx context.Context, gwClient gwclientset.Interface, ns, name string) (bool, error) { + route, err := gwClient.GatewayV1().HTTPRoutes(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("failed to check for existing HTTPRoute %q: %w", name, err) + } + + if !isManagedHTTPRoute(route) { + fmt.Fprintf(os.Stderr, + "⚠️ an HTTPRoute named %q exists in namespace %q but is not managed by func - leaving it in place\n", + name, ns) + return false, nil + } + + if err := DeleteHTTPRoute(ctx, gwClient, ns, name); err != nil { + return false, err + } + return true, nil +} + +// WaitForRouteAccepted polls the HTTPRoute status until the parent matching +// gwName/gwNamespace reports Accepted=True at the route's current +// generation. It fails immediately (not waiting out the full timeout) only +// when that parent reports Accepted=False, surfacing the condition's reason +// and message; Unknown (the route may still be loading into the cluster) is +// polled through to the timeout like an unreported condition. Stale statuses +// - from a different parent, or an old generation (e.g. a mid-flight Gateway +// switch) - are ignored rather than trusted. +func WaitForRouteAccepted(ctx context.Context, gwClient gwclientset.Interface, ns, name, gwName, gwNamespace string, timeout time.Duration) error { + var lastErr error + pollErr := wait.PollUntilContextTimeout(ctx, time.Second, timeout, true, func(ctx context.Context) (bool, error) { + route, err := gwClient.GatewayV1().HTTPRoutes(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + lastErr = fmt.Errorf("failed to get HTTPRoute %q: %w", name, err) + return false, nil + } + + for _, parent := range route.Status.Parents { + if !parentRefMatches(parent.ParentRef, gwName, gwNamespace, ns) { + continue + } + cond := meta.FindStatusCondition(parent.Conditions, string(gwv1.RouteConditionAccepted)) + // ObservedGeneration == 0 means the controller doesn't report + // it at all - a hard equality requirement would lock such + // implementations out entirely, turning a real Accepted=False + // into a blind timeout instead of a fail-fast. + if cond == nil || (cond.ObservedGeneration != route.Generation && cond.ObservedGeneration != 0) { + continue // not yet observed at the current spec generation + } + if cond.Status == metav1.ConditionTrue { + return true, nil + } + if cond.Status == metav1.ConditionFalse { + lastErr = fmt.Errorf("HTTPRoute %q was rejected by Gateway %s/%s: %s: %s", + name, gwNamespace, gwName, cond.Reason, cond.Message) + return false, lastErr + } + // ConditionUnknown: the controller hasn't reached a verdict yet + // (route still loading) - keep polling rather than failing fast. + continue + } + + return false, nil + }) + if pollErr != nil { + if lastErr != nil { + return lastErr + } + return fmt.Errorf("HTTPRoute %q was not accepted by Gateway %s/%s within %s: %w", name, gwNamespace, gwName, timeout, pollErr) + } + return nil +} + +// parentRefMatches reports whether ref (a status.parents[].parentRef) refers +// to the Gateway gwNamespace/gwName. A parentRef with no namespace set +// defaults to the route's own namespace per the Gateway API spec. Kind and +// Group are also checked (defaulting to "Gateway" and +// "gateway.networking.k8s.io" when unset, per spec): mesh implementations +// report status for Service parents too, and a bare name match against +// those would misattribute their verdict to our Gateway. +// simply: check that HTTPRoute holds correct 'parent' (GW) info +func parentRefMatches(ref gwv1.ParentReference, gwName, gwNamespace, routeNamespace string) bool { + if ref.Kind != nil && string(*ref.Kind) != "Gateway" { + return false + } + if ref.Group != nil && string(*ref.Group) != gwv1.GroupName { + return false + } + if string(ref.Name) != gwName { + return false + } + refNamespace := routeNamespace + if ref.Namespace != nil && *ref.Namespace != "" { + refNamespace = string(*ref.Namespace) + } + return refNamespace == gwNamespace +} + +// noEligibleGatewayError builds the actionable hard error returned when +// Gateway discovery (cluster-wide or namespace-restricted) finds zero +// eligible Gateways. The message hands the operator every remaining option: +// pin an existing Gateway, ask an administrator to create one (with a +// minimal ready-to-apply manifest), or opt out entirely. scope, if +// non-empty, names the namespace a restricted ("gateway:/") search was +// limited to; the cluster-wide head says "on this cluster" instead, so the +// message always names exactly the scope that was searched. +func noEligibleGatewayError(scope string) error { + head := "no eligible gateway found on this cluster" + if scope != "" { + head = fmt.Sprintf("no eligible gateway found in namespace %q", scope) + } + return fmt.Errorf( + "%s.\nOptions:\n"+ + " - pin an existing Gateway: func deploy --expose=gateway:/\n"+ + " - ask your cluster administrator to create one; minimal example:\n\n"+ + " apiVersion: gateway.networking.k8s.io/v1\n"+ + " kind: Gateway\n"+ + " metadata:\n"+ + " name: func-gateway\n"+ + " namespace: \n"+ + " spec:\n"+ + " gatewayClassName: \n"+ + " listeners:\n"+ + " - name: http\n"+ + " protocol: HTTP\n"+ + " port: 80\n"+ + " allowedRoutes:\n"+ + " namespaces:\n"+ + " from: All\n\n"+ + " - or deploy cluster-local only: func deploy --expose=none", + head) +} diff --git a/pkg/k8s/httproute_test.go b/pkg/k8s/httproute_test.go new file mode 100644 index 0000000000..640d91f075 --- /dev/null +++ b/pkg/k8s/httproute_test.go @@ -0,0 +1,189 @@ +package k8s + +import ( + "context" + goerrors "errors" + "fmt" + "testing" + + appsv1 "k8s.io/api/apps/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ktesting "k8s.io/client-go/testing" + fn "knative.dev/func/pkg/functions" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestGenerateHTTPRoute_Basic(t *testing.T) { + f := fn.Function{Name: "hello"} + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "hello", + Namespace: "default", + UID: types.UID("test-uid"), + }, + } + + route, err := GenerateHTTPRoute(f, "hello", 80, "hello.funcs.example.com", deployment, newGateway("func-gateway", "default", false), nil, KubernetesDeployerName) + if err != nil { + t.Fatal(err) + } + + if route.Name != "hello" { + t.Errorf("expected name hello, got %q", route.Name) + } + if route.Namespace != "default" { + t.Errorf("expected namespace default, got %q", route.Namespace) + } + + if len(route.Spec.Hostnames) != 1 || string(route.Spec.Hostnames[0]) != "hello.funcs.example.com" { + t.Errorf("expected hostname [hello.funcs.example.com], got %v", route.Spec.Hostnames) + } + + if len(route.Spec.ParentRefs) != 1 { + t.Fatalf("expected 1 parentRef, got %d", len(route.Spec.ParentRefs)) + } + ref := route.Spec.ParentRefs[0] + if string(ref.Name) != "func-gateway" { + t.Errorf("expected parentRef name func-gateway, got %v", ref.Name) + } + if ref.Namespace != nil { + t.Errorf("parentRef should not have namespace when Gateway is in same namespace, got %v", *ref.Namespace) + } + + ownerRefs := route.GetOwnerReferences() + if len(ownerRefs) != 1 { + t.Fatalf("expected 1 ownerRef, got %d", len(ownerRefs)) + } + if ownerRefs[0].Name != "hello" { + t.Errorf("expected ownerRef name hello, got %q", ownerRefs[0].Name) + } + if ownerRefs[0].Kind != "Deployment" { + t.Errorf("expected ownerRef kind Deployment, got %q", ownerRefs[0].Kind) + } + + if len(route.Spec.Rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(route.Spec.Rules)) + } + rule := route.Spec.Rules[0] + if len(rule.BackendRefs) != 1 { + t.Fatalf("expected 1 backendRef, got %d", len(rule.BackendRefs)) + } + backend := rule.BackendRefs[0] + if string(backend.Name) != "hello" { + t.Errorf("expected backendRef name hello, got %v", backend.Name) + } + if backend.Port == nil || *backend.Port != 80 { + t.Errorf("expected backendRef port 80, got %v", backend.Port) + } + + if route.Labels == nil { + t.Fatal("expected labels to be set") + } + if route.Labels["boson.dev/function"] != "true" { + t.Errorf("expected boson.dev/function=true label, got %v", route.Labels) + } +} + +func TestGenerateHTTPRoute_CrossNamespaceGateway(t *testing.T) { + f := fn.Function{Name: "hello"} + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "hello", + Namespace: "default", + UID: types.UID("test-uid"), + }, + } + + route, err := GenerateHTTPRoute(f, "hello", 80, "hello.example.com", deployment, newGateway("shared-gateway", "infra", false), nil, KubernetesDeployerName) + if err != nil { + t.Fatal(err) + } + + if len(route.Spec.ParentRefs) != 1 { + t.Fatalf("expected 1 parentRef, got %d", len(route.Spec.ParentRefs)) + } + ref := route.Spec.ParentRefs[0] + if string(ref.Name) != "shared-gateway" { + t.Errorf("expected parentRef name shared-gateway, got %v", ref.Name) + } + if ref.Namespace == nil || string(*ref.Namespace) != "infra" { + t.Errorf("expected parentRef namespace infra, got %v", ref.Namespace) + } +} + +func TestGenerateHTTPRoute_CommonAnnotations(t *testing.T) { + f := fn.Function{ + Name: "hello", + Deploy: fn.DeploySpec{ + Annotations: map[string]string{"custom.example.com/foo": "bar"}, + }, + } + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "hello", + Namespace: "default", + UID: types.UID("test-uid"), + }, + } + + route, err := GenerateHTTPRoute(f, "hello", 80, "hello.example.com", deployment, newGateway("gw", "default", false), nil, KubernetesDeployerName) + if err != nil { + t.Fatal(err) + } + + if route.Annotations["function.knative.dev/deployer"] != KubernetesDeployerName { + t.Errorf("expected deployer annotation %q, got %v", KubernetesDeployerName, route.Annotations) + } + if route.Annotations["custom.example.com/foo"] != "bar" { + t.Errorf("expected user annotation to be propagated, got %v", route.Annotations) + } +} + +// TestEnsureHTTPRoute_ClearsStaleResourceVersionOnCreateRetry proves that a +// first iteration that finds the route (Update) but hits a Conflict must +// not leak its ResourceVersion into a second iteration's Create after the +// route disappeared out from under it - the apiserver rejects a Create that +// carries one. Reactor sequence: Get(found)->Update(Conflict)->Get(NotFound)->Create. +func TestEnsureHTTPRoute_ClearsStaleResourceVersionOnCreateRetry(t *testing.T) { + ctx := context.Background() + gwFake := newGwFake() + + route := &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "myfunc", Namespace: "default"}, + } + + gr := schema.GroupResource{Group: gwv1.GroupName, Resource: "httproutes"} + + getCalls := 0 + gwFake.PrependReactor("get", "httproutes", func(action ktesting.Action) (bool, runtime.Object, error) { + getCalls++ + if getCalls == 1 { + // First iteration: the route exists, so EnsureHTTPRoute() takes the + // Update path and stamps route.ResourceVersion from this object. + return true, &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "myfunc", Namespace: "default", ResourceVersion: "1"}, + }, nil + } + // Second iteration: the route is gone (e.g. deleted concurrently) - + // EnsureHTTPRoute() must fall back to Create(). + return true, nil, apierrors.NewNotFound(gr, "myfunc") + }) + gwFake.PrependReactor("update", "httproutes", func(action ktesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewConflict(gr, "myfunc", goerrors.New("stale resourceVersion")) + }) + gwFake.PrependReactor("create", "httproutes", func(action ktesting.Action) (bool, runtime.Object, error) { + created := action.(ktesting.CreateAction).GetObject().(*gwv1.HTTPRoute) + if created.ResourceVersion != "" { + return true, nil, fmt.Errorf("Create must not carry a resourceVersion, got %q", created.ResourceVersion) + } + return false, nil, nil // fall through to the default tracker to actually store it + }) + + if err := EnsureHTTPRoute(ctx, gwFake, "default", route); err != nil { + t.Fatalf("expected EnsureHTTPRoute to succeed via the Create fallback, got: %v", err) + } +} diff --git a/pkg/k8s/lister.go b/pkg/k8s/lister.go index 82e0566f88..f3fb848409 100644 --- a/pkg/k8s/lister.go +++ b/pkg/k8s/lister.go @@ -77,12 +77,17 @@ func (l *Lister) get(ctx context.Context, clientset *kubernetes.Clientset, name, return fn.ListItem{}, fmt.Errorf("could not get service: %w", err) } + url := fmt.Sprintf("http://%s.%s.svc", service.Name, service.Namespace) // TODO: use correct scheme + if hostname, ok := service.Annotations[RouteHostnameAnnotation]; ok && hostname != "" { + url = fmt.Sprintf("http://%s", hostname) + } + runtimeLabel := "" listItem := fn.ListItem{ Name: service.Name, Namespace: service.Namespace, Runtime: runtimeLabel, - URL: fmt.Sprintf("http://%s.%s.svc", service.Name, service.Namespace), // TODO: use correct scheme + URL: url, Ready: string(ready), Deployer: KubernetesDeployerName, } diff --git a/pkg/keda/deployer.go b/pkg/keda/deployer.go index 72ac31cd01..decb34e179 100644 --- a/pkg/keda/deployer.go +++ b/pkg/keda/deployer.go @@ -36,6 +36,8 @@ func NewDeployer(opts ...DeployerOpt) *Deployer { Deployer: *k8s.NewDeployer( // init with the kedaDeployerDecorator to have the correct deployer labels&annotations k8s.WithDeployerDecorator(&kedaDeployerDecorator{}), + // keda functions stay behind the interceptor, never a Gateway + k8s.WithDeployerExposureDisabled(), ), } @@ -132,6 +134,9 @@ func (d *Deployer) Deploy(ctx context.Context, f fn.Function) (fn.DeploymentResu Status: deployResult.Status, URL: fmt.Sprintf("http://%s:8080", hosts[0]), // TODO: check on HTTPS too Namespace: deployResult.Namespace, + // The URL above is the in-cluster interceptor-bridge service, never + // externally reachable. + Exposed: false, }, nil } diff --git a/pkg/knative/deployer.go b/pkg/knative/deployer.go index 1bd011d142..e2bcf64cc5 100644 --- a/pkg/knative/deployer.go +++ b/pkg/knative/deployer.go @@ -297,6 +297,7 @@ consider using the --image-pull-secret flag, or setting up pull secrets manually Status: fn.Deployed, URL: route.Status.URL.String(), Namespace: namespace, + Exposed: true, }, nil } else { @@ -358,6 +359,7 @@ consider using the --image-pull-secret flag, or setting up pull secrets manually Status: fn.Updated, URL: route.Status.URL.String(), Namespace: namespace, + Exposed: true, }, nil } } diff --git a/pkg/mock/deployer.go b/pkg/mock/deployer.go index 456b8494e8..7906c5bc08 100644 --- a/pkg/mock/deployer.go +++ b/pkg/mock/deployer.go @@ -31,6 +31,9 @@ func NewDeployer() *Deployer { } else { err = errors.New("namespace required for initial deployment") } + if err == nil { + result.Status = fn.Deployed + } return }, } diff --git a/schema/func_yaml-schema.json b/schema/func_yaml-schema.json index 47e2e1b2b2..d254d514dc 100644 --- a/schema/func_yaml-schema.json +++ b/schema/func_yaml-schema.json @@ -130,6 +130,10 @@ "managementDisabled": { "type": "boolean", "description": "ManagementDisabled disables automatic creation/update of a Function CR\nfor operator management after deploy. The zero value (false) means\nthe function is managed by default when the func-operator is installed." + }, + "expose": { + "type": "string", + "description": "Expose controls external access for the raw deployer (other deployers\nwarn and ignore it). Optional; default to exposure for raw deployer.\nValues: \"gateway\", \"gateway:\u003cns\u003e/\" (namespace-scope discovery),\n\"gateway:\u003cns\u003e/\u003cname\u003e\" (pinned), \"none\" (cluster-local only).\nfunc creates only HTTPRoute resource, failure to expose fails the deploy." } }, "additionalProperties": false,