Repository: kubernetes-sigs/kubefed
Pull request #1377 (2021-03-16 12:35:04)

feat: add a proxy url field to kubefed clusters
<!--  Thanks for sending a pull request!  Here are some tips for you:
1. If this is your first time, read our contributor guidelines https://git.k8s.io/community/contributors/guide#your-first-contribution and developer guide https://git.k8s.io/community/contributors/devel/development.md#development-guide
2. If you want *faster* PR reviews, read how: https://git.k8s.io/community/contributors/guide/pull-requests.md#best-practices-for-faster-reviews
3. Add an entry to CHANGELOG.md if the PR represents a user-visible change.
4. If the PR is unfinished, see how to mark it: https://git.k8s.io/community/contributors/guide/pull-requests.md#marking-unfinished-pull-requests
-->

**What this PR does / why we need it**:

There are certain scenarios where a kubefed cluster can be behind a NAT gateway or DMZ. kubefed needs to be able to talk to these specific clusters using a proxy url. In addition, kubernetes supports (k8s > v1.19) the definition of a proxy-url field in the kubeconfig for the clients. This simplifies the communication against these clusters.

Kubefed clusters could require specifying a Proxy URL in addition to a CaBundle, therefore we should consider adding a ProxyURL field to the kubefed cluster object.


**Which issue(s) this PR fixes** *(optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)` format, will close the issue(s) when PR gets merged)*:
Fixes https://github.com/kubernetes-sigs/kubefed/issues/1376

**Special notes for your reviewer**:


k8s-ci-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/kubefed/pull/1377#" title="Author self-approved">hectorj2f</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fkubefed).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubernetes-sigs/kubefed/blob/master/OWNERS)~~ [hectorj2f]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
k8s-ci-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/kubefed/pull/1377#" title="Author self-approved">hectorj2f</a>*, *<a href="https://github.com/kubernetes-sigs/kubefed/pull/1377#pullrequestreview-613201941" title="Approved">jimmidyson</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fkubefed).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubernetes-sigs/kubefed/blob/master/OWNERS)~~ [hectorj2f,jimmidyson]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
k8s-ci-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/kubefed/pull/1377#" title="Author self-approved">hectorj2f</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fkubefed).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubernetes-sigs/kubefed/blob/master/OWNERS)~~ [hectorj2f]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
hectorj2f: @jimmidyson Yes, I agree. I'll add some tests to this PR to see how much we can test it without having a realistic infrastructure where to use a proxy.
k8s-ci-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/kubefed/pull/1377#" title="Author self-approved">hectorj2f</a>*, *<a href="https://github.com/kubernetes-sigs/kubefed/pull/1377#pullrequestreview-618040345" title="Approved">irfanurrehman</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fkubefed).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubernetes-sigs/kubefed/blob/master/OWNERS)~~ [hectorj2f,irfanurrehman]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/charts/kubefed/charts/controllermanager/crds/crds.yaml b/charts/kubefed/charts/controllermanager/crds/crds.yaml
index 9c77a3ac..98858fdd 100644
--- a/charts/kubefed/charts/controllermanager/crds/crds.yaml
+++ b/charts/kubefed/charts/controllermanager/crds/crds.yaml
@@ -503,6 +503,9 @@ spec:
                 items:
                   type: string
                 type: array
+              proxyURL:
+                description: ProxyURL allows to set proxy URL for the cluster.
+                type: string
               secretRef:
                 description: Name of the secret containing the token required to access
                   the member cluster. The secret needs to exist in the same namespace
diff --git a/go.mod b/go.mod
index 029155cb..09964448 100644
--- a/go.mod
+++ b/go.mod
@@ -17,6 +17,7 @@ require (
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/testify v1.6.1
 	go.uber.org/zap v1.16.0 // indirect
+	golang.org/x/net v0.0.0-20201110031124-69a78807bb2b
 	k8s.io/api v0.20.2
 	k8s.io/apiextensions-apiserver v0.20.2
 	k8s.io/apimachinery v0.20.2
diff --git a/pkg/apis/core/v1beta1/kubefedcluster_types.go b/pkg/apis/core/v1beta1/kubefedcluster_types.go
index 04da5748..c3c59bc9 100644
--- a/pkg/apis/core/v1beta1/kubefedcluster_types.go
+++ b/pkg/apis/core/v1beta1/kubefedcluster_types.go
@@ -51,6 +51,10 @@ type KubeFedClusterSpec struct {
 	// If * is specified, it is expected to be the only option in list.
 	// +optional
 	DisabledTLSValidations []TLSValidation `json:"disabledTLSValidations,omitempty"`
+
+	// ProxyURL allows to set proxy URL for the cluster.
+	// +optional
+	ProxyURL string `json:"proxyURL"`
 }
 
 // LocalSecretReference is a reference to a secret within the enclosing
diff --git a/pkg/apis/core/v1beta1/validation/validation.go b/pkg/apis/core/v1beta1/validation/validation.go
index 5b58d126..45ef4b19 100644
--- a/pkg/apis/core/v1beta1/validation/validation.go
+++ b/pkg/apis/core/v1beta1/validation/validation.go
@@ -18,6 +18,7 @@ package validation
 
 import (
 	"fmt"
+	"net/url"
 	"strconv"
 	"strings"
 	"time"
@@ -163,6 +164,9 @@ func validateKubeFedClusterSpec(spec *v1beta1.KubeFedClusterSpec, path *field.Pa
 	allErrs := validateAPIEndpoint(spec.APIEndpoint, path.Child("apiEndpoint"))
 	allErrs = append(allErrs, validateLocalSecretReference(&spec.SecretRef, path.Child("secretRef"))...)
 	allErrs = append(allErrs, validateDisabledTLSValidations(spec.DisabledTLSValidations, path.Child("disabledTLSValidations"))...)
+	if spec.ProxyURL != "" {
+		allErrs = append(allErrs, validateProxyURL(spec.ProxyURL, path.Child("proxyURL"))...)
+	}
 	return allErrs
 }
 
@@ -175,6 +179,21 @@ func validateKubeFedClusterStatus(status *v1beta1.KubeFedClusterStatus, path *fi
 	return allErrs
 }
 
+func validateProxyURL(proxyURL string, path *field.Path) field.ErrorList {
+	var allErrs field.ErrorList
+
+	u, err := url.Parse(proxyURL)
+	if err != nil {
+		allErrs = append(allErrs, field.Invalid(path, proxyURL, "error parsing the proxy URL"))
+	}
+	switch u.Scheme {
+	case "http", "https", "socks5":
+	default:
+		allErrs = append(allErrs, field.Invalid(path, proxyURL, "proxy URL scheme must be one of: [http, https, socks5]"))
+	}
+	return allErrs
+}
+
 func validateAPIEndpoint(endpoint string, path *field.Path) field.ErrorList {
 	if endpoint == "" {
 		return field.ErrorList{field.Required(path, "")}
diff --git a/pkg/controller/util/cluster_util.go b/pkg/controller/util/cluster_util.go
index 1ca1fef9..124ae87e 100644
--- a/pkg/controller/util/cluster_util.go
+++ b/pkg/controller/util/cluster_util.go
@@ -84,6 +84,14 @@ func BuildClusterConfig(fedCluster *fedv1b1.KubeFedCluster, client generic.Clien
 	clusterConfig.QPS = KubeAPIQPS
 	clusterConfig.Burst = KubeAPIBurst
 
+	if fedCluster.Spec.ProxyURL != "" {
+		proxyURL, err := url.Parse(fedCluster.Spec.ProxyURL)
+		if err != nil {
+			return nil, errors.Errorf("Failed to parse provided proxy URL %s: %v", fedCluster.Spec.ProxyURL, err)
+		}
+		clusterConfig.Proxy = http.ProxyURL(proxyURL)
+	}
+
 	if len(fedCluster.Spec.DisabledTLSValidations) != 0 {
 		klog.V(1).Infof("Cluster %s will use a custom transport for TLS certificate validation", fedCluster.Name)
 		if err = CustomizeTLSTransport(fedCluster, clusterConfig); err != nil {
diff --git a/pkg/kubefedctl/join.go b/pkg/kubefedctl/join.go
index 85e49053..9eedb976 100644
--- a/pkg/kubefedctl/join.go
+++ b/pkg/kubefedctl/join.go
@@ -276,8 +276,20 @@ func joinClusterForNamespace(hostConfig, clusterConfig *rest.Config, kubefedName
 		caBundle = clusterConfig.CAData
 	}
 
+	var proxyURL string
+	if clusterConfig.Proxy != nil {
+		url, err := clusterConfig.Proxy(nil)
+		if err != nil {
+			klog.V(2).Infof("Error getting proxy URL for host %s: %w", clusterConfig.Host, err)
+			return nil, errors.Errorf("failed to create proxy URL request for kubefed cluster: %v", err)
+		}
+		if url != nil {
+			proxyURL = url.String()
+		}
+	}
+
 	kubefedCluster, err := createKubeFedCluster(client, joiningClusterName, clusterConfig.Host,
-		secret.Name, kubefedNamespace, caBundle, disabledTLSValidations, dryRun, errorOnExisting)
+		secret.Name, kubefedNamespace, caBundle, disabledTLSValidations, proxyURL, dryRun, errorOnExisting)
 	if err != nil {
 		klog.V(2).Infof("Failed to create federated cluster resource: %v", err)
 		return nil, err
@@ -319,7 +331,7 @@ func performPreflightChecks(clusterClientset kubeclient.Interface, name, hostClu
 // the cluster and secret.
 func createKubeFedCluster(client genericclient.Client, joiningClusterName, apiEndpoint,
 	secretName, kubefedNamespace string, caBundle []byte, disabledTLSValidations []fedv1b1.TLSValidation,
-	dryRun, errorOnExisting bool) (*fedv1b1.KubeFedCluster, error) {
+	proxyURL string, dryRun, errorOnExisting bool) (*fedv1b1.KubeFedCluster, error) {
 	fedCluster := &fedv1b1.KubeFedCluster{
 		ObjectMeta: metav1.ObjectMeta{
 			Namespace: kubefedNamespace,
@@ -332,6 +344,7 @@ func createKubeFedCluster(client genericclient.Client, joiningClusterName, apiEn
 				Name: secretName,
 			},
 			DisabledTLSValidations: disabledTLSValidations,
+			ProxyURL:               proxyURL,
 		},
 	}
 


Test patch:
diff --git a/pkg/apis/core/v1beta1/kubefedcluster_types_test.go b/pkg/apis/core/v1beta1/kubefedcluster_types_test.go
new file mode 100644
index 00000000..e76df092
--- /dev/null
+++ b/pkg/apis/core/v1beta1/kubefedcluster_types_test.go
@@ -0,0 +1,97 @@
+/*
+Copyright 2021 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1beta1
+
+import (
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	"golang.org/x/net/context"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+)
+
+var _ = Describe("KubefedCluster", func() {
+	var (
+		key              types.NamespacedName
+		created, fetched *KubeFedCluster
+	)
+
+	Context("Create API", func() {
+		It("should create a kubefed cluster setting a proxy url", func() {
+
+			created = &KubeFedCluster{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "foo",
+					Namespace: metav1.NamespaceDefault,
+				},
+				Spec: KubeFedClusterSpec{
+					APIEndpoint: "https://my.example.com:80/path/to/endpoint",
+					ProxyURL:    "socks5://example.com",
+					SecretRef: LocalSecretReference{
+						Name: "foo",
+					},
+				},
+			}
+
+			key, _ = client.ObjectKeyFromObject(created)
+
+			By("creating an API obj")
+			Expect(k8sClient.Create(context.TODO(), created)).To(Succeed())
+
+			fetched = &KubeFedCluster{}
+			Expect(k8sClient.Get(context.TODO(), key, fetched)).To(Succeed())
+			Expect(fetched).To(Equal(created))
+
+			By("deleting the created object")
+			Expect(k8sClient.Delete(context.TODO(), created)).To(Succeed())
+			Expect(k8sClient.Get(context.TODO(), key, created)).ToNot(Succeed())
+		})
+
+		It("should create a kubefed cluster without a proxy url and an empty secret", func() {
+
+			created = &KubeFedCluster{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "foo",
+					Namespace: metav1.NamespaceDefault,
+				},
+				Spec: KubeFedClusterSpec{
+					APIEndpoint: "https://my.example.com:80/path/to/endpoint",
+					SecretRef: LocalSecretReference{
+						Name: "",
+					},
+				},
+			}
+
+			key, _ = client.ObjectKeyFromObject(created)
+
+			By("creating an API obj")
+			Expect(k8sClient.Create(context.TODO(), created)).To(Succeed())
+
+			fetched = &KubeFedCluster{}
+			Expect(k8sClient.Get(context.TODO(), key, fetched)).To(Succeed())
+			Expect(fetched).To(Equal(created))
+
+			By("deleting the created object")
+			Expect(k8sClient.Delete(context.TODO(), created)).To(Succeed())
+			Expect(k8sClient.Get(context.TODO(), key, created)).ToNot(Succeed())
+		})
+
+	})
+
+})
diff --git a/pkg/apis/core/v1beta1/suite_test.go b/pkg/apis/core/v1beta1/suite_test.go
new file mode 100644
index 00000000..020ea4b4
--- /dev/null
+++ b/pkg/apis/core/v1beta1/suite_test.go
@@ -0,0 +1,65 @@
+package v1beta1
+
+import (
+	"path/filepath"
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	"k8s.io/client-go/kubernetes/scheme"
+	"k8s.io/client-go/rest"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/envtest"
+	"sigs.k8s.io/controller-runtime/pkg/envtest/printer"
+	logf "sigs.k8s.io/controller-runtime/pkg/log"
+	"sigs.k8s.io/controller-runtime/pkg/log/zap"
+)
+
+var cfg *rest.Config
+var k8sClient client.Client
+var testEnv *envtest.Environment
+
+func TestAPIs(t *testing.T) {
+	RegisterFailHandler(Fail)
+
+	RunSpecsWithDefaultAndCustomReporters(t,
+		"v1beta1 Suite",
+		[]Reporter{printer.NewlineReporter{}})
+}
+
+var _ = BeforeSuite(func(done Done) {
+	logf.SetLogger(zap.New(func(o *zap.Options) {
+		o.Development = true
+		o.DestWritter = GinkgoWriter
+	}))
+
+	By("bootstrapping test environment")
+	testEnv = &envtest.Environment{
+		CRDInstallOptions: envtest.CRDInstallOptions{
+			ErrorIfPathMissing: true,
+			Paths: []string{
+				filepath.Join("..", "..", "..", "..", "charts", "kubefed", "charts", "controllermanager", "crds"),
+			},
+		},
+	}
+
+	err := SchemeBuilder.AddToScheme(scheme.Scheme)
+	Expect(err).NotTo(HaveOccurred())
+
+	cfg, err = testEnv.Start()
+	Expect(err).ToNot(HaveOccurred())
+	Expect(cfg).ToNot(BeNil())
+
+	k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
+	Expect(err).ToNot(HaveOccurred())
+	Expect(k8sClient).ToNot(BeNil())
+
+	close(done)
+}, 60)
+
+var _ = AfterSuite(func() {
+	By("tearing down the test environment")
+	err := testEnv.Stop()
+	Expect(err).ToNot(HaveOccurred())
+})
diff --git a/pkg/apis/core/v1beta1/validation/validation_test.go b/pkg/apis/core/v1beta1/validation/validation_test.go
index 3ddf72de..a6e6a350 100644
--- a/pkg/apis/core/v1beta1/validation/validation_test.go
+++ b/pkg/apis/core/v1beta1/validation/validation_test.go
@@ -480,6 +480,56 @@ func TestValidateAPIEndpoint(t *testing.T) {
 	}
 }
 
+func TestProxyURL(t *testing.T) {
+	tests := []struct {
+		proxyURL       string
+		expectedErrMsg string
+	}{
+		{
+			proxyURL: "socks5://example.com",
+		},
+		{
+			proxyURL: "https://example.com",
+		},
+		{
+			proxyURL: "http://example.com",
+		},
+		{
+			proxyURL:       "socks6://example.com",
+			expectedErrMsg: "proxyURL: Invalid value: \"socks6://example.com\": proxy URL scheme must be one of: [http, https, socks5]",
+		},
+		{
+			proxyURL:       "example.com",
+			expectedErrMsg: "proxyURL: Invalid value: \"example.com\": proxy URL scheme must be one of: [http, https, socks5]",
+		},
+		{
+			proxyURL:       "chewbacca@example.com",
+			expectedErrMsg: "proxyURL: Invalid value: \"chewbacca@example.com\": proxy URL scheme must be one of: [http, https, socks5]",
+		},
+	}
+
+	for _, test := range tests {
+		errs := validateProxyURL(test.proxyURL, field.NewPath("proxyURL"))
+		if len(errs) == 0 && test.expectedErrMsg == "" {
+			continue
+		}
+		if len(errs) == 0 && test.expectedErrMsg != "" {
+			t.Errorf("[%s] expected failure", test.expectedErrMsg)
+		} else {
+			matchedErr := false
+			for _, err := range errs {
+				if strings.Contains(err.Error(), test.expectedErrMsg) {
+					matchedErr = true
+					break
+				}
+			}
+			if !matchedErr {
+				t.Errorf("unexpected error: %v, expected: %q", errs, test.expectedErrMsg)
+			}
+		}
+	}
+}
+
 func TestValidateLocalSecretReference(t *testing.T) {
 	testCases := []struct {
 		secretName     string
diff --git a/pkg/kubefedctl/join_test.go b/pkg/kubefedctl/join_test.go
new file mode 100644
index 00000000..145fbc1e
--- /dev/null
+++ b/pkg/kubefedctl/join_test.go
@@ -0,0 +1,145 @@
+/*
+Copyright 2018 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package kubefedctl
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"net/http/httputil"
+	"net/url"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	corev1 "k8s.io/api/core/v1"
+	v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/kubernetes/scheme"
+	"k8s.io/client-go/rest"
+	utiltesting "k8s.io/client-go/util/testing"
+
+	"sigs.k8s.io/kubefed/pkg/kubefedctl/util"
+
+	"sigs.k8s.io/kubefed/pkg/apis/core/v1beta1"
+)
+
+var (
+	joiningClusterName = "joiningcluster"
+	hostClusterName    = "hostnamecluster"
+)
+
+var _ = Describe("Kubefedctl", func() {
+
+	Context("Join Cluster", func() {
+		It("should create a kubefed cluster successfully with a proxyURL", func() {
+			testServer, _, _ := testServerEnv(200)
+			defer testServer.Close()
+
+			clusterConfig := &rest.Config{
+				Host: testServer.URL,
+				ContentConfig: rest.ContentConfig{
+					GroupVersion:         &v1.SchemeGroupVersion,
+					NegotiatedSerializer: scheme.Codecs.WithoutConversion(),
+				},
+			}
+
+			expectedKubefedCluster := &v1beta1.KubeFedCluster{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      joiningClusterName,
+					Namespace: metav1.NamespaceDefault,
+				},
+				Spec: v1beta1.KubeFedClusterSpec{
+					APIEndpoint: testServer.URL,
+					SecretRef: v1beta1.LocalSecretReference{
+						Name: "secret",
+					},
+				},
+			}
+
+			kubefedCluster, err := joinClusterForNamespace(
+				cfg,
+				clusterConfig,
+				metav1.NamespaceDefault,
+				metav1.NamespaceDefault,
+				hostClusterName,
+				joiningClusterName,
+				"secret",
+				v1.ClusterScoped, true, false)
+
+			Expect(err).NotTo(HaveOccurred())
+			Expect(kubefedCluster).To(Equal(expectedKubefedCluster))
+
+			By("Using a joining cluster behind a proxy-url")
+			testProxyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+				to, err := url.Parse(req.RequestURI)
+				Expect(err).To(BeNil())
+				httputil.NewSingleHostReverseProxy(to).ServeHTTP(w, req)
+			}))
+			defer testProxyServer.Close()
+
+			u, err := url.Parse(testProxyServer.URL)
+			if err != nil {
+				Expect(err).To(BeNil())
+			}
+
+			// Set a fakeProxyServer as URL
+			clusterConfig.Proxy = http.ProxyURL(u)
+
+			expectedKubefedClusterWithProxyURL := &v1beta1.KubeFedCluster{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      joiningClusterName,
+					Namespace: metav1.NamespaceDefault,
+				},
+				Spec: v1beta1.KubeFedClusterSpec{
+					APIEndpoint: testServer.URL,
+					ProxyURL:    u.String(),
+				},
+			}
+
+			kubefedClusterWithProxyURL, err := joinClusterForNamespace(
+				cfg,
+				clusterConfig,
+				metav1.NamespaceDefault,
+				metav1.NamespaceDefault,
+				hostClusterName,
+				joiningClusterName,
+				"",
+				v1.ClusterScoped, true, false)
+
+			Expect(err).NotTo(HaveOccurred())
+			Expect(kubefedClusterWithProxyURL).To(Equal(expectedKubefedClusterWithProxyURL))
+
+		})
+	})
+})
+
+func testServerEnv(statusCode int) (*httptest.Server, *utiltesting.FakeHandler, *corev1.ServiceAccount) {
+	status := &corev1.ServiceAccount{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      util.ClusterServiceAccountName(joiningClusterName, hostClusterName),
+			Namespace: metav1.NamespaceDefault,
+		},
+	}
+	expectedBody, _ := runtime.Encode(scheme.Codecs.LegacyCodec(corev1.SchemeGroupVersion), status)
+	fakeHandler := utiltesting.FakeHandler{
+		StatusCode:   statusCode,
+		ResponseBody: string(expectedBody),
+	}
+	testServer := httptest.NewServer(&fakeHandler)
+	return testServer, &fakeHandler, status
+}
diff --git a/pkg/kubefedctl/suite_test.go b/pkg/kubefedctl/suite_test.go
new file mode 100644
index 00000000..4238069b
--- /dev/null
+++ b/pkg/kubefedctl/suite_test.go
@@ -0,0 +1,84 @@
+/*
+Copyright 2018 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+package kubefedctl
+
+import (
+	"path/filepath"
+	"testing"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/rest"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/envtest"
+	"sigs.k8s.io/controller-runtime/pkg/envtest/printer"
+	logf "sigs.k8s.io/controller-runtime/pkg/log"
+	"sigs.k8s.io/controller-runtime/pkg/log/zap"
+
+	"sigs.k8s.io/kubefed/pkg/apis/core/v1beta1"
+)
+
+var (
+	cfg          *rest.Config
+	k8sClient    client.Client
+	clientScheme *runtime.Scheme
+	testEnv      *envtest.Environment
+)
+
+func TestKubefedCTL(t *testing.T) {
+	RegisterFailHandler(Fail)
+	RunSpecsWithDefaultAndCustomReporters(t, "Kubefedctl Suite", []Reporter{printer.NewlineReporter{}})
+}
+
+var _ = BeforeSuite(func(done Done) {
+	logf.SetLogger(zap.New(func(o *zap.Options) {
+		o.Development = true
+		o.DestWritter = GinkgoWriter
+	}))
+
+	testEnv = &envtest.Environment{
+		CRDInstallOptions: envtest.CRDInstallOptions{
+			ErrorIfPathMissing: true,
+			Paths: []string{
+				filepath.Join("..", "..", "charts", "kubefed", "charts", "controllermanager", "crds"),
+			},
+		},
+	}
+
+	clientScheme = runtime.NewScheme()
+
+	Expect(v1beta1.AddToScheme(clientScheme)).To(Succeed())
+	Expect(corev1.AddToScheme(clientScheme)).To(Succeed())
+
+	var err error
+	cfg, err = testEnv.Start()
+	Expect(err).ToNot(HaveOccurred())
+	Expect(cfg).ToNot(BeNil())
+
+	k8sClient, err = client.New(cfg, client.Options{Scheme: clientScheme})
+	Expect(err).ToNot(HaveOccurred())
+	Expect(k8sClient).ToNot(BeNil())
+
+	close(done)
+}, 60)
+
+var _ = AfterSuite(func() {
+	By("tearing down the test environment")
+	err := testEnv.Stop()
+	Expect(err).ToNot(HaveOccurred())
+})