diff --git a/Makefile b/Makefile index 55580b4715..343dad0308 100644 --- a/Makefile +++ b/Makefile @@ -254,10 +254,11 @@ $(eval $(call install-sh,standard,operator-controller-standard.yaml)) .PHONY: test test: manifests generate fmt lint test-unit test-e2e test-regression #HELP Run all tests. -E2E_TIMEOUT ?= 10m +E2E_TIMEOUT ?= 12m +GODOG_ARGS ?= .PHONY: e2e e2e: #EXHELP Run the e2e tests. - go test -count=1 -v ./test/e2e/features_test.go -timeout=$(E2E_TIMEOUT) + go test -count=1 -v ./test/e2e/features_test.go -timeout=$(E2E_TIMEOUT) $(if $(GODOG_ARGS),-args $(GODOG_ARGS)) E2E_REGISTRY_NAME := docker-registry E2E_REGISTRY_NAMESPACE := operator-controller-e2e @@ -331,7 +332,7 @@ test-experimental-e2e: COVERAGE_NAME := experimental-e2e test-experimental-e2e: export MANIFEST := $(EXPERIMENTAL_RELEASE_MANIFEST) test-experimental-e2e: export INSTALL_DEFAULT_CATALOGS := false test-experimental-e2e: PROMETHEUS_VALUES := helm/prom_experimental.yaml -test-experimental-e2e: E2E_TIMEOUT := 15m +test-experimental-e2e: E2E_TIMEOUT := 17m test-experimental-e2e: run-internal image-registry prometheus e2e e2e-coverage kind-clean #HELP Run experimental e2e test suite on local kind cluster .PHONY: prometheus diff --git a/internal/shared/util/http/httputil.go b/internal/shared/util/http/httputil.go index f5a982d2de..5c5e060217 100644 --- a/internal/shared/util/http/httputil.go +++ b/internal/shared/util/http/httputil.go @@ -18,10 +18,13 @@ func BuildHTTPClient(cpw *CertPoolWatcher) (*http.Client, error) { RootCAs: pool, MinVersion: tls.VersionTLS12, } - tlsTransport := &http.Transport{ + httpClient.Transport = &http.Transport{ TLSClientConfig: tlsConfig, + // Proxy must be set explicitly; a nil Proxy field means "no proxy" and + // ignores HTTPS_PROXY/NO_PROXY env vars. Only http.DefaultTransport sets + // this by default; custom transports must opt in. + Proxy: http.ProxyFromEnvironment, } - httpClient.Transport = tlsTransport return httpClient, nil } diff --git a/internal/shared/util/http/httputil_test.go b/internal/shared/util/http/httputil_test.go new file mode 100644 index 0000000000..5bb2144870 --- /dev/null +++ b/internal/shared/util/http/httputil_test.go @@ -0,0 +1,203 @@ +package http_test + +import ( + "context" + "encoding/pem" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "sigs.k8s.io/controller-runtime/pkg/log" + + httputil "github.com/operator-framework/operator-controller/internal/shared/util/http" +) + +// startRecordingProxy starts a plain-HTTP CONNECT proxy that tunnels HTTPS +// connections and records the target host of each CONNECT request. +func startRecordingProxy(proxied chan<- string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + http.Error(w, "only CONNECT supported", http.StatusMethodNotAllowed) + return + } + // Non-blocking: if there are unexpected extra CONNECT requests (retries, + // parallel connections) we record the first one and drop the rest rather + // than blocking the proxy handler goroutine. + select { + case proxied <- r.Host: + default: + } + + dst, err := net.Dial("tcp", r.Host) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + defer dst.Close() + + hj, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "hijacking not supported", http.StatusInternalServerError) + return + } + conn, bufrw, err := hj.Hijack() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer conn.Close() + + if _, err = conn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")); err != nil { + return + } + + done := make(chan struct{}, 2) + tunnel := func(dst io.Writer, src io.Reader) { + defer func() { done <- struct{}{} }() + _, _ = io.Copy(dst, src) + // Half-close the write side so the other direction sees EOF and + // its io.Copy returns, preventing the goroutine from hanging. + if cw, ok := dst.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + } + } + // Use bufrw (not conn) as the client→dst source: Hijack may have + // buffered bytes (e.g. the TLS ClientHello) that arrived together with + // the CONNECT headers; reading from conn directly would lose them. + go tunnel(dst, bufrw) + go tunnel(conn, dst) + <-done + <-done // wait for both directions before closing connections + })) +} + +// certPoolWatcherForTLSServer creates a CertPoolWatcher that trusts the given +// TLS test server's certificate. +func certPoolWatcherForTLSServer(t *testing.T, server *httptest.Server) *httputil.CertPoolWatcher { + t.Helper() + + dir := t.TempDir() + certPath := filepath.Join(dir, "server.pem") + + certDER := server.TLS.Certificates[0].Certificate[0] + f, err := os.Create(certPath) + require.NoError(t, err) + require.NoError(t, pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})) + require.NoError(t, f.Close()) + + cpw, err := httputil.NewCertPoolWatcher(dir, log.FromContext(context.Background())) + require.NoError(t, err) + require.NotNil(t, cpw) + t.Cleanup(cpw.Done) + require.NoError(t, cpw.Start(context.Background())) + return cpw +} + +// TestBuildHTTPClientTransportUsesProxyFromEnvironment verifies that the +// transport returned by BuildHTTPClient has Proxy set to http.ProxyFromEnvironment +// so that HTTPS_PROXY and NO_PROXY env vars are honoured at runtime. +func TestBuildHTTPClientTransportUsesProxyFromEnvironment(t *testing.T) { + // Use system certs (empty dir) — we only need a valid CertPoolWatcher. + cpw, err := httputil.NewCertPoolWatcher("", log.FromContext(context.Background())) + require.NoError(t, err) + t.Cleanup(cpw.Done) + require.NoError(t, cpw.Start(context.Background())) + + client, err := httputil.BuildHTTPClient(cpw) + require.NoError(t, err) + + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok) + require.NotNil(t, transport.Proxy, + "BuildHTTPClient must set transport.Proxy so that HTTPS_PROXY env vars are respected; "+ + "a nil Proxy field means no proxy regardless of environment") +} + +// TestBuildHTTPClientProxyTunnelsConnections verifies end-to-end that the +// HTTP client produced by BuildHTTPClient correctly tunnels HTTPS connections +// through an HTTP CONNECT proxy. +// +// The test overrides transport.Proxy with http.ProxyURL rather than relying on +// HTTPS_PROXY: httptest servers bind to 127.0.0.1, which http.ProxyFromEnvironment +// silently excludes from proxying, and env-var changes within the same process +// are unreliable due to sync.Once caching. Using http.ProxyURL directly exercises +// the same tunnelling code path that HTTPS_PROXY triggers in production. +func TestBuildHTTPClientProxyTunnelsConnections(t *testing.T) { + targetServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer targetServer.Close() + + proxied := make(chan string, 1) + proxyServer := startRecordingProxy(proxied) + defer proxyServer.Close() + + proxyURL, err := url.Parse(proxyServer.URL) + require.NoError(t, err) + + cpw := certPoolWatcherForTLSServer(t, targetServer) + client, err := httputil.BuildHTTPClient(cpw) + require.NoError(t, err) + + // Point the transport directly at our test proxy, bypassing the loopback + // exclusion and env-var caching of http.ProxyFromEnvironment. + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok) + transport.Proxy = http.ProxyURL(proxyURL) + + resp, err := client.Get(targetServer.URL) + require.NoError(t, err) + resp.Body.Close() + + select { + case host := <-proxied: + require.Equal(t, targetServer.Listener.Addr().String(), host, + "proxy must have received a CONNECT request for the target server address") + case <-time.After(5 * time.Second): + t.Fatal("HTTPS connection to target server did not go through the proxy") + } +} + +// TestBuildHTTPClientProxyBlocksWhenRejected verifies that when the proxy +// rejects the CONNECT tunnel, the client request fails rather than silently +// falling back to a direct connection. +func TestBuildHTTPClientProxyBlocksWhenRejected(t *testing.T) { + targetServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer targetServer.Close() + + // A proxy that returns 403 Forbidden for every CONNECT request. + rejectingProxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + http.Error(w, "proxy access denied", http.StatusForbidden) + return + } + http.Error(w, "only CONNECT supported", http.StatusMethodNotAllowed) + })) + defer rejectingProxy.Close() + + proxyURL, err := url.Parse(rejectingProxy.URL) + require.NoError(t, err) + + cpw := certPoolWatcherForTLSServer(t, targetServer) + client, err := httputil.BuildHTTPClient(cpw) + require.NoError(t, err) + + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok) + transport.Proxy = http.ProxyURL(proxyURL) + + resp, err := client.Get(targetServer.URL) + if resp != nil { + resp.Body.Close() + } + require.Error(t, err, "request should fail when the proxy rejects the CONNECT tunnel") +} diff --git a/test/e2e/features/proxy.feature b/test/e2e/features/proxy.feature new file mode 100644 index 0000000000..c4ddcc091c --- /dev/null +++ b/test/e2e/features/proxy.feature @@ -0,0 +1,64 @@ +Feature: HTTPS proxy support for outbound catalog requests + + OLM's operator-controller fetches catalog data from catalogd over HTTPS. + When HTTPS_PROXY is set in the operator-controller's environment, all + outbound HTTPS requests must be routed through the configured proxy. + + Background: + Given OLM is available + And ClusterCatalog "test" serves bundles + And ServiceAccount "olm-sa" with needed permissions is available in test namespace + + @HTTPProxy + Scenario: operator-controller respects HTTPS_PROXY when fetching catalog data + Given the "operator-controller" component is configured with HTTPS_PROXY "http://127.0.0.1:39999" + When ClusterExtension is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterExtension + metadata: + name: ${NAME} + spec: + namespace: ${TEST_NAMESPACE} + serviceAccount: + name: olm-sa + source: + sourceType: Catalog + catalog: + packageName: test + selector: + matchLabels: + "olm.operatorframework.io/metadata.name": test-catalog + """ + Then ClusterExtension reports Progressing as True with Reason Retrying and Message includes: + """ + proxyconnect + """ + + @HTTPProxy + Scenario: operator-controller sends catalog requests through a configured HTTPS proxy + # The recording proxy runs on the host and cannot route to in-cluster service + # addresses, so it responds 502 after recording the CONNECT. This is + # intentional: the scenario only verifies that operator-controller respects + # HTTPS_PROXY and sends catalog fetches through the proxy, not that the full + # end-to-end request succeeds. + Given the "operator-controller" component is configured with HTTPS_PROXY pointing to a recording proxy + When ClusterExtension is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterExtension + metadata: + name: ${NAME} + spec: + namespace: ${TEST_NAMESPACE} + serviceAccount: + name: olm-sa + source: + sourceType: Catalog + catalog: + packageName: test + selector: + matchLabels: + "olm.operatorframework.io/metadata.name": test-catalog + """ + Then the recording proxy received a CONNECT request for the catalogd service diff --git a/test/e2e/steps/hooks.go b/test/e2e/steps/hooks.go index 556a99638d..d3951a9fe9 100644 --- a/test/e2e/steps/hooks.go +++ b/test/e2e/steps/hooks.go @@ -27,12 +27,15 @@ type resource struct { namespace string } -// deploymentRestore records the original container args of a deployment so that -// it can be patched back to its pre-test state during scenario cleanup. +// deploymentRestore records the original state of a deployment so it can be +// rolled back after a test that modifies deployment configuration. type deploymentRestore struct { - namespace string - deploymentName string - originalArgs []string + name string // deployment name + namespace string + containerName string // container to patch (for env var restores) + patchedArgs bool // true when container args were modified (for TLS profile patches) + originalArgs []string // original container args; may be nil if args were unset + originalEnv []string // original env vars as "NAME=VALUE" (for proxy patches) } type scenarioContext struct { @@ -47,8 +50,8 @@ type scenarioContext struct { metricsResponse map[string]string leaderPods map[string]string // component name -> leader pod name deploymentRestores []deploymentRestore - - extensionObjects []client.Object + extensionObjects []client.Object + proxy *recordingProxy } // GatherClusterExtensionObjects collects all resources related to the ClusterExtension container in @@ -192,19 +195,27 @@ func ScenarioCleanup(ctx context.Context, _ *godog.Scenario, err error) (context } } - // Always restore deployments whose args were modified during the scenario, - // even when the scenario failed, so that a misconfigured TLS profile does - // not leak into subsequent scenarios. Restore in reverse order so that - // multiple patches to the same deployment unwind back to the true original. + // Stop the in-process recording proxy if one was started. + if sc.proxy != nil { + sc.proxy.stop() + } + + // Restore any deployments that were modified during the scenario. Runs + // unconditionally (even on failure) to prevent a misconfigured deployment + // from bleeding into subsequent scenarios. Restored in LIFO order so that + // multiple patches to the same deployment unwind to the true original. for i := len(sc.deploymentRestores) - 1; i >= 0; i-- { dr := sc.deploymentRestores[i] - if err2 := patchDeploymentArgs(dr.namespace, dr.deploymentName, dr.originalArgs); err2 != nil { - logger.Info("Error restoring deployment args", "name", dr.deploymentName, "error", err2) - continue + if dr.patchedArgs { + if err2 := patchDeploymentArgs(dr.namespace, dr.name, dr.originalArgs); err2 != nil { + logger.Info("Error restoring deployment args", "name", dr.name, "error", err2) + } else if _, err2 := k8sClient("rollout", "status", "-n", dr.namespace, + fmt.Sprintf("deployment/%s", dr.name), "--timeout=2m"); err2 != nil { + logger.Info("Timeout waiting for deployment rollout after restore", "name", dr.name) + } } - if _, err2 := k8sClient("rollout", "status", "-n", dr.namespace, - fmt.Sprintf("deployment/%s", dr.deploymentName), "--timeout=2m"); err2 != nil { - logger.Info("Timeout waiting for deployment rollout after restore", "name", dr.deploymentName) + if err2 := restoreDeployment(dr); err2 != nil { + logger.Info("Error restoring deployment env", "deployment", dr.name, "namespace", dr.namespace, "error", err2) } } diff --git a/test/e2e/steps/proxy_steps.go b/test/e2e/steps/proxy_steps.go new file mode 100644 index 0000000000..d740ff5202 --- /dev/null +++ b/test/e2e/steps/proxy_steps.go @@ -0,0 +1,403 @@ +package steps + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "strings" + "sync" + "time" + + appsv1 "k8s.io/api/apps/v1" +) + +// --------------------------------------------------------------------------- +// recordingProxy — an in-process HTTP CONNECT proxy that tunnels connections +// and records the host of every CONNECT request it receives. +// --------------------------------------------------------------------------- + +type recordingProxy struct { + listener net.Listener + mu sync.Mutex + hosts []string +} + +func newRecordingProxy() (*recordingProxy, error) { + l, err := net.Listen("tcp", "0.0.0.0:0") //nolint:gosec // must bind to all interfaces so cluster pods can reach the host + if err != nil { + return nil, fmt.Errorf("failed to start recording proxy: %w", err) + } + p := &recordingProxy{listener: l} + go p.serve() + return p, nil +} + +func (p *recordingProxy) addr() string { + return p.listener.Addr().String() +} + +func (p *recordingProxy) port() (string, error) { + _, port, err := net.SplitHostPort(p.addr()) + if err != nil { + return "", fmt.Errorf("failed to parse proxy address %q: %w", p.addr(), err) + } + return port, nil +} + +func (p *recordingProxy) serve() { + for { + conn, err := p.listener.Accept() + if err != nil { + return // listener closed + } + go p.handle(conn) + } +} + +func (p *recordingProxy) handle(conn net.Conn) { + defer conn.Close() + + // Use a buffered reader so http.ReadRequest can parse the full request + // even if headers arrive across multiple TCP segments. + br := bufio.NewReader(conn) + req, err := http.ReadRequest(br) + if err != nil { + return + } + if req.Method != http.MethodConnect { + _, _ = conn.Write([]byte("HTTP/1.1 405 Method Not Allowed\r\n\r\n")) + return + } + target := req.Host + + p.mu.Lock() + p.hosts = append(p.hosts, target) + p.mu.Unlock() + + dst, err := (&net.Dialer{Timeout: 30 * time.Second}).Dial("tcp", target) + if err != nil { + _, _ = conn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) + return + } + defer dst.Close() + + _, _ = conn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")) + + // Tunnel traffic in both directions. Use br (not conn) as the source for + // the client→server direction so that any bytes buffered after the CONNECT + // headers are forwarded to the destination instead of being discarded. + done := make(chan struct{}, 2) + tunnel := func(dst io.Writer, src io.Reader) { + defer func() { done <- struct{}{} }() + _, _ = io.Copy(dst, src) + // Half-close the write side so the other direction sees EOF and + // its io.Copy returns, preventing the goroutine from hanging. + if cw, ok := dst.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + } + } + go tunnel(dst, br) + go tunnel(conn, dst) + <-done + <-done // wait for both directions to finish before closing connections +} + +func (p *recordingProxy) stop() { + _ = p.listener.Close() +} + +func (p *recordingProxy) recordedHosts() []string { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]string, len(p.hosts)) + copy(out, p.hosts) + return out +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// kindGatewayIP returns the gateway IP of the "kind" network for the +// configured container runtime, which is the address that pods inside the +// kind cluster use to reach the host machine. The runtime is read from the +// CONTAINER_RUNTIME environment variable; it defaults to "docker". +func kindGatewayIP() (string, error) { + runtime := os.Getenv("CONTAINER_RUNTIME") + if runtime == "" { + runtime = "docker" + } + // Range over all IPAM config entries rather than hard-coding index 0. + // Some kind setups place an IPv6 config at index 0 (with no Gateway) and + // the IPv4 config at index 1; indexing 0 directly would return empty. + out, err := exec.Command(runtime, "network", "inspect", "kind", //nolint:gosec + "--format", "{{range .IPAM.Config}}{{.Gateway}} {{end}}").Output() + if err != nil { + return "", fmt.Errorf("failed to inspect kind %s network: %w", runtime, err) + } + // Prefer the first valid IPv4 gateway. + for _, candidate := range strings.Fields(string(out)) { + if candidate == "" { + continue + } + if ip := net.ParseIP(candidate); ip != nil && ip.To4() != nil { + return candidate, nil + } + } + return "", fmt.Errorf("kind %s network has no IPv4 gateway configured", runtime) +} + +// kubernetesClusterIP returns the cluster IP of the "kubernetes" service in +// the "default" namespace, which is the address client-go uses to reach the +// API server from inside a pod (via the KUBERNETES_SERVICE_HOST env var). +func kubernetesClusterIP() (string, error) { + ip, err := k8sClient("get", "service", "kubernetes", "-n", "default", + "-o", "jsonpath={.spec.clusterIP}") + if err != nil { + return "", fmt.Errorf("failed to get kubernetes service cluster IP: %w", err) + } + return strings.TrimSpace(ip), nil +} + +// getDeploymentContainerEnv returns the environment variables for the named +// container in the given deployment, as a slice of "NAME=VALUE" strings. +func getDeploymentContainerEnv(deploymentName, namespace, containerName string) ([]string, error) { + raw, err := k8sClient("get", "deployment", deploymentName, "-n", namespace, "-o", "json") + if err != nil { + return nil, fmt.Errorf("failed to get deployment %s/%s: %w", namespace, deploymentName, err) + } + + var dep appsv1.Deployment + if err := json.Unmarshal([]byte(raw), &dep); err != nil { + return nil, fmt.Errorf("failed to unmarshal deployment: %w", err) + } + + for _, c := range dep.Spec.Template.Spec.Containers { + if c.Name == containerName { + env := make([]string, 0, len(c.Env)) + for _, e := range c.Env { + env = append(env, e.Name+"="+e.Value) + } + return env, nil + } + } + return nil, fmt.Errorf("container %q not found in deployment %s/%s", containerName, namespace, deploymentName) +} + +// setDeploymentEnvVars replaces the environment of the named container with +// the provided "NAME=VALUE" pairs and waits for the rollout to complete. +// It locates the container by name (rather than assuming index 0) and uses +// the JSON Patch "add" operation, which creates the env field if absent. +func setDeploymentEnvVars(deploymentName, namespace, containerName string, env []string) error { + // Fetch the deployment to find the container index. + raw, err := k8sClient("get", "deployment", deploymentName, "-n", namespace, "-o", "json") + if err != nil { + return fmt.Errorf("failed to get deployment %s/%s: %w", namespace, deploymentName, err) + } + var dep appsv1.Deployment + if err := json.Unmarshal([]byte(raw), &dep); err != nil { + return fmt.Errorf("failed to unmarshal deployment: %w", err) + } + idx := -1 + for i, c := range dep.Spec.Template.Spec.Containers { + if c.Name == containerName { + idx = i + break + } + } + if idx < 0 { + return fmt.Errorf("container %q not found in deployment %s/%s", containerName, namespace, deploymentName) + } + + type envVar struct { + Name string `json:"name"` + Value string `json:"value"` + } + envVars := make([]envVar, 0, len(env)) + for _, kv := range env { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid env var %q: must be NAME=VALUE", kv) + } + envVars = append(envVars, envVar{Name: parts[0], Value: parts[1]}) + } + + // Use "add" rather than "replace": "add" on an object key creates it if + // absent and overwrites it if present, so it works whether the container + // already has an env field or not. + patch := []map[string]interface{}{ + { + "op": "add", + "path": fmt.Sprintf("/spec/template/spec/containers/%d/env", idx), + "value": envVars, + }, + } + patchBytes, err := json.Marshal(patch) + if err != nil { + return fmt.Errorf("failed to marshal patch: %w", err) + } + + if _, err := k8sClient("patch", "deployment", deploymentName, "-n", namespace, + "--type=json", fmt.Sprintf("--patch=%s", string(patchBytes))); err != nil { + return fmt.Errorf("failed to patch deployment %s/%s: %w", namespace, deploymentName, err) + } + + if _, err := k8sClient("rollout", "status", "deployment", deploymentName, "-n", namespace, + "--timeout=5m"); err != nil { + return fmt.Errorf("rollout of deployment %s/%s did not complete: %w", namespace, deploymentName, err) + } + + return nil +} + +// addOrReplaceEnvVar adds a "NAME=VALUE" entry to env, replacing an existing +// entry with the same NAME if present. +func addOrReplaceEnvVar(env []string, name, value string) []string { + prefix := name + "=" + for i, kv := range env { + if strings.HasPrefix(kv, prefix) { + result := make([]string, len(env)) + copy(result, env) + result[i] = name + "=" + value + return result + } + } + return append(env, name+"="+value) +} + +// restoreDeployment restores a deployment to its state captured in a +// deploymentRestore record. It is called from ScenarioCleanup. +func restoreDeployment(r deploymentRestore) error { + if r.originalEnv == nil { + return nil + } + return setDeploymentEnvVars(r.name, r.namespace, r.containerName, r.originalEnv) +} + +// configureDeploymentProxy patches the named deployment to set HTTPS_PROXY to +// proxyURL and NO_PROXY to the Kubernetes API server cluster IP, then waits +// for the rollout. The original env is saved for cleanup. +func configureDeploymentProxy(ctx context.Context, component, proxyURL string) error { + sc := scenarioCtx(ctx) + + var deployName string + switch component { + case "operator-controller": + deployName = olmDeploymentName + case "catalogd": + deployName = "catalogd-controller-manager" + default: + return fmt.Errorf("unknown component %q", component) + } + + // Only record a restore entry the first time this deployment is patched in + // this scenario. Subsequent patches to the same deployment must not + // overwrite the saved original, otherwise cleanup would restore to an + // intermediate state instead of the true pre-scenario state. + alreadyTracked := false + for _, r := range sc.deploymentRestores { + if r.name == deployName && r.namespace == olmNamespace { + alreadyTracked = true + break + } + } + + origEnv, err := getDeploymentContainerEnv(deployName, olmNamespace, "manager") + if err != nil { + return err + } + + if !alreadyTracked { + sc.deploymentRestores = append(sc.deploymentRestores, deploymentRestore{ + name: deployName, + namespace: olmNamespace, + containerName: "manager", + originalEnv: origEnv, + }) + } + + // Exclude the Kubernetes API server from proxying so the controller can + // still reconcile resources. client-go connects to KUBERNETES_SERVICE_HOST + // which is the cluster IP of the "kubernetes" service — a plain IP, not a + // DNS name, so DNS-wildcard NO_PROXY entries won't match it. + k8sIP, err := kubernetesClusterIP() + if err != nil { + return err + } + + newEnv := addOrReplaceEnvVar(origEnv, "HTTPS_PROXY", proxyURL) + newEnv = addOrReplaceEnvVar(newEnv, "NO_PROXY", k8sIP) + return setDeploymentEnvVars(deployName, olmNamespace, "manager", newEnv) +} + +// --------------------------------------------------------------------------- +// Step functions +// --------------------------------------------------------------------------- + +// ConfigureDeploymentWithHTTPSProxy sets HTTPS_PROXY to a dead loopback +// address on the given deployment, proving that catalog fetches are blocked +// when the proxy is unreachable. +func ConfigureDeploymentWithHTTPSProxy(ctx context.Context, component, proxyURL string) error { + return configureDeploymentProxy(ctx, component, proxyURL) +} + +// StartRecordingProxyAndConfigureDeployment starts an in-process HTTP CONNECT +// proxy reachable from the cluster via the container-runtime kind network +// gateway, then patches the component deployment to route HTTPS through it. +func StartRecordingProxyAndConfigureDeployment(ctx context.Context, component string) error { + sc := scenarioCtx(ctx) + + p, err := newRecordingProxy() + if err != nil { + return err + } + sc.proxy = p + + port, err := p.port() + if err != nil { + return err + } + + gatewayIP, err := kindGatewayIP() + if err != nil { + return fmt.Errorf("cannot reach host from cluster: %w", err) + } + + proxyURL := fmt.Sprintf("http://%s:%s", gatewayIP, port) + logger.Info("Recording proxy started", "url", proxyURL) + + return configureDeploymentProxy(ctx, component, proxyURL) +} + +// RecordingProxyReceivedCONNECTForCatalogd polls until the recording proxy has +// received at least one CONNECT request whose target host contains "catalogd", +// or the polling timeout is reached. +// +// Note: the recording proxy runs on the host and cannot route to in-cluster +// service addresses, so it responds with 502 Bad Gateway after recording the +// CONNECT. This is intentional — the step only verifies that operator-controller +// respected HTTPS_PROXY and sent the request through the proxy. +func RecordingProxyReceivedCONNECTForCatalogd(ctx context.Context) error { + sc := scenarioCtx(ctx) + if sc.proxy == nil { + return fmt.Errorf("no recording proxy was started in this scenario") + } + + waitFor(ctx, func() bool { + for _, h := range sc.proxy.recordedHosts() { + if strings.Contains(h, "catalogd") { + logger.Info("Recording proxy confirmed CONNECT for catalogd", "host", h) + return true + } + } + return false + }) + + return nil +} diff --git a/test/e2e/steps/steps.go b/test/e2e/steps/steps.go index 9a6a63fa3b..1597e1d179 100644 --- a/test/e2e/steps/steps.go +++ b/test/e2e/steps/steps.go @@ -169,6 +169,10 @@ func RegisterSteps(sc *godog.ScenarioContext) { sc.Step(`^(?i)all (ClusterCatalog|ClusterExtension) resources are reconciled$`, allResourcesAreReconciled) sc.Step(`^(?i)(ClusterCatalog|ClusterExtension) is reconciled$`, ResourceTypeIsReconciled) sc.Step(`^(?i)ClusterCatalog reports ([[:alnum:]]+) as ([[:alnum:]]+) with Reason ([[:alnum:]]+)$`, ClusterCatalogReportsCondition) + + sc.Step(`^(?i)the "([^"]+)" component is configured with HTTPS_PROXY "([^"]+)"$`, ConfigureDeploymentWithHTTPSProxy) + sc.Step(`^(?i)the "([^"]+)" component is configured with HTTPS_PROXY pointing to a recording proxy$`, StartRecordingProxyAndConfigureDeployment) + sc.Step(`^(?i)the recording proxy received a CONNECT request for the catalogd service$`, RecordingProxyReceivedCONNECTForCatalogd) } func init() { diff --git a/test/e2e/steps/tls_steps.go b/test/e2e/steps/tls_steps.go index 8c35c25151..3842c9eacc 100644 --- a/test/e2e/steps/tls_steps.go +++ b/test/e2e/steps/tls_steps.go @@ -289,9 +289,10 @@ func configureDeploymentCustomTLS(ctx context.Context, component, version, ciphe sc := scenarioCtx(ctx) sc.deploymentRestores = append(sc.deploymentRestores, deploymentRestore{ - namespace: olmNamespace, - deploymentName: deploymentName, - originalArgs: origArgs, + name: deploymentName, + namespace: olmNamespace, + patchedArgs: true, + originalArgs: origArgs, }) newArgs := buildCustomTLSArgs(origArgs, version, ciphers, curves)