github.com/secure-build/gitlab-runner@v12.5.0+incompatible/executors/kubernetes/service_proxy_test.go (about)

     1  package kubernetes
     2  
     3  import (
     4  	"bytes"
     5  	"errors"
     6  	"io/ioutil"
     7  	"net/http"
     8  	"net/http/httptest"
     9  	"net/url"
    10  	"strconv"
    11  	"testing"
    12  
    13  	"github.com/gorilla/websocket"
    14  	"github.com/stretchr/testify/assert"
    15  	"github.com/stretchr/testify/require"
    16  	api "k8s.io/api/core/v1"
    17  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    18  	"k8s.io/apimachinery/pkg/runtime"
    19  	"k8s.io/apimachinery/pkg/runtime/schema"
    20  	"k8s.io/client-go/kubernetes"
    21  	restclient "k8s.io/client-go/rest"
    22  	"k8s.io/client-go/rest/fake"
    23  
    24  	"gitlab.com/gitlab-org/gitlab-runner/common"
    25  	"gitlab.com/gitlab-org/gitlab-runner/executors"
    26  	"gitlab.com/gitlab-org/gitlab-runner/session/proxy"
    27  )
    28  
    29  func TestPoolGetter(t *testing.T) {
    30  	pool := proxy.Pool{"test": &proxy.Proxy{Settings: fakeProxySettings()}}
    31  	ex := executor{
    32  		AbstractExecutor: executors.AbstractExecutor{
    33  			ProxyPool: pool,
    34  		},
    35  	}
    36  
    37  	assert.Equal(t, pool, ex.Pool())
    38  }
    39  
    40  func TestProxyRequestError(t *testing.T) {
    41  	version, codec := testVersionAndCodec()
    42  	objectInfo := metav1.ObjectMeta{Name: "test-pod", Namespace: "test-ns"}
    43  
    44  	tests := map[string]struct {
    45  		port            string
    46  		podStatus       api.PodPhase
    47  		containerReady  bool
    48  		expectedErrCode int
    49  	}{
    50  		"Invalid port number": {
    51  			port:            "81",
    52  			podStatus:       api.PodRunning,
    53  			expectedErrCode: http.StatusNotFound,
    54  		},
    55  		"Invalid port name": {
    56  			port:            "foobar",
    57  			podStatus:       api.PodRunning,
    58  			expectedErrCode: http.StatusNotFound,
    59  		},
    60  		"Pod is not ready yet": {
    61  			port:            "80",
    62  			podStatus:       api.PodPending,
    63  			expectedErrCode: http.StatusServiceUnavailable,
    64  		},
    65  		"Service containers are not ready yet": {
    66  			port:            "80",
    67  			podStatus:       api.PodRunning,
    68  			containerReady:  false,
    69  			expectedErrCode: http.StatusServiceUnavailable,
    70  		},
    71  	}
    72  
    73  	for name, test := range tests {
    74  		t.Run(name, func(t *testing.T) {
    75  			ex := executor{
    76  				pod: &api.Pod{ObjectMeta: objectInfo},
    77  				kubeClient: testKubernetesClient(version, fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
    78  					return mockPodRunningStatus(req, version, codec, objectInfo, test.podStatus, test.containerReady)
    79  				})),
    80  			}
    81  
    82  			h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    83  				ex.ProxyRequest(w, r, "", test.port, fakeProxySettings())
    84  			})
    85  
    86  			rw := httptest.NewRecorder()
    87  			req, err := http.NewRequest(http.MethodGet, "/", nil)
    88  			require.NoError(t, err)
    89  
    90  			h.ServeHTTP(rw, req)
    91  
    92  			resp := rw.Result()
    93  			assert.Equal(t, test.expectedErrCode, resp.StatusCode)
    94  		})
    95  	}
    96  }
    97  
    98  func fakeProxySettings() *proxy.Settings {
    99  	return &proxy.Settings{
   100  		ServiceName: "name",
   101  		Ports: []proxy.Port{
   102  			{
   103  				Number:   80,
   104  				Protocol: "http",
   105  				Name:     "port-name",
   106  			},
   107  		},
   108  	}
   109  }
   110  
   111  func TestProxyRequestHTTP(t *testing.T) {
   112  	version, codec := testVersionAndCodec()
   113  	objectInfo := metav1.ObjectMeta{Name: "test-pod", Namespace: "test-ns"}
   114  	defaultBody := "ACK"
   115  	defaultPort := "80"
   116  	defaultPortNumber, err := strconv.Atoi(defaultPort)
   117  	require.NoError(t, err)
   118  
   119  	serviceName := "service-name"
   120  	proxyEndpointURI := "/api/" + version + "/namespaces/" + objectInfo.Namespace + "/services/http:" + serviceName + ":" + defaultPort + "/proxy"
   121  	defaultProxySettings := proxy.Settings{
   122  		ServiceName: serviceName,
   123  		Ports: []proxy.Port{
   124  			{
   125  				Number:   defaultPortNumber,
   126  				Protocol: "http",
   127  			},
   128  		},
   129  	}
   130  
   131  	ex := executor{
   132  		pod: &api.Pod{ObjectMeta: objectInfo},
   133  	}
   134  
   135  	tests := map[string]struct {
   136  		podStatus          api.PodPhase
   137  		requestedURI       string
   138  		proxySettings      proxy.Settings
   139  		endpointURI        string
   140  		expectedBody       string
   141  		expectedStatusCode int
   142  	}{
   143  		"Returns error if the pod is not ready": {
   144  			podStatus:          api.PodPending,
   145  			proxySettings:      defaultProxySettings,
   146  			expectedBody:       "Service Unavailable\n",
   147  			expectedStatusCode: http.StatusServiceUnavailable,
   148  		},
   149  		"Returns error if invalid port protocol": {
   150  			podStatus: api.PodRunning,
   151  			proxySettings: proxy.Settings{
   152  				ServiceName: serviceName,
   153  				Ports: []proxy.Port{
   154  					{
   155  						Number:   defaultPortNumber,
   156  						Protocol: "whatever",
   157  					},
   158  				},
   159  			},
   160  			expectedBody:       "Service Unavailable\n",
   161  			expectedStatusCode: http.StatusServiceUnavailable,
   162  		},
   163  		"Handles HTTP requests": {
   164  			podStatus:          api.PodRunning,
   165  			proxySettings:      defaultProxySettings,
   166  			endpointURI:        proxyEndpointURI,
   167  			expectedBody:       defaultBody,
   168  			expectedStatusCode: http.StatusOK,
   169  		},
   170  		"Adds the requested URI to the proxy path": {
   171  			podStatus:          api.PodRunning,
   172  			requestedURI:       "foobar",
   173  			proxySettings:      defaultProxySettings,
   174  			endpointURI:        proxyEndpointURI + "/foobar",
   175  			expectedBody:       defaultBody,
   176  			expectedStatusCode: http.StatusOK,
   177  		},
   178  		"Uses the right protocol based on the proxy configuration": {
   179  			podStatus: api.PodRunning,
   180  			proxySettings: proxy.Settings{
   181  				ServiceName: serviceName,
   182  				Ports: []proxy.Port{
   183  					{
   184  						Number:   defaultPortNumber,
   185  						Protocol: "https",
   186  					},
   187  				},
   188  			},
   189  			endpointURI:        "/api/" + version + "/namespaces/" + objectInfo.Namespace + "/services/https:" + serviceName + ":" + defaultPort + "/proxy",
   190  			expectedBody:       defaultBody,
   191  			expectedStatusCode: http.StatusOK,
   192  		},
   193  	}
   194  
   195  	for name, test := range tests {
   196  		t.Run(name, func(t *testing.T) {
   197  			h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   198  				ex.ProxyRequest(w, r, test.requestedURI, defaultPort, &test.proxySettings)
   199  			})
   200  
   201  			ex.kubeClient = testKubernetesClient(version, fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
   202  				switch p, m := req.URL.Path, req.Method; {
   203  				case p == test.endpointURI && m == http.MethodGet:
   204  					return &http.Response{StatusCode: http.StatusOK, Body: ioutil.NopCloser(bytes.NewReader([]byte(defaultBody)))}, nil
   205  				default:
   206  					return mockPodRunningStatus(req, version, codec, objectInfo, test.podStatus, true)
   207  				}
   208  			}))
   209  
   210  			rw := httptest.NewRecorder()
   211  			req, err := http.NewRequest(http.MethodGet, "/", nil)
   212  			require.NoError(t, err)
   213  
   214  			h.ServeHTTP(rw, req)
   215  
   216  			resp := rw.Result()
   217  			defer resp.Body.Close()
   218  
   219  			b, err := ioutil.ReadAll(resp.Body)
   220  			require.NoError(t, err)
   221  			assert.Equal(t, test.expectedStatusCode, resp.StatusCode)
   222  			assert.Equal(t, test.expectedBody, string(b))
   223  		})
   224  	}
   225  }
   226  
   227  func TestProxyRequestHTTPError(t *testing.T) {
   228  	version, codec := testVersionAndCodec()
   229  	objectInfo := metav1.ObjectMeta{Name: "test-pod", Namespace: "test-ns"}
   230  
   231  	ex := executor{
   232  		pod: &api.Pod{ObjectMeta: objectInfo},
   233  	}
   234  
   235  	proxySettings := proxy.Settings{
   236  		ServiceName: "service-name",
   237  		Ports: []proxy.Port{
   238  			{
   239  				Number:   80,
   240  				Protocol: "http",
   241  			},
   242  		},
   243  	}
   244  
   245  	endpointURI := "/api/" + version + "/namespaces/" + objectInfo.Namespace + "/services/http:service-name:80/proxy"
   246  	errorMessage := "Error Message"
   247  
   248  	tests := map[string]struct {
   249  		expectedErrorCode int
   250  		expectedErrorMsg  string
   251  	}{
   252  		"Error is StatusServiceUnavailable": {
   253  			expectedErrorCode: http.StatusServiceUnavailable,
   254  			expectedErrorMsg:  "",
   255  		},
   256  		"Any other error": {
   257  			expectedErrorCode: http.StatusNotFound,
   258  			expectedErrorMsg:  errorMessage,
   259  		},
   260  	}
   261  
   262  	for name, test := range tests {
   263  		t.Run(name, func(t *testing.T) {
   264  			h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   265  				ex.ProxyRequest(w, r, "", "80", &proxySettings)
   266  			})
   267  
   268  			ex.kubeClient = testKubernetesClient(version, fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
   269  				switch p, m := req.URL.Path, req.Method; {
   270  				case p == endpointURI && m == http.MethodGet:
   271  					return &http.Response{StatusCode: test.expectedErrorCode, Body: ioutil.NopCloser(bytes.NewReader([]byte(errorMessage)))}, nil
   272  				default:
   273  					return mockPodRunningStatus(req, version, codec, objectInfo, api.PodRunning, true)
   274  				}
   275  			}))
   276  
   277  			rw := httptest.NewRecorder()
   278  			req, err := http.NewRequest(http.MethodGet, "/", nil)
   279  			require.NoError(t, err)
   280  
   281  			h.ServeHTTP(rw, req)
   282  
   283  			resp := rw.Result()
   284  			defer resp.Body.Close()
   285  
   286  			b, err := ioutil.ReadAll(resp.Body)
   287  			require.NoError(t, err)
   288  			assert.Equal(t, test.expectedErrorCode, resp.StatusCode)
   289  			assert.Equal(t, test.expectedErrorMsg, string(b))
   290  		})
   291  	}
   292  }
   293  
   294  func mockPodRunningStatus(req *http.Request, version string, codec runtime.Codec, objectInfo metav1.ObjectMeta, status api.PodPhase, servicesReady bool) (*http.Response, error) {
   295  	switch p, m := req.URL.Path, req.Method; {
   296  	case p == "/api/"+version+"/namespaces/"+objectInfo.Namespace+"/pods/"+objectInfo.Name && m == http.MethodGet:
   297  		pod := &api.Pod{
   298  			ObjectMeta: objectInfo,
   299  			Status: api.PodStatus{
   300  				Phase:             status,
   301  				ContainerStatuses: []api.ContainerStatus{{Ready: servicesReady}},
   302  			},
   303  		}
   304  		return &http.Response{StatusCode: http.StatusOK, Body: objBody(codec, pod), Header: map[string][]string{
   305  			"Content-Type": {"application/json"},
   306  		}}, nil
   307  	default:
   308  		return nil, errors.New("unexpected request")
   309  	}
   310  }
   311  
   312  func TestProxyRequestWebsockets(t *testing.T) {
   313  	version, codec := testVersionAndCodec()
   314  	objectInfo := metav1.ObjectMeta{Name: "test-pod", Namespace: "test-ns"}
   315  	defaultPort := "80"
   316  	defaultPortNumber, err := strconv.Atoi(defaultPort)
   317  	require.NoError(t, err)
   318  
   319  	serviceName := "service-name"
   320  	proxyEndpointURI := "/api/" + version + "/namespaces/" + objectInfo.Namespace + "/services/http:" + serviceName + ":" + defaultPort + "/proxy"
   321  	defaultProxySettings := proxy.Settings{
   322  		ServiceName: serviceName,
   323  		Ports: []proxy.Port{
   324  			{
   325  				Number:   defaultPortNumber,
   326  				Protocol: "http",
   327  			},
   328  		},
   329  	}
   330  
   331  	ex := executor{
   332  		AbstractExecutor: executors.AbstractExecutor{
   333  			Config: common.RunnerConfig{
   334  				RunnerSettings: common.RunnerSettings{
   335  					Kubernetes: &common.KubernetesConfig{
   336  						Host: "localhost",
   337  					},
   338  				},
   339  			},
   340  		},
   341  		configurationOverwrites: &overwrites{},
   342  		pod: &api.Pod{ObjectMeta: objectInfo},
   343  	}
   344  
   345  	tests := map[string]struct {
   346  		podStatus          api.PodPhase
   347  		requestedURI       string
   348  		proxySettings      proxy.Settings
   349  		endpointURI        string
   350  		expectedStatusCode int
   351  	}{
   352  		"Returns error if the service is not ready": {
   353  			podStatus:          api.PodPending,
   354  			proxySettings:      defaultProxySettings,
   355  			expectedStatusCode: http.StatusServiceUnavailable,
   356  		},
   357  		"Returns error if invalid port protocol": {
   358  			podStatus: api.PodRunning,
   359  			proxySettings: proxy.Settings{
   360  				ServiceName: serviceName,
   361  				Ports: []proxy.Port{
   362  					{
   363  						Number:   80,
   364  						Protocol: "whatever",
   365  					},
   366  				},
   367  			},
   368  			expectedStatusCode: http.StatusServiceUnavailable,
   369  		},
   370  		"Handles Websockets requests": {
   371  			podStatus:          api.PodRunning,
   372  			proxySettings:      defaultProxySettings,
   373  			endpointURI:        proxyEndpointURI,
   374  			expectedStatusCode: http.StatusSwitchingProtocols,
   375  		},
   376  		"Adds the requested URI to the proxy path": {
   377  			podStatus:          api.PodRunning,
   378  			requestedURI:       "foobar",
   379  			proxySettings:      defaultProxySettings,
   380  			endpointURI:        proxyEndpointURI + "/foobar",
   381  			expectedStatusCode: http.StatusSwitchingProtocols,
   382  		},
   383  		"Uses the right protocol based on the proxy configuration": {
   384  			podStatus: api.PodRunning,
   385  			proxySettings: proxy.Settings{
   386  				ServiceName: "service-name",
   387  				Ports: []proxy.Port{
   388  					{
   389  						Number:   80,
   390  						Protocol: "https",
   391  					},
   392  				},
   393  			},
   394  			endpointURI:        "/api/" + version + "/namespaces/" + objectInfo.Namespace + "/services/https:service-name:80/proxy",
   395  			expectedStatusCode: http.StatusSwitchingProtocols,
   396  		},
   397  	}
   398  
   399  	for name, test := range tests {
   400  		t.Run(name, func(t *testing.T) {
   401  			h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   402  				ex.ProxyRequest(w, r, r.URL.Path, defaultPort, &test.proxySettings)
   403  			})
   404  
   405  			// Mocked Kubernetes API server making the proxy request
   406  			kubeAPISrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   407  				assert.Equal(t, test.endpointURI, r.URL.Path)
   408  
   409  				upgrader := websocket.Upgrader{}
   410  				c, err := upgrader.Upgrade(w, r, nil)
   411  				require.NoError(t, err)
   412  
   413  				for {
   414  					mt, message, err := c.ReadMessage()
   415  					if err != nil {
   416  						break
   417  					}
   418  					err = c.WriteMessage(mt, message)
   419  					if err != nil {
   420  						break
   421  					}
   422  				}
   423  				defer c.Close()
   424  			}))
   425  			defer kubeAPISrv.Close()
   426  
   427  			ex.kubeClient = mockKubernetesClientWithHost(version, kubeAPISrv.Listener.Addr().String(), fake.CreateHTTPClient(func(req *http.Request) (*http.Response, error) {
   428  				return mockPodRunningStatus(req, version, codec, objectInfo, test.podStatus, true)
   429  			}))
   430  
   431  			// HTTP server
   432  			srv := httptest.NewServer(h)
   433  			defer srv.Close()
   434  
   435  			u := url.URL{
   436  				Scheme: "ws",
   437  				Host:   srv.Listener.Addr().String(),
   438  				Path:   test.requestedURI,
   439  			}
   440  
   441  			conn, resp, _ := websocket.DefaultDialer.Dial(u.String(), http.Header{})
   442  			defer func() {
   443  				if conn != nil {
   444  					_ = conn.Close()
   445  				}
   446  			}()
   447  
   448  			assert.Equal(t, test.expectedStatusCode, resp.StatusCode)
   449  
   450  			if resp.StatusCode == http.StatusSwitchingProtocols {
   451  				testMessage := "testmessage"
   452  				err := conn.WriteMessage(websocket.TextMessage, []byte(testMessage))
   453  				require.NoError(t, err)
   454  
   455  				_, p, err := conn.ReadMessage()
   456  				require.NoError(t, err)
   457  				assert.Equal(t, testMessage, string(p))
   458  			}
   459  		})
   460  	}
   461  }
   462  
   463  func mockKubernetesClientWithHost(version string, host string, httpClient *http.Client) *kubernetes.Clientset {
   464  	conf := restclient.Config{
   465  		Host: host,
   466  		ContentConfig: restclient.ContentConfig{
   467  			GroupVersion: &schema.GroupVersion{Version: version},
   468  		},
   469  	}
   470  	kube := kubernetes.NewForConfigOrDie(&conf)
   471  	fakeClient := fake.RESTClient{Client: httpClient}
   472  	kube.CoreV1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client
   473  	kube.ExtensionsV1beta1().RESTClient().(*restclient.RESTClient).Client = fakeClient.Client
   474  
   475  	return kube
   476  }