diff --git a/cmd/kubeadm/app/cmd/BUILD b/cmd/kubeadm/app/cmd/BUILD index 4ef8efa956b..0409f212eca 100644 --- a/cmd/kubeadm/app/cmd/BUILD +++ b/cmd/kubeadm/app/cmd/BUILD @@ -98,6 +98,7 @@ go_test( "//vendor/k8s.io/apimachinery/pkg/runtime:go_default_library", "//vendor/k8s.io/client-go/kubernetes/fake:go_default_library", "//vendor/k8s.io/client-go/testing:go_default_library", + "//vendor/k8s.io/client-go/tools/bootstrap/token/api:go_default_library", "//vendor/k8s.io/utils/exec:go_default_library", "//vendor/k8s.io/utils/exec/testing:go_default_library", ], diff --git a/cmd/kubeadm/app/cmd/token_test.go b/cmd/kubeadm/app/cmd/token_test.go index 10588af479a..e9d25088366 100644 --- a/cmd/kubeadm/app/cmd/token_test.go +++ b/cmd/kubeadm/app/cmd/token_test.go @@ -18,8 +18,17 @@ package cmd import ( "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "path/filepath" "regexp" + "strings" + "sync/atomic" "testing" + "time" "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -27,11 +36,47 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/fake" core "k8s.io/client-go/testing" + bootstrapapi "k8s.io/client-go/tools/bootstrap/token/api" kubeadmapiext "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm/v1alpha1" ) const ( TokenExpectedRegex = "^\\S{6}\\.\\S{16}\n$" + TestConfig = `apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: + server: localhost:8000 + name: prod +contexts: +- context: + cluster: prod + namespace: default + user: default-service-account + name: default +current-context: default +kind: Config +preferences: {} +users: +- name: kubernetes-admin + user: + client-certificate-data: + client-key-data: +` + TestConfigCertAuthorityData = "certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUN5RENDQWJDZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRFM01USXhOREUxTlRFek1Gb1hEVEkzTVRJeE1qRTFOVEV6TUZvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTlZrCnNkT0NjRDBIOG9ycXZ5djBEZ09jZEpjRGc4aTJPNGt3QVpPOWZUanJGRHJqbDZlVXRtdlMyZ1lZd0c4TGhPV2gKb0lkZ3AvbVkrbVlDakliUUJtTmE2Ums1V2JremhJRzM1c1lseE9NVUJJR0xXMzN0RTh4SlR1RVd3V0NmZnpLcQpyaU1UT1A3REF3MUxuM2xUNlpJNGRNM09NOE1IUk9Wd3lRMDVpbWo5eUx5R1lYdTlvSncwdTVXWVpFYmpUL3VpCjJBZ2QwVDMrZGFFb044aVBJOTlVQkQxMzRkc2VGSEJEY3hHcmsvVGlQdHBpSC9IOGoxRWZaYzRzTGlONzJmL2YKYUpacTROSHFiT2F5UkpITCtJejFNTW1DRkN3cjdHOHVENWVvWWp2dEdZN2xLc1pBTlUwK3VlUnJsTitxTzhQWQpxaTZNMDFBcmV1UzFVVHFuTkM4Q0F3RUFBYU1qTUNFd0RnWURWUjBQQVFIL0JBUURBZ0trTUE4R0ExVWRFd0VCCi93UUZNQU1CQWY4d0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFNbXo4Nm9LMmFLa0owMnlLSC9ZcTlzaDZZcDEKYmhLS25mMFJCaTA1clRacWdhTi9oTnROdmQxSzJxZGRLNzhIT2pVdkpNRGp3NERieXF0Wll2V01XVFRCQnQrSgpPMGNyWkg5NXlqUW42YzRlcU1FTjFhOUFKNXRlclNnTDVhREJsK0FMTWxaNVpxTzBUOUJDdTJtNXV3dGNWaFZuCnh6cGpTT3V5WVdOQ3A5bW9mV2VPUTljNXhEcElWeUlMUkFvNmZ5Z2c3N25TSDN4ckVmd0VKUHFMd1RPYVk1bTcKeEZWWWJoR3dxUGU5V0I5aTR5cnNrZUFBWlpUSzdVbklKMXFkRmlHQk9aZlRtaDhYQ3BOTHZZcFBLQW9hWWlsRwpjOW1acVhpWVlESTV6R1IxMElpc2FWNXJUY2hDenNQVWRhQzRVbnpTZG01cTdKYTAyb0poQlU1TE1FMD0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=" + TestConfigNoCluster = `apiVersion: v1 +clusters: +- cluster: + server: + name: prod +contexts: +- context: + namespace: default + user: default-service-account + name: default +kind: Config +preferences: {} +` ) func TestRunGenerateToken(t *testing.T) { @@ -46,7 +91,7 @@ func TestRunGenerateToken(t *testing.T) { matched, err := regexp.MatchString(TokenExpectedRegex, output) if err != nil { - t.Fatalf("encountered an error while trying to match RunGenerateToken's output: %v", err) + t.Fatalf("Encountered an error while trying to match RunGenerateToken's output: %v", err) } if !matched { t.Errorf("RunGenerateToken's output did not match expected regex; wanted: [%s], got: [%s]", TokenExpectedRegex, output) @@ -65,6 +110,7 @@ func TestRunCreateToken(t *testing.T) { token string usages []string extraGroups []string + printJoin bool expectedError bool }{ { @@ -123,10 +169,18 @@ func TestRunCreateToken(t *testing.T) { extraGroups: []string{"system:bootstrappers:foo"}, expectedError: true, }, + { + name: "invalid: print join command", + token: "", + usages: []string{"signing", "authentication"}, + extraGroups: []string{"system:bootstrappers:foo"}, + printJoin: true, + expectedError: true, + }, } for _, tc := range testCases { - cfg := &kubeadmapiext.MasterConfiguration{ + // KubernetesVersion is not used by bootstrap-token, but we set this explicitly to avoid // the lookup of the version from the internet when executing ConfigFileAndDefaultsToInternalConfig KubernetesVersion: "v1.9.0", @@ -136,9 +190,345 @@ func TestRunCreateToken(t *testing.T) { TokenGroups: tc.extraGroups, } - err := RunCreateToken(&buf, fakeClient, "", cfg, "", false, "") + err := RunCreateToken(&buf, fakeClient, "", cfg, "", tc.printJoin, "") if (err != nil) != tc.expectedError { t.Errorf("Test case %s: RunCreateToken expected error: %v, saw: %v", tc.name, tc.expectedError, (err != nil)) } } } + +func TestNewCmdTokenGenerate(t *testing.T) { + var buf bytes.Buffer + args := []string{} + + cmd := NewCmdTokenGenerate(&buf) + cmd.SetArgs(args) + + if err := cmd.Execute(); err != nil { + t.Errorf("Cannot execute token command: %v", err) + } +} + +func TestNewCmdToken(t *testing.T) { + var buf, bufErr bytes.Buffer + testConfigFile := "test-config-file" + + tmpDir, err := ioutil.TempDir("", "kubeadm-token-test") + if err != nil { + t.Errorf("Unable to create temporary directory: %v", err) + } + defer os.RemoveAll(tmpDir) + fullPath := filepath.Join(tmpDir, testConfigFile) + + f, err := os.Create(fullPath) + if err != nil { + t.Errorf("Unable to create test file %q: %v", fullPath, err) + } + defer f.Close() + + testCases := []struct { + name string + args []string + configToWrite string + expectedError bool + }{ + { + name: "valid: generate", + args: []string{"generate"}, + configToWrite: "", + expectedError: false, + }, + { + name: "valid: delete", + args: []string{"delete", "abcdef.1234567890123456", "--dry-run", "--kubeconfig=" + fullPath}, + configToWrite: TestConfig, + expectedError: false, + }, + } + + cmd := NewCmdToken(&buf, &bufErr) + for _, tc := range testCases { + if _, err = f.WriteString(tc.configToWrite); err != nil { + t.Errorf("Unable to write test file %q: %v", fullPath, err) + } + cmd.SetArgs(tc.args) + err := cmd.Execute() + if (err != nil) != tc.expectedError { + t.Errorf("Test case %q: NewCmdToken expected error: %v, saw: %v", tc.name, tc.expectedError, (err != nil)) + } + } +} + +func TestGetSecretString(t *testing.T) { + secret := v1.Secret{} + key := "test-key" + if str := getSecretString(&secret, key); str != "" { + t.Errorf("getSecretString() did not return empty string for a nil v1.Secret.Data") + } + secret.Data = make(map[string][]byte) + if str := getSecretString(&secret, key); str != "" { + t.Errorf("getSecretString() did not return empty string for missing v1.Secret.Data key") + } + secret.Data[key] = []byte("test-value") + if str := getSecretString(&secret, key); str == "" { + t.Errorf("getSecretString() failed for a valid v1.Secret.Data key") + } +} + +func TestGetClientset(t *testing.T) { + testConfigFile := "test-config-file" + + tmpDir, err := ioutil.TempDir("", "kubeadm-token-test") + if err != nil { + t.Errorf("Unable to create temporary directory: %v", err) + } + defer os.RemoveAll(tmpDir) + fullPath := filepath.Join(tmpDir, testConfigFile) + + // test dryRun = false on a non-exisiting file + if _, err = getClientset(fullPath, false); err == nil { + t.Errorf("getClientset(); dry-run: false; did no fail for test file %q: %v", fullPath, err) + } + + // test dryRun = true on a non-exisiting file + if _, err = getClientset(fullPath, true); err == nil { + t.Errorf("getClientset(); dry-run: true; did no fail for test file %q: %v", fullPath, err) + } + + f, err := os.Create(fullPath) + if err != nil { + t.Errorf("Unable to create test file %q: %v", fullPath, err) + } + defer f.Close() + + if _, err = f.WriteString(TestConfig); err != nil { + t.Errorf("Unable to write test file %q: %v", fullPath, err) + } + + // test dryRun = true on an exisiting file + if _, err = getClientset(fullPath, true); err != nil { + t.Errorf("getClientset(); dry-run: true; failed for test file %q: %v", fullPath, err) + } +} + +func TestRunDeleteToken(t *testing.T) { + var buf bytes.Buffer + + tmpDir, err := ioutil.TempDir("", "kubeadm-token-test") + if err != nil { + t.Errorf("Unable to create temporary directory: %v", err) + } + defer os.RemoveAll(tmpDir) + fullPath := filepath.Join(tmpDir, "test-config-file") + + f, err := os.Create(fullPath) + if err != nil { + t.Errorf("Unable to create test file %q: %v", fullPath, err) + } + defer f.Close() + + if _, err = f.WriteString(TestConfig); err != nil { + t.Errorf("Unable to write test file %q: %v", fullPath, err) + } + + client, err := getClientset(fullPath, true) + if err != nil { + t.Errorf("Unable to run getClientset() for test file %q: %v", fullPath, err) + } + + // test valid; should not fail + // for some reason Secrets().Delete() does not fail even for this dummy config + if err = RunDeleteToken(&buf, client, "abcdef.1234567890123456"); err != nil { + t.Errorf("RunDeleteToken() failed for a valid token: %v", err) + } + + // test invalid token; should fail + if err = RunDeleteToken(&buf, client, "invalid-token"); err == nil { + t.Errorf("RunDeleteToken() succeeded for an invalid token: %v", err) + } +} + +var httpTestItr uint32 +var httpSentResponse uint32 = 1 + +func TestRunListTokens(t *testing.T) { + var err error + var bufOut, bufErr bytes.Buffer + + tmpDir, err := ioutil.TempDir("", "kubeadm-token-test") + if err != nil { + t.Errorf("Unable to create temporary directory: %v", err) + } + defer os.RemoveAll(tmpDir) + fullPath := filepath.Join(tmpDir, "test-config-file") + + f, err := os.Create(fullPath) + if err != nil { + t.Errorf("Unable to create test file %q: %v", fullPath, err) + } + defer f.Close() + + // test config without secrets; should fail + if _, err = f.WriteString(TestConfig); err != nil { + t.Errorf("Unable to write test file %q: %v", fullPath, err) + } + + client, err := getClientset(fullPath, true) + if err != nil { + t.Errorf("Unable to run getClientset() for test file %q: %v", fullPath, err) + } + + if err = RunListTokens(&bufOut, &bufErr, client); err == nil { + t.Errorf("RunListTokens() did not fail for a config without secrets: %v", err) + } + + // test config without secrets but use a dummy API server that returns secrets + portString := "9008" + http.HandleFunc("/", httpHandler) + httpServer := &http.Server{Addr: "localhost:" + portString} + go func() { + err := httpServer.ListenAndServe() + if err != nil { + t.Errorf("Failed to start dummy API server: localhost:%s", portString) + } + }() + + fmt.Printf("dummy API server listening on localhost:%s\n", portString) + testConfigOpenPort := strings.Replace(TestConfig, "server: localhost:8000", "server: localhost:"+portString, -1) + + if _, err = f.WriteString(testConfigOpenPort); err != nil { + t.Errorf("Unable to write test file %q: %v", fullPath, err) + } + + client, err = getClientset(fullPath, true) + if err != nil { + t.Errorf("Unable to run getClientset() for test file %q: %v", fullPath, err) + } + + // the order of these tests should match the case check + // for httpTestItr in httpHandler + testCases := []struct { + name string + expectedError bool + }{ + { + name: "token-id not defined", + expectedError: true, + }, + { + name: "secret name not formatted correctly", + expectedError: true, + }, + { + name: "token-secret not defined", + expectedError: true, + }, + { + name: "token expiration not formatted correctly", + expectedError: true, + }, + { + name: "token expiration formatted correctly", + expectedError: false, + }, + { + name: "token usage constant not true", + expectedError: false, + }, + { + name: "token usage constant set to true", + expectedError: false, + }, + } + for _, tc := range testCases { + bufErr.Reset() + atomic.StoreUint32(&httpSentResponse, 0) + fmt.Printf("Running HTTP test case (%d) %q\n", atomic.LoadUint32(&httpTestItr), tc.name) + // should always return nil here if a valid list of secrets if fetched + err := RunListTokens(&bufOut, &bufErr, client) + if err != nil { + t.Errorf("HTTP test case %d: Was unable to fetch a list of secrets", atomic.LoadUint32(&httpTestItr)) + } + // wait for a response from the dummy HTTP server + timeSpent := 0 * time.Millisecond + timeToSleep := 50 * time.Millisecond + timeMax := 2000 * time.Millisecond + for { + if atomic.LoadUint32(&httpSentResponse) == 1 { + break + } + if timeSpent >= timeMax { + t.Errorf("HTTP test case %d: The server did not respond within %d ms", atomic.LoadUint32(&httpTestItr), timeMax) + } + timeSpent += timeToSleep + time.Sleep(timeToSleep) + } + // check if an error is written in the error buffer + hasError := bufErr.Len() != 0 + if hasError != tc.expectedError { + t.Errorf("HTTP test case %d: RunListTokens expected error: %v, saw: %v; %v", atomic.LoadUint32(&httpTestItr), tc.expectedError, hasError, bufErr.String()) + } + } +} + +// only one of these should run at a time in a goroutine +func httpHandler(w http.ResponseWriter, r *http.Request) { + tokenID := []byte("07401b") + tokenSecret := []byte("f395accd246ae52d") + tokenExpire := []byte("2012-11-01T22:08:41+00:00") + badValue := "bad-value" + name := bootstrapapi.BootstrapTokenSecretPrefix + string(tokenID) + tokenUsageKey := bootstrapapi.BootstrapTokenUsagePrefix + "test" + + secret := v1.Secret{} + secret.Type = bootstrapapi.SecretTypeBootstrapToken + secret.TypeMeta = metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"} + secret.Data = map[string][]byte{} + + switch atomic.LoadUint32(&httpTestItr) { + case 0: + secret.Data[bootstrapapi.BootstrapTokenIDKey] = []byte("") + case 1: + secret.Data[bootstrapapi.BootstrapTokenIDKey] = tokenID + secret.ObjectMeta = metav1.ObjectMeta{Name: badValue} + case 2: + secret.Data[bootstrapapi.BootstrapTokenIDKey] = tokenID + secret.Data[bootstrapapi.BootstrapTokenSecretKey] = []byte("") + secret.ObjectMeta = metav1.ObjectMeta{Name: name} + case 3: + secret.Data[bootstrapapi.BootstrapTokenIDKey] = tokenID + secret.Data[bootstrapapi.BootstrapTokenSecretKey] = tokenSecret + secret.Data[bootstrapapi.BootstrapTokenExpirationKey] = []byte(badValue) + secret.ObjectMeta = metav1.ObjectMeta{Name: name} + case 4: + secret.Data[bootstrapapi.BootstrapTokenIDKey] = tokenID + secret.Data[bootstrapapi.BootstrapTokenSecretKey] = tokenSecret + secret.Data[bootstrapapi.BootstrapTokenExpirationKey] = tokenExpire + secret.ObjectMeta = metav1.ObjectMeta{Name: name} + case 5: + secret.Data[bootstrapapi.BootstrapTokenIDKey] = tokenID + secret.Data[bootstrapapi.BootstrapTokenSecretKey] = tokenSecret + secret.Data[bootstrapapi.BootstrapTokenExpirationKey] = tokenExpire + secret.Data[tokenUsageKey] = []byte("false") + secret.ObjectMeta = metav1.ObjectMeta{Name: name} + case 6: + secret.Data[bootstrapapi.BootstrapTokenIDKey] = tokenID + secret.Data[bootstrapapi.BootstrapTokenSecretKey] = tokenSecret + secret.Data[bootstrapapi.BootstrapTokenExpirationKey] = tokenExpire + secret.Data[tokenUsageKey] = []byte("true") + secret.ObjectMeta = metav1.ObjectMeta{Name: name} + } + + secretList := v1.SecretList{} + secretList.Items = []v1.Secret{secret} + secretList.TypeMeta = metav1.TypeMeta{APIVersion: "v1", Kind: "SecretList"} + + output, err := json.Marshal(secretList) + if err == nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(output)) + } + atomic.AddUint32(&httpTestItr, 1) + atomic.StoreUint32(&httpSentResponse, 1) +}