From b7dd46c4e61f74d266d14e4c020fcba4bd3ce1c0 Mon Sep 17 00:00:00 2001 From: Guhan Eswaran Date: Wed, 30 Sep 2020 13:26:05 +0000 Subject: [PATCH] Support rotation of svc account tokens This patchset introduces airshipctl command - airshipctl cluster rotate-sa-token which basically rotates SA tokens Previous work: https://review.opendev.org/#/c/749470/ Change-Id: Ibe932fa8d2831979e5b2ac2781f746e8ec2fdc3c --- cmd/cluster/cluster.go | 2 + cmd/cluster/resetsatoken/resetsatoken.go | 76 ++++++++ cmd/cluster/resetsatoken/resetsatoken_test.go | 36 ++++ .../reset-with-help.golden | 24 +++ .../cluster-cmd-with-help.golden | 9 +- docs/source/cli/airshipctl_cluster.md | 1 + .../cli/airshipctl_cluster_rotate-sa-token.md | 49 ++++++ pkg/cluster/resetsatoken/command.go | 60 +++++++ pkg/cluster/resetsatoken/command_test.go | 85 +++++++++ pkg/cluster/resetsatoken/errors.go | 46 +++++ pkg/cluster/resetsatoken/resetsatoken.go | 139 +++++++++++++++ pkg/cluster/resetsatoken/resetsatoken_test.go | 166 ++++++++++++++++++ .../resetsatoken/testdata/airshipconfig.yaml | 23 +++ .../resetsatoken/testdata/kubeconfig.yaml | 19 ++ pkg/cluster/resetsatoken/testdata/pod.yaml | 25 +++ pkg/cluster/resetsatoken/testdata/secret.yaml | 6 + 16 files changed, 762 insertions(+), 4 deletions(-) create mode 100644 cmd/cluster/resetsatoken/resetsatoken.go create mode 100644 cmd/cluster/resetsatoken/resetsatoken_test.go create mode 100644 cmd/cluster/resetsatoken/testdata/TestResetTokenGoldenOutput/reset-with-help.golden create mode 100644 docs/source/cli/airshipctl_cluster_rotate-sa-token.md create mode 100644 pkg/cluster/resetsatoken/command.go create mode 100644 pkg/cluster/resetsatoken/command_test.go create mode 100644 pkg/cluster/resetsatoken/errors.go create mode 100644 pkg/cluster/resetsatoken/resetsatoken.go create mode 100644 pkg/cluster/resetsatoken/resetsatoken_test.go create mode 100644 pkg/cluster/resetsatoken/testdata/airshipconfig.yaml create mode 100644 pkg/cluster/resetsatoken/testdata/kubeconfig.yaml create mode 100644 pkg/cluster/resetsatoken/testdata/pod.yaml create mode 100644 pkg/cluster/resetsatoken/testdata/secret.yaml diff --git a/cmd/cluster/cluster.go b/cmd/cluster/cluster.go index d615359f5..22e1be2bd 100644 --- a/cmd/cluster/cluster.go +++ b/cmd/cluster/cluster.go @@ -17,6 +17,7 @@ package cluster import ( "github.com/spf13/cobra" + "opendev.org/airship/airshipctl/cmd/cluster/resetsatoken" "opendev.org/airship/airshipctl/pkg/config" ) @@ -39,6 +40,7 @@ func NewClusterCommand(cfgFactory config.Factory) *cobra.Command { clusterRootCmd.AddCommand(NewInitCommand(cfgFactory)) clusterRootCmd.AddCommand(NewMoveCommand(cfgFactory)) clusterRootCmd.AddCommand(NewStatusCommand(cfgFactory)) + clusterRootCmd.AddCommand(resetsatoken.NewResetCommand(cfgFactory)) return clusterRootCmd } diff --git a/cmd/cluster/resetsatoken/resetsatoken.go b/cmd/cluster/resetsatoken/resetsatoken.go new file mode 100644 index 000000000..7d9045915 --- /dev/null +++ b/cmd/cluster/resetsatoken/resetsatoken.go @@ -0,0 +1,76 @@ +/* + 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 + + https://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 resetsatoken + +import ( + "github.com/spf13/cobra" + + "opendev.org/airship/airshipctl/pkg/cluster/resetsatoken" + "opendev.org/airship/airshipctl/pkg/config" + "opendev.org/airship/airshipctl/pkg/log" +) + +const ( + resetLong = ` +Use to reset/rotate the Service Account(SA) tokens and additionally restart the +corresponding pods to get the latest token data reflected in the pod spec + +Secret-namespace is a mandatory field and secret-name is optional. If secret- +name is not given, all the SA tokens in that particular namespace is considered, +else only that particular input secret-name` + + resetExample = ` +# To rotate a particular SA token +airshipctl cluster rotate-sa-token -n cert-manager -s cert-manager-token-vvn9p + +# To rotate all the SA tokens in cert-manager namespace +airshipctl cluster rotate-sa-token -n cert-manager +` +) + +// NewResetCommand creates a new command for generating secret information +func NewResetCommand(cfgFactory config.Factory) *cobra.Command { + r := &resetsatoken.ResetCommand{ + Options: resetsatoken.ResetFlags{}, + CfgFactory: cfgFactory, + } + + resetCmd := &cobra.Command{ + Use: "rotate-sa-token", + Short: "Rotate tokens of Service Accounts", + Long: resetLong[1:], + Example: resetExample, + RunE: func(cmd *cobra.Command, args []string) error { + return r.RunE() + }, + } + + resetCmd.Flags().StringVarP(&r.Options.Namespace, "secret-namespace", "n", "", + "namespace of the Service Account Token") + resetCmd.Flags().StringVarP(&r.Options.SecretName, "secret-name", "s", "", + "name of the secret containing Service Account Token") + resetCmd.Flags().StringVar(&r.Options.Kubeconfig, "kubeconfig", "", + "Path to kubeconfig associated with cluster being managed") + + err := resetCmd.MarkFlagRequired("secret-namespace") + if err != nil { + log.Fatal(err) + } + err = resetCmd.MarkFlagRequired("kubeconfig") + if err != nil { + log.Fatalf("marking kubeconfig flag required failed: %v", err) + } + return resetCmd +} diff --git a/cmd/cluster/resetsatoken/resetsatoken_test.go b/cmd/cluster/resetsatoken/resetsatoken_test.go new file mode 100644 index 000000000..994552a58 --- /dev/null +++ b/cmd/cluster/resetsatoken/resetsatoken_test.go @@ -0,0 +1,36 @@ +/* + 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 + + https://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 resetsatoken_test + +import ( + "testing" + + "opendev.org/airship/airshipctl/cmd/cluster/resetsatoken" + "opendev.org/airship/airshipctl/testutil" +) + +func TestResetToken(t *testing.T) { + cmdTests := []*testutil.CmdTest{ + { + Name: "reset-with-help", + CmdLine: "--help", + Cmd: resetsatoken.NewResetCommand(nil), + }, + } + + for _, tt := range cmdTests { + testutil.RunTest(t, tt) + } +} diff --git a/cmd/cluster/resetsatoken/testdata/TestResetTokenGoldenOutput/reset-with-help.golden b/cmd/cluster/resetsatoken/testdata/TestResetTokenGoldenOutput/reset-with-help.golden new file mode 100644 index 000000000..7a1f49531 --- /dev/null +++ b/cmd/cluster/resetsatoken/testdata/TestResetTokenGoldenOutput/reset-with-help.golden @@ -0,0 +1,24 @@ +Use to reset/rotate the Service Account(SA) tokens and additionally restart the +corresponding pods to get the latest token data reflected in the pod spec + +Secret-namespace is a mandatory field and secret-name is optional. If secret- +name is not given, all the SA tokens in that particular namespace is considered, +else only that particular input secret-name + +Usage: + rotate-sa-token [flags] + +Examples: + +# To rotate a particular SA token +airshipctl cluster rotate-sa-token -n cert-manager -s cert-manager-token-vvn9p + +# To rotate all the SA tokens in cert-manager namespace +airshipctl cluster rotate-sa-token -n cert-manager + + +Flags: + -h, --help help for rotate-sa-token + --kubeconfig string Path to kubeconfig associated with cluster being managed + -s, --secret-name string name of the secret containing Service Account Token + -n, --secret-namespace string namespace of the Service Account Token diff --git a/cmd/cluster/testdata/TestNewClusterCommandGoldenOutput/cluster-cmd-with-help.golden b/cmd/cluster/testdata/TestNewClusterCommandGoldenOutput/cluster-cmd-with-help.golden index 416509fc3..f35d3d944 100644 --- a/cmd/cluster/testdata/TestNewClusterCommandGoldenOutput/cluster-cmd-with-help.golden +++ b/cmd/cluster/testdata/TestNewClusterCommandGoldenOutput/cluster-cmd-with-help.golden @@ -5,10 +5,11 @@ Usage: cluster [command] Available Commands: - help Help about any command - init Deploy cluster-api provider components - move Move Cluster API objects, provider specific objects and all dependencies to the target cluster - status Retrieve statuses of deployed cluster components + help Help about any command + init Deploy cluster-api provider components + move Move Cluster API objects, provider specific objects and all dependencies to the target cluster + rotate-sa-token Rotate tokens of Service Accounts + status Retrieve statuses of deployed cluster components Flags: -h, --help help for cluster diff --git a/docs/source/cli/airshipctl_cluster.md b/docs/source/cli/airshipctl_cluster.md index 4a71842ac..c88e70743 100644 --- a/docs/source/cli/airshipctl_cluster.md +++ b/docs/source/cli/airshipctl_cluster.md @@ -26,5 +26,6 @@ such as getting status and deploying initial infrastructure. * [airshipctl](airshipctl.md) - A unified entrypoint to various airship components * [airshipctl cluster init](airshipctl_cluster_init.md) - Deploy cluster-api provider components * [airshipctl cluster move](airshipctl_cluster_move.md) - Move Cluster API objects, provider specific objects and all dependencies to the target cluster +* [airshipctl cluster rotate-sa-token](airshipctl_cluster_rotate-sa-token.md) - Rotate tokens of Service Accounts * [airshipctl cluster status](airshipctl_cluster_status.md) - Retrieve statuses of deployed cluster components diff --git a/docs/source/cli/airshipctl_cluster_rotate-sa-token.md b/docs/source/cli/airshipctl_cluster_rotate-sa-token.md new file mode 100644 index 000000000..f5a0aa307 --- /dev/null +++ b/docs/source/cli/airshipctl_cluster_rotate-sa-token.md @@ -0,0 +1,49 @@ +## airshipctl cluster rotate-sa-token + +Rotate tokens of Service Accounts + +### Synopsis + +Use to reset/rotate the Service Account(SA) tokens and additionally restart the +corresponding pods to get the latest token data reflected in the pod spec + +Secret-namespace is a mandatory field and secret-name is optional. If secret- +name is not given, all the SA tokens in that particular namespace is considered, +else only that particular input secret-name + +``` +airshipctl cluster rotate-sa-token [flags] +``` + +### Examples + +``` + +# To rotate a particular SA token +airshipctl cluster rotate-sa-token -n cert-manager -s cert-manager-token-vvn9p + +# To rotate all the SA tokens in cert-manager namespace +airshipctl cluster rotate-sa-token -n cert-manager + +``` + +### Options + +``` + -h, --help help for rotate-sa-token + --kubeconfig string Path to kubeconfig associated with cluster being managed + -s, --secret-name string name of the secret containing Service Account Token + -n, --secret-namespace string namespace of the Service Account Token +``` + +### Options inherited from parent commands + +``` + --airshipconf string Path to file for airshipctl configuration. (default "$HOME/.airship/config") + --debug enable verbose output +``` + +### SEE ALSO + +* [airshipctl cluster](airshipctl_cluster.md) - Manage Kubernetes clusters + diff --git a/pkg/cluster/resetsatoken/command.go b/pkg/cluster/resetsatoken/command.go new file mode 100644 index 000000000..c75118555 --- /dev/null +++ b/pkg/cluster/resetsatoken/command.go @@ -0,0 +1,60 @@ +/* + 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 + https://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 resetsatoken + +import ( + "opendev.org/airship/airshipctl/pkg/config" + "opendev.org/airship/airshipctl/pkg/k8s/client" + "opendev.org/airship/airshipctl/pkg/log" +) + +// ResetFlags flags for reset command +type ResetFlags struct { + Namespace string + SecretName string + Kubeconfig string +} + +// ResetCommand for reset command +type ResetCommand struct { + Options ResetFlags + CfgFactory config.Factory +} + +// RunE implements the functionality for resetsatoken +func (c *ResetCommand) RunE() error { + airshipconfig, err := c.CfgFactory() + if err != nil { + return err + } + + factory := client.DefaultClient + + kclient, err := factory(airshipconfig.LoadedConfigPath(), c.Options.Kubeconfig) + if err != nil { + return err + } + + manager, err := NewTokenManager(kclient.ClientSet()) + if err != nil { + return err + } + + log.Printf("Starting Token Rotation") + + err = manager.RotateToken(c.Options.Namespace, c.Options.SecretName) + if err != nil { + return ErrRotateTokenFail{Err: err.Error()} + } + return nil +} diff --git a/pkg/cluster/resetsatoken/command_test.go b/pkg/cluster/resetsatoken/command_test.go new file mode 100644 index 000000000..31fc3ba1f --- /dev/null +++ b/pkg/cluster/resetsatoken/command_test.go @@ -0,0 +1,85 @@ +/* + 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 + https://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 resetsatoken_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "opendev.org/airship/airshipctl/pkg/cluster/resetsatoken" + "opendev.org/airship/airshipctl/pkg/config" + "opendev.org/airship/airshipctl/pkg/k8s/client" + "opendev.org/airship/airshipctl/pkg/k8s/client/fake" +) + +func TestRunE(t *testing.T) { + airshipConfigPath := "testdata/airshipconfig.yaml" + kubeConfigPath := "testdata/kubeconfig.yaml" + + tests := []struct { + testCaseName string + testErr string + resetFlags resetsatoken.ResetFlags + cfgFactory config.Factory + }{ + { + testCaseName: "invalid config factory", + cfgFactory: func() (*config.Config, error) { + return nil, fmt.Errorf("test config error") + }, + resetFlags: resetsatoken.ResetFlags{}, + testErr: "test config error", + }, + { + testCaseName: "valid config factory", + cfgFactory: config.CreateFactory(&airshipConfigPath), + resetFlags: resetsatoken.ResetFlags{ + SecretName: "test-secret", + Namespace: "test-namespace", + }, + testErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.testCaseName, func(t *testing.T) { + command := resetsatoken.ResetCommand{ + Options: tt.resetFlags, + CfgFactory: tt.cfgFactory, + } + err := command.RunE() + if tt.testErr != "" { + assert.Contains(t, err.Error(), tt.testErr) + } else { + fakeConfig, err := command.CfgFactory() + assert.NoError(t, err) + + factory := client.DefaultClient + _, err = factory(fakeConfig.LoadedConfigPath(), kubeConfigPath) + assert.NoError(t, err) + + fakeClient := fake.NewClient() + assert.NotEmpty(t, fakeClient) + + clientset := fakeClient.ClientSet() + fakeManager, err := resetsatoken.NewTokenManager(clientset) + assert.NoError(t, err) + + err = fakeManager.RotateToken(command.Options.Namespace, command.Options.SecretName) + assert.Error(t, err) + } + }) + } +} diff --git a/pkg/cluster/resetsatoken/errors.go b/pkg/cluster/resetsatoken/errors.go new file mode 100644 index 000000000..e0e706853 --- /dev/null +++ b/pkg/cluster/resetsatoken/errors.go @@ -0,0 +1,46 @@ +/* + 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 + + https://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 resetsatoken + +import ( + "fmt" +) + +// ErrNoSATokenFound is returned if there are no SA tokens found in the provided namespace +type ErrNoSATokenFound struct { + namespace string +} + +// ErrNotSAToken is returned if the user input is not an SA token +type ErrNotSAToken struct { + secretName string +} + +// ErrRotateTokenFail is called when there is a failure in rotating the SA token +type ErrRotateTokenFail struct { + Err string +} + +func (e ErrNoSATokenFound) Error() string { + return fmt.Sprintf("no service account tokens found in namespace %s", e.namespace) +} + +func (e ErrNotSAToken) Error() string { + return fmt.Sprintf("%s is not a Service Account Token", e.secretName) +} + +func (e ErrRotateTokenFail) Error() string { + return fmt.Sprintf("failed to rotate token: %s", e.Err) +} diff --git a/pkg/cluster/resetsatoken/resetsatoken.go b/pkg/cluster/resetsatoken/resetsatoken.go new file mode 100644 index 000000000..4b48c557d --- /dev/null +++ b/pkg/cluster/resetsatoken/resetsatoken.go @@ -0,0 +1,139 @@ +/* + 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 + + https://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 resetsatoken + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + + "opendev.org/airship/airshipctl/pkg/log" +) + +const ( + replicaSetKind = "ReplicaSet" +) + +// TokenManager manages service account rotation +type TokenManager struct { + kclient kubernetes.Interface +} + +// NewTokenManager returns an instance of a TokenManager +func NewTokenManager(kclient kubernetes.Interface) (*TokenManager, error) { + return &TokenManager{ + kclient: kclient, + }, nil +} + +// RotateToken - rotates token > 1. Deletes the secret and 2. Deletes its pod +// Deleting the SA Secret recreates a new secret with a new token information +// However, the pods referencing to the old secret needs to be refreshed +// manually and hence deleting the pod to allow it to get recreated with new +// secret reference +func (manager TokenManager) RotateToken(ns string, secretName string) error { + if secretName == "" { + return manager.rotateAllTokens(ns) + } + return manager.rotateSingleToken(ns, secretName) +} + +// deleteSecret- deletes the secret +func (manager TokenManager) deleteSecret(secretName string, ns string) error { + return manager.kclient.CoreV1().Secrets(ns).Delete(secretName, &metav1.DeleteOptions{}) +} + +// deletePod - identifies the secret relationship with pods and deletes corresponding pods +// if its part of replicaset +func (manager TokenManager) deletePod(secretName string, ns string) error { + pods, err := manager.kclient.CoreV1().Pods(ns).List(metav1.ListOptions{}) + if err != nil { + return err + } + + for _, pod := range pods.Items { + for _, volume := range pod.Spec.Volumes { + if volume.Name == secretName { + if manager.isReplicaSet(pod.OwnerReferences) { + log.Printf("Deleting pod - %s in %s", pod.Name, ns) + if deleteErr := manager.kclient.CoreV1().Pods(ns).Delete(pod.Name, + &metav1.DeleteOptions{}); deleteErr != nil { + log.Printf("Failed to delete pod: %v", err.Error()) + } + } + } + } + } + return nil +} + +// rotateAllTokens rotates all the tokens in the given namespace +func (manager TokenManager) rotateAllTokens(ns string) error { + tokenTypeFieldSelector := fmt.Sprintf("type=%s", corev1.SecretTypeServiceAccountToken) + listOptions := metav1.ListOptions{FieldSelector: tokenTypeFieldSelector} + + secrets, err := manager.kclient.CoreV1().Secrets(ns).List(listOptions) + if err != nil { + return err + } + + if len(secrets.Items) == 0 { + return ErrNoSATokenFound{namespace: ns} + } + + for _, secret := range secrets.Items { + err := manager.rotate(secret.Name, secret.Namespace) + if err != nil { + return err + } + } + return nil +} + +// rotateSingleToken rotates a given token in the given ns +func (manager TokenManager) rotateSingleToken(ns string, secretName string) error { + secret, err := manager.kclient.CoreV1().Secrets(ns).Get(secretName, metav1.GetOptions{}) + if err != nil { + return err + } + + if secret.Type != corev1.SecretTypeServiceAccountToken { + return ErrNotSAToken{secretName: secretName} + } + return manager.rotate(secretName, ns) +} + +// rotate performs delete action for secrets and its pods +func (manager TokenManager) rotate(secretName string, secretNamespace string) error { + log.Printf("Rotating token - %s in %s", secretName, secretNamespace) + err := manager.deleteSecret(secretName, secretNamespace) + if err != nil { + return err + } + + return manager.deletePod(secretName, secretNamespace) +} + +// isReplicaSet checks if the pod is controlled by a ReplicaSet making it safe to delete +func (manager TokenManager) isReplicaSet(ownerReferences []metav1.OwnerReference) bool { + for _, ownerRef := range ownerReferences { + if ownerRef.Kind == replicaSetKind { + return true + } + } + return false +} diff --git a/pkg/cluster/resetsatoken/resetsatoken_test.go b/pkg/cluster/resetsatoken/resetsatoken_test.go new file mode 100644 index 000000000..8f35e1f51 --- /dev/null +++ b/pkg/cluster/resetsatoken/resetsatoken_test.go @@ -0,0 +1,166 @@ +/* + 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 + + https://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 resetsatoken_test + +import ( + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/yaml" + kfake "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/kubernetes/scheme" + ktesting "k8s.io/client-go/testing" + + "opendev.org/airship/airshipctl/pkg/cluster/resetsatoken" + "opendev.org/airship/airshipctl/pkg/k8s/client/fake" + "opendev.org/airship/airshipctl/testutil" +) + +type testCase struct { + name string + existingSecrets []*v1.Secret + existingPods []*v1.Pod + secretName string + secretNamespace string + numPodDeletes int + numSecretDeletes int + expectErr bool +} + +var testCases = []testCase{ + { + name: "no-pods-secrets", + expectErr: true, + }, + { + name: "valid-secret-no-pod", + secretName: "valid-secret", + secretNamespace: "valid-namespace", + existingSecrets: []*v1.Secret{getSecret()}, + numSecretDeletes: 1, + }, + { + name: "valid-secret-no-pod-empty-filter", + secretNamespace: "valid-namespace", + existingSecrets: []*v1.Secret{getSecret()}, + numSecretDeletes: 1, + }, + { + name: "invalid-secret-no-pod", + secretName: "invalid-secret", + existingSecrets: []*v1.Secret{getSecret()}, + secretNamespace: "valid-namespace", + }, + { + name: "unmatched-secret-pod", + secretName: "invalid-secret", + secretNamespace: "valid-namespace", + existingPods: []*v1.Pod{getPod()}, + existingSecrets: []*v1.Secret{getSecret()}, + }, + { + name: "matched-secret-pod", + secretName: "valid-secret", + secretNamespace: "valid-namespace", + existingPods: []*v1.Pod{getPod()}, + existingSecrets: []*v1.Secret{getSecret()}, + numPodDeletes: 1, + numSecretDeletes: 1, + }, +} + +func TestResetSaToken(t *testing.T) { + for _, testCase := range testCases { + cfg, _ := testutil.InitConfig(t) + + var objects []runtime.Object + for _, pod := range testCase.existingPods { + objects = append(objects, pod) + } + for _, secret := range testCase.existingSecrets { + objects = append(objects, secret) + } + ra := fake.WithTypedObjects(objects...) + kclient := fake.NewClient(ra) + + assert.NotEmpty(t, kclient) + assert.NotEmpty(t, cfg) + + clientset := kclient.ClientSet() + manager, err := resetsatoken.NewTokenManager(clientset) + assert.NoError(t, err) + + err = manager.RotateToken(testCase.secretNamespace, testCase.secretName) + if testCase.expectErr { + assert.Error(t, err) + continue + } + + actions := clientset.(*kfake.Clientset).Actions() + + podDeleteActions := filterActions(actions, "pods", "delete") + assert.Len(t, podDeleteActions, testCase.numPodDeletes) + + secretDeleteActions := filterActions(actions, "secrets", "delete") + assert.Len(t, secretDeleteActions, testCase.numSecretDeletes) + } +} + +func getSecret() *v1.Secret { + object := readObjectFromFile("testdata/secret.yaml") + if secret, ok := object.(*v1.Secret); ok { + return secret + } + return nil +} + +func getPod() *v1.Pod { + object := readObjectFromFile("testdata/pod.yaml") + if pod, ok := object.(*v1.Pod); ok { + return pod + } + return nil +} + +func readObjectFromFile(fileName string) runtime.Object { + contents, err := ioutil.ReadFile(fileName) + if err != nil { + return nil + } + jsonContents, err := yaml.ToJSON(contents) + if err != nil { + return nil + } + + object, err := runtime.Decode(scheme.Codecs.UniversalDeserializer(), jsonContents) + if err != nil { + return nil + } + return object +} + +func filterActions(actions []ktesting.Action, resource string, verb string) []ktesting.Action { + var result []ktesting.Action + for _, action := range actions { + if action.GetVerb() == verb && action.GetResource().Resource == resource { + result = append(result, action) + } + } + return result +} diff --git a/pkg/cluster/resetsatoken/testdata/airshipconfig.yaml b/pkg/cluster/resetsatoken/testdata/airshipconfig.yaml new file mode 100644 index 000000000..fe1b4ecdf --- /dev/null +++ b/pkg/cluster/resetsatoken/testdata/airshipconfig.yaml @@ -0,0 +1,23 @@ +apiVersion: airshipit.org/v1alpha1 +contexts: + dummy_cluster: + contextKubeconf: dummycluster_ephemeral + manifest: dummy_manifest +currentContext: dummy_cluster +kind: Config +manifests: + dummy_manifest: + primaryRepositoryName: primary + repositories: + primary: + auth: + sshKey: testdata/test-key.pem + type: ssh-key + checkout: + branch: "" + force: false + remoteRef: "" + tag: v1.0.1 + url: http://dummy.url.com/primary.git + subPath: site + targetPath: testdata diff --git a/pkg/cluster/resetsatoken/testdata/kubeconfig.yaml b/pkg/cluster/resetsatoken/testdata/kubeconfig.yaml new file mode 100644 index 000000000..b0d205918 --- /dev/null +++ b/pkg/cluster/resetsatoken/testdata/kubeconfig.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +clusters: +- cluster: + certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUN5RENDQWJDZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRFNU1Ea3lPVEUzTURNd09Wb1hEVEk1TURreU5qRTNNRE13T1Zvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTUZyCkdxM0kyb2dZci81Y01Udy9Na1pORTNWQURzdEdyU240WjU2TDhPUGhMcUhDN2t1dno2dVpES3dCSGtGeTBNK2MKRXIzd2piUGE1aTV5NmkyMGtxSHBVMjdPZTA0dzBXV2s4N0RSZVlWaGNoZVJHRXoraWt3SndIcGRmMjJVemZNKwpkSDBzaUhuMVd6UnovYk4za3hMUzJlMnZ2U1Y3bmNubk1YRUd4OXV0MUY0NThHeWxxdmxXTUlWMzg5Q2didXFDCkcwcFdiMTBLM0RVZWdiT25Xa1FmSm5sTWRRVVZDUVdZZEZaaklrcWtkWi9hVTRobkNEV01oZXNWRnFNaDN3VVAKczhQay9BNWh1ZFFPbnFRNDVIWXZLdjZ5RjJWcDUyWExBRUx3NDJ4aVRKZlh0V1h4eHR6cU4wY1lyL2VxeS9XMQp1YVVGSW5xQjFVM0JFL1oxbmFrQ0F3RUFBYU1qTUNFd0RnWURWUjBQQVFIL0JBUURBZ0trTUE4R0ExVWRFd0VCCi93UUZNQU1CQWY4d0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFKUUVKQVBLSkFjVDVuK3dsWGJsdU9mS0J3c2gKZTI4R1c5R2QwM0N0NGF3RzhzMXE1ZHNua2tpZmVTUENHVFZ1SXF6UTZDNmJaSk9SMDMvVEl5ejh6NDJnaitDVApjWUZXZkltM2RKTnpRL08xWkdySXZZNWdtcWJtWDlpV0JaU24rRytEOGxubzd2aGMvY0tBRFR5OTMvVU92MThuCkdhMnIrRGJJcHcyTWVBVEl2elpxRS9RWlVSQ25DMmdjUFhTVzFqN2h4R3o1a3ZNcGVDZTdQYVUvdVFvblVHSWsKZ2t6ZzI4NHQvREhUUzc4N1V1SUg5cXBaV09yTFNMOGFBeUxQUHhWSXBteGZmbWRETE9TS2VUemRlTmxoSitUMwowQlBVaHBQTlJBNTNJN0hRQjhVUDR2elNONTkzZ1VFbVlFQ2Jic2RYSzB6ZVR6SDdWWHR2Zmd5WTVWWT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + server: https://127.0.0.1:6443 + name: dummycluster_ephemeral +contexts: +- context: + cluster: dummycluster_ephemeral + user: kubernetes-admin + name: dummy_cluster +current-context: dummy_cluster +kind: Config +preferences: {} +users: +- name: kubernetes-admin + user: + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUM4akNDQWRxZ0F3SUJBZ0lJQXhEdzk2RUY4SXN3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB4T1RBNU1qa3hOekF6TURsYUZ3MHlNREE1TWpneE56QXpNVEphTURReApGekFWQmdOVkJBb1REbk41YzNSbGJUcHRZWE4wWlhKek1Sa3dGd1lEVlFRREV4QnJkV0psY201bGRHVnpMV0ZrCmJXbHVNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXV6R0pZdlBaNkRvaTQyMUQKSzhXSmFaQ25OQWQycXo1cC8wNDJvRnpRUGJyQWd6RTJxWVZrek9MOHhBVmVSN1NONXdXb1RXRXlGOEVWN3JyLwo0K0hoSEdpcTVQbXF1SUZ5enpuNi9JWmM4alU5eEVmenZpa2NpckxmVTR2UlhKUXdWd2dBU05sMkFXQUloMmRECmRUcmpCQ2ZpS1dNSHlqMFJiSGFsc0J6T3BnVC9IVHYzR1F6blVRekZLdjJkajVWMU5rUy9ESGp5UlJKK0VMNlEKQlltR3NlZzVQNE5iQzllYnVpcG1NVEFxL0p1bU9vb2QrRmpMMm5acUw2Zkk2ZkJ0RjVPR2xwQ0IxWUo4ZnpDdApHUVFaN0hUSWJkYjJ0cDQzRlZPaHlRYlZjSHFUQTA0UEoxNSswV0F5bVVKVXo4WEE1NDRyL2J2NzRKY0pVUkZoCmFyWmlRd0lEQVFBQm95Y3dKVEFPQmdOVkhROEJBZjhFQkFNQ0JhQXdFd1lEVlIwbEJBd3dDZ1lJS3dZQkJRVUgKQXdJd0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFMMmhIUmVibEl2VHJTMFNmUVg1RG9ueVVhNy84aTg1endVWApSd3dqdzFuS0U0NDJKbWZWRGZ5b0hRYUM4Ti9MQkxyUXM0U0lqU1JYdmFHU1dSQnRnT1RRV21Db1laMXdSbjdwCndDTXZQTERJdHNWWm90SEZpUFl2b1lHWFFUSXA3YlROMmg1OEJaaEZ3d25nWUovT04zeG1rd29IN1IxYmVxWEYKWHF1TTluekhESk41VlZub1lQR09yRHMwWlg1RnNxNGtWVU0wVExNQm9qN1ZIRDhmU0E5RjRYNU4yMldsZnNPMAo4aksrRFJDWTAyaHBrYTZQQ0pQS0lNOEJaMUFSMG9ZakZxT0plcXpPTjBqcnpYWHh4S2pHVFVUb1BldVA5dCtCCjJOMVA1TnI4a2oxM0lrend5Q1NZclFVN09ZM3ltZmJobHkrcXZxaFVFa014MlQ1SkpmQT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcFFJQkFBS0NBUUVBdXpHSll2UFo2RG9pNDIxREs4V0phWkNuTkFkMnF6NXAvMDQyb0Z6UVBickFnekUyCnFZVmt6T0w4eEFWZVI3U041d1dvVFdFeUY4RVY3cnIvNCtIaEhHaXE1UG1xdUlGeXp6bjYvSVpjOGpVOXhFZnoKdmlrY2lyTGZVNHZSWEpRd1Z3Z0FTTmwyQVdBSWgyZERkVHJqQkNmaUtXTUh5ajBSYkhhbHNCek9wZ1QvSFR2MwpHUXpuVVF6Rkt2MmRqNVYxTmtTL0RIanlSUkorRUw2UUJZbUdzZWc1UDROYkM5ZWJ1aXBtTVRBcS9KdW1Pb29kCitGakwyblpxTDZmSTZmQnRGNU9HbHBDQjFZSjhmekN0R1FRWjdIVEliZGIydHA0M0ZWT2h5UWJWY0hxVEEwNFAKSjE1KzBXQXltVUpVejhYQTU0NHIvYnY3NEpjSlVSRmhhclppUXdJREFRQUJBb0lCQVFDU0pycjlaeVpiQ2dqegpSL3VKMFZEWCt2aVF4c01BTUZyUjJsOE1GV3NBeHk1SFA4Vk4xYmc5djN0YUVGYnI1U3hsa3lVMFJRNjNQU25DCm1uM3ZqZ3dVQWlScllnTEl5MGk0UXF5VFBOU1V4cnpTNHRxTFBjM3EvSDBnM2FrNGZ2cSsrS0JBUUlqQnloamUKbnVFc1JpMjRzT3NESlM2UDE5NGlzUC9yNEpIM1M5bFZGbkVuOGxUR2c0M1kvMFZoMXl0cnkvdDljWjR5ZUNpNwpjMHFEaTZZcXJZaFZhSW9RRW1VQjdsbHRFZkZzb3l4VDR6RTE5U3pVbkRoMmxjYTF1TzhqcmI4d2xHTzBoQ2JyClB1R1l2WFFQa3Q0VlNmalhvdGJ3d2lBNFRCVERCRzU1bHp6MmNKeS9zSS8zSHlYbEMxcTdXUmRuQVhhZ1F0VzkKOE9DZGRkb0JBb0dCQU5NcUNtSW94REtyckhZZFRxT1M1ZFN4cVMxL0NUN3ZYZ0pScXBqd2Y4WHA2WHo0KzIvTAozVXFaVDBEL3dGTkZkc1Z4eFYxMnNYMUdwMHFWZVlKRld5OVlCaHVSWGpTZ0ZEWldSY1Z1Y01sNVpPTmJsbmZGCjVKQ0xnNXFMZ1g5VTNSRnJrR3A0R241UDQxamg4TnhKVlhzZG5xWE9xNTFUK1RRT1UzdkpGQjc1QW9HQkFPTHcKalp1cnZtVkZyTHdaVGgvRDNpWll5SVV0ZUljZ2NKLzlzbTh6L0pPRmRIbFd4dGRHUFVzYVd1MnBTNEhvckFtbgpqTm4vSTluUXd3enZ3MWUzVVFPbUhMRjVBczk4VU5hbk5TQ0xNMW1yaXZHRXJ1VHFnTDM1bU41eFZPdTUxQU5JCm4yNkFtODBJT2JDeEtLa0R0ZXJSaFhHd3g5c1pONVJCbG9VRThZNGJBb0dBQ3ZsdVhMZWRxcng5VkE0bDNoNXUKVDJXRVUxYjgxZ1orcmtRc1I1S0lNWEw4cllBTElUNUpHKzFuendyN3BkaEFXZmFWdVV2SDRhamdYT0h6MUs5aQpFODNSVTNGMG9ldUg0V01PY1RwU0prWm0xZUlXcWRiaEVCb1FGdUlWTXRib1BsV0d4ZUhFRHJoOEtreGp4aThSCmdEcUQyajRwY1IzQ0g5QjJ5a0lqQjVFQ2dZRUExc0xXLys2enE1c1lNSm14K1JXZThhTXJmL3pjQnVTSU1LQWgKY0dNK0wwMG9RSHdDaUU4TVNqcVN1ajV3R214YUFuanhMb3ZwSFlRV1VmUEVaUW95UE1YQ2VhRVBLOU4xbk8xMwp0V2lHRytIZkIxaU5PazFCc0lhNFNDbndOM1FRVTFzeXBaeEgxT3hueS9LYmkvYmEvWEZ5VzNqMGFUK2YvVWxrCmJGV1ZVdWtDZ1lFQTBaMmRTTFlmTjV5eFNtYk5xMWVqZXdWd1BjRzQxR2hQclNUZEJxdHFac1doWGE3aDdLTWEKeHdvamh5SXpnTXNyK2tXODdlajhDQ2h0d21sQ1p5QU92QmdOZytncnJ1cEZLM3FOSkpKeU9YREdHckdpbzZmTQp5aXB3Q2tZVGVxRThpZ1J6UkI5QkdFUGY4eVpjMUtwdmZhUDVhM0lRZmxiV0czbGpUemNNZVZjPQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo= diff --git a/pkg/cluster/resetsatoken/testdata/pod.yaml b/pkg/cluster/resetsatoken/testdata/pod.yaml new file mode 100644 index 000000000..8e6b6f1b8 --- /dev/null +++ b/pkg/cluster/resetsatoken/testdata/pod.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: Pod +metadata: + name: valid-pod + namespace: valid-namespace + ownerReferences: + - apiVersion: apps/v1 + blockOwnerDeletion: true + controller: true + kind: ReplicaSet + name: valid-pod-rs +spec: + containers: + - image: pod-image + volumeMounts: + - mountPath: /var/run/secrets/kubernetes.io/serviceaccount + name: valid-secret + readOnly: true + serviceAccount: valid-serviceaccount + serviceAccountName: valid-serviceaccount + volumes: + - name: valid-secret + secret: + defaultMode: 420 + secretName: valid-secret diff --git a/pkg/cluster/resetsatoken/testdata/secret.yaml b/pkg/cluster/resetsatoken/testdata/secret.yaml new file mode 100644 index 000000000..17ce464d4 --- /dev/null +++ b/pkg/cluster/resetsatoken/testdata/secret.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Secret +metadata: + name: valid-secret + namespace: valid-namespace +type: kubernetes.io/service-account-token