From 5b7bda90c0353de0e8dbfaa33ef30cb77f0bf69e Mon Sep 17 00:00:00 2001 From: "Lubomir I. Ivanov" Date: Fri, 18 Jun 2021 00:24:50 +0300 Subject: [PATCH] kubeadm: introduce apis/bootstraptoken/v1 Package bootstraptoken contains an API and utilities wrapping the "bootstrap.kubernetes.io/token" Secret type to ease its usage in kubeadm. The API is released as v1, since these utilities have been part of a GA workflow for 10+ releases. The "bootstrap.kubernetes.io/token" Secret type is also GA. --- cmd/kubeadm/app/apis/bootstraptoken/doc.go | 21 + .../app/apis/bootstraptoken/v1/types.go | 58 ++ .../app/apis/bootstraptoken/v1/utils.go | 212 ++++++ .../app/apis/bootstraptoken/v1/utils_test.go | 680 ++++++++++++++++++ .../v1/zz_generated.deepcopy.go | 65 ++ 5 files changed, 1036 insertions(+) create mode 100644 cmd/kubeadm/app/apis/bootstraptoken/doc.go create mode 100644 cmd/kubeadm/app/apis/bootstraptoken/v1/types.go create mode 100644 cmd/kubeadm/app/apis/bootstraptoken/v1/utils.go create mode 100644 cmd/kubeadm/app/apis/bootstraptoken/v1/utils_test.go create mode 100644 cmd/kubeadm/app/apis/bootstraptoken/v1/zz_generated.deepcopy.go diff --git a/cmd/kubeadm/app/apis/bootstraptoken/doc.go b/cmd/kubeadm/app/apis/bootstraptoken/doc.go new file mode 100644 index 00000000000..b7be16ba469 --- /dev/null +++ b/cmd/kubeadm/app/apis/bootstraptoken/doc.go @@ -0,0 +1,21 @@ +/* +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. +*/ + +// +groupName=bootstraptoken.kubeadm.k8s.io + +// Package bootstraptoken contains an API and utilities wrapping the +// "bootstrap.kubernetes.io/token" Secret type to ease its usage in kubeadm. +package bootstraptoken // import "k8s.io/kubernetes/cmd/kubeadm/app/apis/bootstraptoken" diff --git a/cmd/kubeadm/app/apis/bootstraptoken/v1/types.go b/cmd/kubeadm/app/apis/bootstraptoken/v1/types.go new file mode 100644 index 00000000000..8ebaf77f3ba --- /dev/null +++ b/cmd/kubeadm/app/apis/bootstraptoken/v1/types.go @@ -0,0 +1,58 @@ +/* +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 v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// BootstrapToken describes one bootstrap token, stored as a Secret in the cluster +// +k8s:deepcopy-gen=true +type BootstrapToken struct { + // Token is used for establishing bidirectional trust between nodes and control-planes. + // Used for joining nodes in the cluster. + Token *BootstrapTokenString `json:"token" datapolicy:"token"` + // Description sets a human-friendly message why this token exists and what it's used + // for, so other administrators can know its purpose. + // +optional + Description string `json:"description,omitempty"` + // TTL defines the time to live for this token. Defaults to 24h. + // Expires and TTL are mutually exclusive. + // +optional + TTL *metav1.Duration `json:"ttl,omitempty"` + // Expires specifies the timestamp when this token expires. Defaults to being set + // dynamically at runtime based on the TTL. Expires and TTL are mutually exclusive. + // +optional + Expires *metav1.Time `json:"expires,omitempty"` + // Usages describes the ways in which this token can be used. Can by default be used + // for establishing bidirectional trust, but that can be changed here. + // +optional + Usages []string `json:"usages,omitempty"` + // Groups specifies the extra groups that this token will authenticate as when/if + // used for authentication + // +optional + Groups []string `json:"groups,omitempty"` +} + +// BootstrapTokenString is a token of the format abcdef.abcdef0123456789 that is used +// for both validation of the practically of the API server from a joining node's point +// of view and as an authentication method for the node in the bootstrap phase of +// "kubeadm join". This token is and should be short-lived +type BootstrapTokenString struct { + ID string `json:"-"` + Secret string `json:"-" datapolicy:"token"` +} diff --git a/cmd/kubeadm/app/apis/bootstraptoken/v1/utils.go b/cmd/kubeadm/app/apis/bootstraptoken/v1/utils.go new file mode 100644 index 00000000000..83640d03937 --- /dev/null +++ b/cmd/kubeadm/app/apis/bootstraptoken/v1/utils.go @@ -0,0 +1,212 @@ +/* +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 v1 + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/pkg/errors" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + bootstrapapi "k8s.io/cluster-bootstrap/token/api" + bootstraputil "k8s.io/cluster-bootstrap/token/util" + bootstrapsecretutil "k8s.io/cluster-bootstrap/util/secrets" +) + +// MarshalJSON implements the json.Marshaler interface. +func (bts BootstrapTokenString) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`"%s"`, bts.String())), nil +} + +// UnmarshalJSON implements the json.Unmarshaller interface. +func (bts *BootstrapTokenString) UnmarshalJSON(b []byte) error { + // If the token is represented as "", just return quickly without an error + if len(b) == 0 { + return nil + } + + // Remove unnecessary " characters coming from the JSON parser + token := strings.Replace(string(b), `"`, ``, -1) + // Convert the string Token to a BootstrapTokenString object + newbts, err := NewBootstrapTokenString(token) + if err != nil { + return err + } + bts.ID = newbts.ID + bts.Secret = newbts.Secret + return nil +} + +// String returns the string representation of the BootstrapTokenString +func (bts BootstrapTokenString) String() string { + if len(bts.ID) > 0 && len(bts.Secret) > 0 { + return bootstraputil.TokenFromIDAndSecret(bts.ID, bts.Secret) + } + return "" +} + +// NewBootstrapTokenString converts the given Bootstrap Token as a string +// to the BootstrapTokenString object used for serialization/deserialization +// and internal usage. It also automatically validates that the given token +// is of the right format +func NewBootstrapTokenString(token string) (*BootstrapTokenString, error) { + substrs := bootstraputil.BootstrapTokenRegexp.FindStringSubmatch(token) + // TODO: Add a constant for the 3 value here, and explain better why it's needed (other than because how the regexp parsin works) + if len(substrs) != 3 { + return nil, errors.Errorf("the bootstrap token %q was not of the form %q", token, bootstrapapi.BootstrapTokenPattern) + } + + return &BootstrapTokenString{ID: substrs[1], Secret: substrs[2]}, nil +} + +// NewBootstrapTokenStringFromIDAndSecret is a wrapper around NewBootstrapTokenString +// that allows the caller to specify the ID and Secret separately +func NewBootstrapTokenStringFromIDAndSecret(id, secret string) (*BootstrapTokenString, error) { + return NewBootstrapTokenString(bootstraputil.TokenFromIDAndSecret(id, secret)) +} + +// BootstrapTokenToSecret converts the given BootstrapToken object to its Secret representation that +// may be submitted to the API Server in order to be stored. +func BootstrapTokenToSecret(bt *BootstrapToken) *v1.Secret { + return &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: bootstraputil.BootstrapTokenSecretName(bt.Token.ID), + Namespace: metav1.NamespaceSystem, + }, + Type: v1.SecretType(bootstrapapi.SecretTypeBootstrapToken), + Data: encodeTokenSecretData(bt, time.Now()), + } +} + +// encodeTokenSecretData takes the token discovery object and an optional duration and returns the .Data for the Secret +// now is passed in order to be able to used in unit testing +func encodeTokenSecretData(token *BootstrapToken, now time.Time) map[string][]byte { + data := map[string][]byte{ + bootstrapapi.BootstrapTokenIDKey: []byte(token.Token.ID), + bootstrapapi.BootstrapTokenSecretKey: []byte(token.Token.Secret), + } + + if len(token.Description) > 0 { + data[bootstrapapi.BootstrapTokenDescriptionKey] = []byte(token.Description) + } + + // If for some strange reason both token.TTL and token.Expires would be set + // (they are mutually exclusive in validation so this shouldn't be the case), + // token.Expires has higher priority, as can be seen in the logic here. + if token.Expires != nil { + // Format the expiration date accordingly + // TODO: This maybe should be a helper function in bootstraputil? + expirationString := token.Expires.Time.UTC().Format(time.RFC3339) + data[bootstrapapi.BootstrapTokenExpirationKey] = []byte(expirationString) + + } else if token.TTL != nil && token.TTL.Duration > 0 { + // Only if .Expires is unset, TTL might have an effect + // Get the current time, add the specified duration, and format it accordingly + expirationString := now.Add(token.TTL.Duration).UTC().Format(time.RFC3339) + data[bootstrapapi.BootstrapTokenExpirationKey] = []byte(expirationString) + } + + for _, usage := range token.Usages { + data[bootstrapapi.BootstrapTokenUsagePrefix+usage] = []byte("true") + } + + if len(token.Groups) > 0 { + data[bootstrapapi.BootstrapTokenExtraGroupsKey] = []byte(strings.Join(token.Groups, ",")) + } + return data +} + +// BootstrapTokenFromSecret returns a BootstrapToken object from the given Secret +func BootstrapTokenFromSecret(secret *v1.Secret) (*BootstrapToken, error) { + // Get the Token ID field from the Secret data + tokenID := bootstrapsecretutil.GetData(secret, bootstrapapi.BootstrapTokenIDKey) + if len(tokenID) == 0 { + return nil, errors.Errorf("bootstrap Token Secret has no token-id data: %s", secret.Name) + } + + // Enforce the right naming convention + if secret.Name != bootstraputil.BootstrapTokenSecretName(tokenID) { + return nil, errors.Errorf("bootstrap token name is not of the form '%s(token-id)'. Actual: %q. Expected: %q", + bootstrapapi.BootstrapTokenSecretPrefix, secret.Name, bootstraputil.BootstrapTokenSecretName(tokenID)) + } + + tokenSecret := bootstrapsecretutil.GetData(secret, bootstrapapi.BootstrapTokenSecretKey) + if len(tokenSecret) == 0 { + return nil, errors.Errorf("bootstrap Token Secret has no token-secret data: %s", secret.Name) + } + + // Create the BootstrapTokenString object based on the ID and Secret + bts, err := NewBootstrapTokenStringFromIDAndSecret(tokenID, tokenSecret) + if err != nil { + return nil, errors.Wrap(err, "bootstrap Token Secret is invalid and couldn't be parsed") + } + + // Get the description (if any) from the Secret + description := bootstrapsecretutil.GetData(secret, bootstrapapi.BootstrapTokenDescriptionKey) + + // Expiration time is optional, if not specified this implies the token + // never expires. + secretExpiration := bootstrapsecretutil.GetData(secret, bootstrapapi.BootstrapTokenExpirationKey) + var expires *metav1.Time + if len(secretExpiration) > 0 { + expTime, err := time.Parse(time.RFC3339, secretExpiration) + if err != nil { + return nil, errors.Wrapf(err, "can't parse expiration time of bootstrap token %q", secret.Name) + } + expires = &metav1.Time{Time: expTime} + } + + // Build an usages string slice from the Secret data + var usages []string + for k, v := range secret.Data { + // Skip all fields that don't include this prefix + if !strings.HasPrefix(k, bootstrapapi.BootstrapTokenUsagePrefix) { + continue + } + // Skip those that don't have this usage set to true + if string(v) != "true" { + continue + } + usages = append(usages, strings.TrimPrefix(k, bootstrapapi.BootstrapTokenUsagePrefix)) + } + // Only sort the slice if defined + if usages != nil { + sort.Strings(usages) + } + + // Get the extra groups information from the Secret + // It's done this way to make .Groups be nil in case there is no items, rather than an + // empty slice or an empty slice with a "" string only + var groups []string + groupsString := bootstrapsecretutil.GetData(secret, bootstrapapi.BootstrapTokenExtraGroupsKey) + g := strings.Split(groupsString, ",") + if len(g) > 0 && len(g[0]) > 0 { + groups = g + } + + return &BootstrapToken{ + Token: bts, + Description: description, + Expires: expires, + Usages: usages, + Groups: groups, + }, nil +} diff --git a/cmd/kubeadm/app/apis/bootstraptoken/v1/utils_test.go b/cmd/kubeadm/app/apis/bootstraptoken/v1/utils_test.go new file mode 100644 index 00000000000..9a12ded26c5 --- /dev/null +++ b/cmd/kubeadm/app/apis/bootstraptoken/v1/utils_test.go @@ -0,0 +1,680 @@ +/* +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 v1 + +import ( + "encoding/json" + "reflect" + "testing" + "time" + + "github.com/pkg/errors" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMarshalJSON(t *testing.T) { + var tests = []struct { + bts BootstrapTokenString + expected string + }{ + {BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, `"abcdef.abcdef0123456789"`}, + {BootstrapTokenString{ID: "foo", Secret: "bar"}, `"foo.bar"`}, + {BootstrapTokenString{ID: "h", Secret: "b"}, `"h.b"`}, + } + for _, rt := range tests { + t.Run(rt.bts.ID, func(t *testing.T) { + b, err := json.Marshal(rt.bts) + if err != nil { + t.Fatalf("json.Marshal returned an unexpected error: %v", err) + } + if string(b) != rt.expected { + t.Errorf( + "failed BootstrapTokenString.MarshalJSON:\n\texpected: %s\n\t actual: %s", + rt.expected, + string(b), + ) + } + }) + } +} + +func TestUnmarshalJSON(t *testing.T) { + var tests = []struct { + input string + bts *BootstrapTokenString + expectedError bool + }{ + {`"f.s"`, &BootstrapTokenString{}, true}, + {`"abcdef."`, &BootstrapTokenString{}, true}, + {`"abcdef:abcdef0123456789"`, &BootstrapTokenString{}, true}, + {`abcdef.abcdef0123456789`, &BootstrapTokenString{}, true}, + {`"abcdef.abcdef0123456789`, &BootstrapTokenString{}, true}, + {`"abcdef.ABCDEF0123456789"`, &BootstrapTokenString{}, true}, + {`"abcdef.abcdef0123456789"`, &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, false}, + {`"123456.aabbccddeeffgghh"`, &BootstrapTokenString{ID: "123456", Secret: "aabbccddeeffgghh"}, false}, + } + for _, rt := range tests { + t.Run(rt.input, func(t *testing.T) { + newbts := &BootstrapTokenString{} + err := json.Unmarshal([]byte(rt.input), newbts) + if (err != nil) != rt.expectedError { + t.Errorf("failed BootstrapTokenString.UnmarshalJSON:\n\texpected error: %t\n\t actual error: %v", rt.expectedError, err) + } else if !reflect.DeepEqual(rt.bts, newbts) { + t.Errorf( + "failed BootstrapTokenString.UnmarshalJSON:\n\texpected: %v\n\t actual: %v", + rt.bts, + newbts, + ) + } + }) + } +} + +func TestJSONRoundtrip(t *testing.T) { + var tests = []struct { + input string + bts *BootstrapTokenString + }{ + {`"abcdef.abcdef0123456789"`, nil}, + {"", &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}}, + } + for _, rt := range tests { + t.Run(rt.input, func(t *testing.T) { + if err := roundtrip(rt.input, rt.bts); err != nil { + t.Errorf("failed BootstrapTokenString JSON roundtrip with error: %v", err) + } + }) + } +} + +func roundtrip(input string, bts *BootstrapTokenString) error { + var b []byte + var err error + newbts := &BootstrapTokenString{} + // If string input was specified, roundtrip like this: string -> (unmarshal) -> object -> (marshal) -> string + if len(input) > 0 { + if err := json.Unmarshal([]byte(input), newbts); err != nil { + return errors.Wrap(err, "expected no unmarshal error, got error") + } + if b, err = json.Marshal(newbts); err != nil { + return errors.Wrap(err, "expected no marshal error, got error") + } + if input != string(b) { + return errors.Errorf( + "expected token: %s\n\t actual: %s", + input, + string(b), + ) + } + } else { // Otherwise, roundtrip like this: object -> (marshal) -> string -> (unmarshal) -> object + if b, err = json.Marshal(bts); err != nil { + return errors.Wrap(err, "expected no marshal error, got error") + } + if err := json.Unmarshal(b, newbts); err != nil { + return errors.Wrap(err, "expected no unmarshal error, got error") + } + if !reflect.DeepEqual(bts, newbts) { + return errors.Errorf( + "expected object: %v\n\t actual: %v", + bts, + newbts, + ) + } + } + return nil +} + +func TestTokenFromIDAndSecret(t *testing.T) { + var tests = []struct { + bts BootstrapTokenString + expected string + }{ + {BootstrapTokenString{ID: "foo", Secret: "bar"}, "foo.bar"}, + {BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, "abcdef.abcdef0123456789"}, + {BootstrapTokenString{ID: "h", Secret: "b"}, "h.b"}, + } + for _, rt := range tests { + t.Run(rt.bts.ID, func(t *testing.T) { + actual := rt.bts.String() + if actual != rt.expected { + t.Errorf( + "failed BootstrapTokenString.String():\n\texpected: %s\n\t actual: %s", + rt.expected, + actual, + ) + } + }) + } +} + +func TestNewBootstrapTokenString(t *testing.T) { + var tests = []struct { + token string + expectedError bool + bts *BootstrapTokenString + }{ + {token: "", expectedError: true, bts: nil}, + {token: ".", expectedError: true, bts: nil}, + {token: "1234567890123456789012", expectedError: true, bts: nil}, // invalid parcel size + {token: "12345.1234567890123456", expectedError: true, bts: nil}, // invalid parcel size + {token: ".1234567890123456", expectedError: true, bts: nil}, // invalid parcel size + {token: "123456.", expectedError: true, bts: nil}, // invalid parcel size + {token: "123456:1234567890.123456", expectedError: true, bts: nil}, // invalid separation + {token: "abcdef:1234567890123456", expectedError: true, bts: nil}, // invalid separation + {token: "Abcdef.1234567890123456", expectedError: true, bts: nil}, // invalid token id + {token: "123456.AABBCCDDEEFFGGHH", expectedError: true, bts: nil}, // invalid token secret + {token: "123456.AABBCCD-EEFFGGHH", expectedError: true, bts: nil}, // invalid character + {token: "abc*ef.1234567890123456", expectedError: true, bts: nil}, // invalid character + {token: "abcdef.1234567890123456", expectedError: false, bts: &BootstrapTokenString{ID: "abcdef", Secret: "1234567890123456"}}, + {token: "123456.aabbccddeeffgghh", expectedError: false, bts: &BootstrapTokenString{ID: "123456", Secret: "aabbccddeeffgghh"}}, + {token: "abcdef.abcdef0123456789", expectedError: false, bts: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}}, + {token: "123456.1234560123456789", expectedError: false, bts: &BootstrapTokenString{ID: "123456", Secret: "1234560123456789"}}, + } + for _, rt := range tests { + t.Run(rt.token, func(t *testing.T) { + actual, err := NewBootstrapTokenString(rt.token) + if (err != nil) != rt.expectedError { + t.Errorf( + "failed NewBootstrapTokenString for the token %q\n\texpected error: %t\n\t actual error: %v", + rt.token, + rt.expectedError, + err, + ) + } else if !reflect.DeepEqual(actual, rt.bts) { + t.Errorf( + "failed NewBootstrapTokenString for the token %q\n\texpected: %v\n\t actual: %v", + rt.token, + rt.bts, + actual, + ) + } + }) + } +} + +func TestNewBootstrapTokenStringFromIDAndSecret(t *testing.T) { + var tests = []struct { + id, secret string + expectedError bool + bts *BootstrapTokenString + }{ + {id: "", secret: "", expectedError: true, bts: nil}, + {id: "1234567890123456789012", secret: "", expectedError: true, bts: nil}, // invalid parcel size + {id: "12345", secret: "1234567890123456", expectedError: true, bts: nil}, // invalid parcel size + {id: "", secret: "1234567890123456", expectedError: true, bts: nil}, // invalid parcel size + {id: "123456", secret: "", expectedError: true, bts: nil}, // invalid parcel size + {id: "Abcdef", secret: "1234567890123456", expectedError: true, bts: nil}, // invalid token id + {id: "123456", secret: "AABBCCDDEEFFGGHH", expectedError: true, bts: nil}, // invalid token secret + {id: "123456", secret: "AABBCCD-EEFFGGHH", expectedError: true, bts: nil}, // invalid character + {id: "abc*ef", secret: "1234567890123456", expectedError: true, bts: nil}, // invalid character + {id: "abcdef", secret: "1234567890123456", expectedError: false, bts: &BootstrapTokenString{ID: "abcdef", Secret: "1234567890123456"}}, + {id: "123456", secret: "aabbccddeeffgghh", expectedError: false, bts: &BootstrapTokenString{ID: "123456", Secret: "aabbccddeeffgghh"}}, + {id: "abcdef", secret: "abcdef0123456789", expectedError: false, bts: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}}, + {id: "123456", secret: "1234560123456789", expectedError: false, bts: &BootstrapTokenString{ID: "123456", Secret: "1234560123456789"}}, + } + for _, rt := range tests { + t.Run(rt.id, func(t *testing.T) { + actual, err := NewBootstrapTokenStringFromIDAndSecret(rt.id, rt.secret) + if (err != nil) != rt.expectedError { + t.Errorf( + "failed NewBootstrapTokenStringFromIDAndSecret for the token with id %q and secret %q\n\texpected error: %t\n\t actual error: %v", + rt.id, + rt.secret, + rt.expectedError, + err, + ) + } else if !reflect.DeepEqual(actual, rt.bts) { + t.Errorf( + "failed NewBootstrapTokenStringFromIDAndSecret for the token with id %q and secret %q\n\texpected: %v\n\t actual: %v", + rt.id, + rt.secret, + rt.bts, + actual, + ) + } + }) + } +} + +// This timestamp is used as the reference value when computing expiration dates based on TTLs in these unit tests +var refTime = time.Date(1970, time.January, 1, 1, 1, 1, 0, time.UTC) + +func TestBootstrapTokenToSecret(t *testing.T) { + var tests = []struct { + bt *BootstrapToken + secret *v1.Secret + }{ + { + &BootstrapToken{ // all together + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + Description: "foo", + Expires: &metav1.Time{ + Time: refTime, + }, + Usages: []string{"signing", "authentication"}, + Groups: []string{"system:bootstrappers", "system:bootstrappers:foo"}, + }, + &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bootstrap-token-abcdef", + Namespace: "kube-system", + }, + Type: v1.SecretType("bootstrap.kubernetes.io/token"), + Data: map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "description": []byte("foo"), + "expiration": []byte(refTime.Format(time.RFC3339)), + "usage-bootstrap-signing": []byte("true"), + "usage-bootstrap-authentication": []byte("true"), + "auth-extra-groups": []byte("system:bootstrappers,system:bootstrappers:foo"), + }, + }, + }, + } + for _, rt := range tests { + t.Run(rt.bt.Token.ID, func(t *testing.T) { + actual := BootstrapTokenToSecret(rt.bt) + if !reflect.DeepEqual(actual, rt.secret) { + t.Errorf( + "failed BootstrapTokenToSecret():\n\texpected: %v\n\t actual: %v", + rt.secret, + actual, + ) + } + }) + } +} + +func TestBootstrapTokenToSecretRoundtrip(t *testing.T) { + var tests = []struct { + bt *BootstrapToken + }{ + { + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + Description: "foo", + Expires: &metav1.Time{ + Time: refTime, + }, + Usages: []string{"authentication", "signing"}, + Groups: []string{"system:bootstrappers", "system:bootstrappers:foo"}, + }, + }, + } + for _, rt := range tests { + t.Run(rt.bt.Token.ID, func(t *testing.T) { + actual, err := BootstrapTokenFromSecret(BootstrapTokenToSecret(rt.bt)) + if err != nil { + t.Errorf("failed BootstrapToken to Secret roundtrip with error: %v", err) + } + if !reflect.DeepEqual(actual, rt.bt) { + t.Errorf( + "failed BootstrapToken to Secret roundtrip:\n\texpected: %v\n\t actual: %v", + rt.bt, + actual, + ) + } + }) + } +} + +func TestEncodeTokenSecretData(t *testing.T) { + var tests = []struct { + name string + bt *BootstrapToken + data map[string][]byte + }{ + { + "the minimum amount of information needed to be specified", + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + }, + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + }, + }, + { + "adds description", + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + Description: "foo", + }, + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "description": []byte("foo"), + }, + }, + { + "adds ttl", + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + TTL: &metav1.Duration{ + Duration: mustParseDuration("2h", t), + }, + }, + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "expiration": []byte(refTime.Add(mustParseDuration("2h", t)).Format(time.RFC3339)), + }, + }, + { + "adds expiration", + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + Expires: &metav1.Time{ + Time: refTime, + }, + }, + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "expiration": []byte(refTime.Format(time.RFC3339)), + }, + }, + { + "adds ttl and expiration, should favor expiration", + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + TTL: &metav1.Duration{ + Duration: mustParseDuration("2h", t), + }, + Expires: &metav1.Time{ + Time: refTime, + }, + }, + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "expiration": []byte(refTime.Format(time.RFC3339)), + }, + }, + { + "adds usages", + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + Usages: []string{"authentication", "signing"}, + }, + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "usage-bootstrap-signing": []byte("true"), + "usage-bootstrap-authentication": []byte("true"), + }, + }, + { + "adds groups", + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + Groups: []string{"system:bootstrappers", "system:bootstrappers:foo"}, + }, + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "auth-extra-groups": []byte("system:bootstrappers,system:bootstrappers:foo"), + }, + }, + { + "all together", + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + Description: "foo", + TTL: &metav1.Duration{ + Duration: mustParseDuration("2h", t), + }, + Expires: &metav1.Time{ + Time: refTime, + }, + Usages: []string{"authentication", "signing"}, + Groups: []string{"system:bootstrappers", "system:bootstrappers:foo"}, + }, + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "description": []byte("foo"), + "expiration": []byte(refTime.Format(time.RFC3339)), + "usage-bootstrap-signing": []byte("true"), + "usage-bootstrap-authentication": []byte("true"), + "auth-extra-groups": []byte("system:bootstrappers,system:bootstrappers:foo"), + }, + }, + } + for _, rt := range tests { + t.Run(rt.name, func(t *testing.T) { + actual := encodeTokenSecretData(rt.bt, refTime) + if !reflect.DeepEqual(actual, rt.data) { + t.Errorf( + "failed encodeTokenSecretData:\n\texpected: %v\n\t actual: %v", + rt.data, + actual, + ) + } + }) + } +} + +func mustParseDuration(durationStr string, t *testing.T) time.Duration { + d, err := time.ParseDuration(durationStr) + if err != nil { + t.Fatalf("couldn't parse duration %q: %v", durationStr, err) + } + return d +} + +func TestBootstrapTokenFromSecret(t *testing.T) { + var tests = []struct { + desc string + name string + data map[string][]byte + bt *BootstrapToken + expectedError bool + }{ + { + "minimum information", + "bootstrap-token-abcdef", + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + }, + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + }, + false, + }, + { + "invalid token id", + "bootstrap-token-abcdef", + map[string][]byte{ + "token-id": []byte("abcdeF"), + "token-secret": []byte("abcdef0123456789"), + }, + nil, + true, + }, + { + "invalid secret naming", + "foo", + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + }, + nil, + true, + }, + { + "invalid token secret", + "bootstrap-token-abcdef", + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("ABCDEF0123456789"), + }, + nil, + true, + }, + { + "adds description", + "bootstrap-token-abcdef", + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "description": []byte("foo"), + }, + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + Description: "foo", + }, + false, + }, + { + "adds expiration", + "bootstrap-token-abcdef", + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "expiration": []byte(refTime.Format(time.RFC3339)), + }, + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + Expires: &metav1.Time{ + Time: refTime, + }, + }, + false, + }, + { + "invalid expiration", + "bootstrap-token-abcdef", + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "expiration": []byte("invalid date"), + }, + nil, + true, + }, + { + "adds usages", + "bootstrap-token-abcdef", + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "usage-bootstrap-signing": []byte("true"), + "usage-bootstrap-authentication": []byte("true"), + }, + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + Usages: []string{"authentication", "signing"}, + }, + false, + }, + { + "should ignore usages that aren't set to true", + "bootstrap-token-abcdef", + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "usage-bootstrap-signing": []byte("true"), + "usage-bootstrap-authentication": []byte("true"), + "usage-bootstrap-foo": []byte("false"), + "usage-bootstrap-bar": []byte(""), + }, + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + Usages: []string{"authentication", "signing"}, + }, + false, + }, + { + "adds groups", + "bootstrap-token-abcdef", + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "auth-extra-groups": []byte("system:bootstrappers,system:bootstrappers:foo"), + }, + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + Groups: []string{"system:bootstrappers", "system:bootstrappers:foo"}, + }, + false, + }, + { + "all fields set", + "bootstrap-token-abcdef", + map[string][]byte{ + "token-id": []byte("abcdef"), + "token-secret": []byte("abcdef0123456789"), + "description": []byte("foo"), + "expiration": []byte(refTime.Format(time.RFC3339)), + "usage-bootstrap-signing": []byte("true"), + "usage-bootstrap-authentication": []byte("true"), + "auth-extra-groups": []byte("system:bootstrappers,system:bootstrappers:foo"), + }, + &BootstrapToken{ + Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, + Description: "foo", + Expires: &metav1.Time{ + Time: refTime, + }, + Usages: []string{"authentication", "signing"}, + Groups: []string{"system:bootstrappers", "system:bootstrappers:foo"}, + }, + false, + }, + } + for _, rt := range tests { + t.Run(rt.desc, func(t *testing.T) { + actual, err := BootstrapTokenFromSecret(&v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: rt.name, + Namespace: "kube-system", + }, + Type: v1.SecretType("bootstrap.kubernetes.io/token"), + Data: rt.data, + }) + if (err != nil) != rt.expectedError { + t.Errorf( + "failed BootstrapTokenFromSecret\n\texpected error: %t\n\t actual error: %v", + rt.expectedError, + err, + ) + } else { + if actual == nil && rt.bt == nil { + // if both pointers are nil, it's okay, just continue + return + } + // If one of the pointers is defined but the other isn't, throw error. If both pointers are defined but unequal, throw error + if (actual == nil && rt.bt != nil) || (actual != nil && rt.bt == nil) || !reflect.DeepEqual(*actual, *rt.bt) { + t.Errorf( + "failed BootstrapTokenFromSecret\n\texpected: %s\n\t actual: %s", + jsonMarshal(rt.bt), + jsonMarshal(actual), + ) + } + } + }) + } +} + +func jsonMarshal(bt *BootstrapToken) string { + b, _ := json.Marshal(*bt) + return string(b) +} diff --git a/cmd/kubeadm/app/apis/bootstraptoken/v1/zz_generated.deepcopy.go b/cmd/kubeadm/app/apis/bootstraptoken/v1/zz_generated.deepcopy.go new file mode 100644 index 00000000000..60e9998c35d --- /dev/null +++ b/cmd/kubeadm/app/apis/bootstraptoken/v1/zz_generated.deepcopy.go @@ -0,0 +1,65 @@ +// +build !ignore_autogenerated + +/* +Copyright 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. +*/ + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BootstrapToken) DeepCopyInto(out *BootstrapToken) { + *out = *in + if in.Token != nil { + in, out := &in.Token, &out.Token + *out = new(BootstrapTokenString) + **out = **in + } + if in.TTL != nil { + in, out := &in.TTL, &out.TTL + *out = new(metav1.Duration) + **out = **in + } + if in.Expires != nil { + in, out := &in.Expires, &out.Expires + *out = (*in).DeepCopy() + } + if in.Usages != nil { + in, out := &in.Usages, &out.Usages + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BootstrapToken. +func (in *BootstrapToken) DeepCopy() *BootstrapToken { + if in == nil { + return nil + } + out := new(BootstrapToken) + in.DeepCopyInto(out) + return out +}