From 2443ea29ade4c42b1870c0dd63d070e4e17325ff Mon Sep 17 00:00:00 2001 From: Sven Petersen <5971734+sven-petersen@users.noreply.github.com> Date: Mon, 31 Oct 2022 16:42:01 +0100 Subject: [PATCH] feat: add cmd to patch bastion ipblock/cidr filter --- internal/gardenclient/client.go | 55 +++ internal/gardenclient/mocks/mock_client.go | 86 +++- pkg/cmd/cmd.go | 1 + pkg/cmd/ssh/base_options.go | 72 ++++ pkg/cmd/ssh/options.go | 43 +- pkg/cmd/ssh/ssh_patch.go | 42 ++ pkg/cmd/ssh/ssh_patch_export_test.go | 55 +++ pkg/cmd/ssh/ssh_patch_options.go | 400 ++++++++++++++++++ pkg/cmd/ssh/ssh_patch_test.go | 455 +++++++++++++++++++++ 9 files changed, 1163 insertions(+), 46 deletions(-) create mode 100644 pkg/cmd/ssh/base_options.go create mode 100644 pkg/cmd/ssh/ssh_patch.go create mode 100644 pkg/cmd/ssh/ssh_patch_export_test.go create mode 100644 pkg/cmd/ssh/ssh_patch_options.go create mode 100644 pkg/cmd/ssh/ssh_patch_test.go diff --git a/internal/gardenclient/client.go b/internal/gardenclient/client.go index ec2fe188..a0683d79 100644 --- a/internal/gardenclient/client.go +++ b/internal/gardenclient/client.go @@ -15,7 +15,9 @@ import ( openstackv1alpha1 "github.com/gardener/gardener-extension-provider-openstack/pkg/apis/openstack/v1alpha1" gardencore "github.com/gardener/gardener/pkg/apis/core" gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + operationsv1alpha1 "github.com/gardener/gardener/pkg/apis/operations/v1alpha1" seedmanagementv1alpha1 "github.com/gardener/gardener/pkg/apis/seedmanagement/v1alpha1" + authenticationv1 "k8s.io/api/authentication/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -80,6 +82,16 @@ type Client interface { // GetShootOfManagedSeed returns shoot of seed using ManagedSeed resource, nil if not a managed seed GetShootOfManagedSeed(ctx context.Context, name string) (*seedmanagementv1alpha1.Shoot, error) + // GetBastion returns a Gardener bastion resource in a namespace by name + GetBastion(ctx context.Context, namespace, name string) (*operationsv1alpha1.Bastion, error) + // ListBastions returns all Gardener bastion resources, filtered by a list option + ListBastions(ctx context.Context, opts ...client.ListOption) (*operationsv1alpha1.BastionList, error) + // PatchBastion patches an existing bastion to match newBastion using the merge patch strategy + PatchBastion(ctx context.Context, newBastion, oldBastion *operationsv1alpha1.Bastion) error + + // Creates a token review for a user with token authentication + CreateTokenReview(ctx context.Context, token string) (*authenticationv1.TokenReview, error) + // RuntimeClient returns the underlying kubernetes runtime client // TODO: Remove this when we switched all APIs to the new gardenclient RuntimeClient() client.Client @@ -282,6 +294,49 @@ func (g *clientImpl) GetShootOfManagedSeed(ctx context.Context, name string) (*s return managedSeed.Spec.Shoot, nil } +func (g *clientImpl) GetBastion(ctx context.Context, namespace, name string) (*operationsv1alpha1.Bastion, error) { + bastion := &operationsv1alpha1.Bastion{} + key := types.NamespacedName{Namespace: namespace, Name: name} + + if err := g.c.Get(ctx, key, bastion); err != nil { + return nil, fmt.Errorf("failed to get bastion %v: %w", key, err) + } + + return bastion, nil +} + +func (g *clientImpl) ListBastions(ctx context.Context, opts ...client.ListOption) (*operationsv1alpha1.BastionList, error) { + bastionList := &operationsv1alpha1.BastionList{} + + if err := g.resolveListOptions(ctx, opts...); err != nil { + return nil, err + } + + if err := g.c.List(ctx, bastionList, opts...); err != nil { + return nil, fmt.Errorf("failed to list bastions with list options %q: %w", opts, err) + } + + return bastionList, nil +} + +func (g *clientImpl) PatchBastion(ctx context.Context, newBastion, oldBastion *operationsv1alpha1.Bastion) error { + return g.c.Patch(ctx, newBastion, client.MergeFrom(oldBastion)) +} + +func (g *clientImpl) CreateTokenReview(ctx context.Context, token string) (*authenticationv1.TokenReview, error) { + tokenReview := &authenticationv1.TokenReview{ + Spec: authenticationv1.TokenReviewSpec{ + Token: token, + }, + } + + if err := g.c.Create(ctx, tokenReview); err != nil { + return nil, fmt.Errorf("failed to create token review: %w", err) + } + + return tokenReview, nil +} + func (g *clientImpl) GetSeedClientConfig(ctx context.Context, name string) (clientcmd.ClientConfig, error) { if shoot, err := g.GetShootOfManagedSeed(ctx, name); err != nil { return nil, err diff --git a/internal/gardenclient/mocks/mock_client.go b/internal/gardenclient/mocks/mock_client.go index da9f3dc8..45790963 100644 --- a/internal/gardenclient/mocks/mock_client.go +++ b/internal/gardenclient/mocks/mock_client.go @@ -9,9 +9,11 @@ import ( reflect "reflect" v1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" - v1alpha1 "github.com/gardener/gardener/pkg/apis/seedmanagement/v1alpha1" + v1alpha1 "github.com/gardener/gardener/pkg/apis/operations/v1alpha1" + v1alpha10 "github.com/gardener/gardener/pkg/apis/seedmanagement/v1alpha1" gomock "github.com/golang/mock/gomock" - v1 "k8s.io/api/core/v1" + v1 "k8s.io/api/authentication/v1" + v10 "k8s.io/api/core/v1" clientcmd "k8s.io/client-go/tools/clientcmd" client "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -39,6 +41,21 @@ func (m *MockClient) EXPECT() *MockClientMockRecorder { return m.recorder } +// CreateTokenReview mocks base method. +func (m *MockClient) CreateTokenReview(arg0 context.Context, arg1 string) (*v1.TokenReview, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateTokenReview", arg0, arg1) + ret0, _ := ret[0].(*v1.TokenReview) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateTokenReview indicates an expected call of CreateTokenReview. +func (mr *MockClientMockRecorder) CreateTokenReview(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateTokenReview", reflect.TypeOf((*MockClient)(nil).CreateTokenReview), arg0, arg1) +} + // FindShoot mocks base method. func (m *MockClient) FindShoot(arg0 context.Context, arg1 ...client.ListOption) (*v1beta1.Shoot, error) { m.ctrl.T.Helper() @@ -59,6 +76,21 @@ func (mr *MockClientMockRecorder) FindShoot(arg0 interface{}, arg1 ...interface{ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindShoot", reflect.TypeOf((*MockClient)(nil).FindShoot), varargs...) } +// GetBastion mocks base method. +func (m *MockClient) GetBastion(arg0 context.Context, arg1, arg2 string) (*v1alpha1.Bastion, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetBastion", arg0, arg1, arg2) + ret0, _ := ret[0].(*v1alpha1.Bastion) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetBastion indicates an expected call of GetBastion. +func (mr *MockClientMockRecorder) GetBastion(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetBastion", reflect.TypeOf((*MockClient)(nil).GetBastion), arg0, arg1, arg2) +} + // GetCloudProfile mocks base method. func (m *MockClient) GetCloudProfile(arg0 context.Context, arg1 string) (*v1beta1.CloudProfile, error) { m.ctrl.T.Helper() @@ -75,10 +107,10 @@ func (mr *MockClientMockRecorder) GetCloudProfile(arg0, arg1 interface{}) *gomoc } // GetConfigMap mocks base method. -func (m *MockClient) GetConfigMap(arg0 context.Context, arg1, arg2 string) (*v1.ConfigMap, error) { +func (m *MockClient) GetConfigMap(arg0 context.Context, arg1, arg2 string) (*v10.ConfigMap, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetConfigMap", arg0, arg1, arg2) - ret0, _ := ret[0].(*v1.ConfigMap) + ret0, _ := ret[0].(*v10.ConfigMap) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -90,10 +122,10 @@ func (mr *MockClientMockRecorder) GetConfigMap(arg0, arg1, arg2 interface{}) *go } // GetNamespace mocks base method. -func (m *MockClient) GetNamespace(arg0 context.Context, arg1 string) (*v1.Namespace, error) { +func (m *MockClient) GetNamespace(arg0 context.Context, arg1 string) (*v10.Namespace, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetNamespace", arg0, arg1) - ret0, _ := ret[0].(*v1.Namespace) + ret0, _ := ret[0].(*v10.Namespace) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -135,10 +167,10 @@ func (mr *MockClientMockRecorder) GetProjectByNamespace(arg0, arg1 interface{}) } // GetSecret mocks base method. -func (m *MockClient) GetSecret(arg0 context.Context, arg1, arg2 string) (*v1.Secret, error) { +func (m *MockClient) GetSecret(arg0 context.Context, arg1, arg2 string) (*v10.Secret, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetSecret", arg0, arg1, arg2) - ret0, _ := ret[0].(*v1.Secret) + ret0, _ := ret[0].(*v10.Secret) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -225,10 +257,10 @@ func (mr *MockClientMockRecorder) GetShootClientConfig(arg0, arg1, arg2 interfac } // GetShootOfManagedSeed mocks base method. -func (m *MockClient) GetShootOfManagedSeed(arg0 context.Context, arg1 string) (*v1alpha1.Shoot, error) { +func (m *MockClient) GetShootOfManagedSeed(arg0 context.Context, arg1 string) (*v1alpha10.Shoot, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetShootOfManagedSeed", arg0, arg1) - ret0, _ := ret[0].(*v1alpha1.Shoot) + ret0, _ := ret[0].(*v1alpha10.Shoot) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -239,6 +271,26 @@ func (mr *MockClientMockRecorder) GetShootOfManagedSeed(arg0, arg1 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShootOfManagedSeed", reflect.TypeOf((*MockClient)(nil).GetShootOfManagedSeed), arg0, arg1) } +// ListBastions mocks base method. +func (m *MockClient) ListBastions(arg0 context.Context, arg1 ...client.ListOption) (*v1alpha1.BastionList, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ListBastions", varargs...) + ret0, _ := ret[0].(*v1alpha1.BastionList) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListBastions indicates an expected call of ListBastions. +func (mr *MockClientMockRecorder) ListBastions(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListBastions", reflect.TypeOf((*MockClient)(nil).ListBastions), varargs...) +} + // ListProjects mocks base method. func (m *MockClient) ListProjects(arg0 context.Context, arg1 ...client.ListOption) (*v1beta1.ProjectList, error) { m.ctrl.T.Helper() @@ -299,6 +351,20 @@ func (mr *MockClientMockRecorder) ListShoots(arg0 interface{}, arg1 ...interface return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListShoots", reflect.TypeOf((*MockClient)(nil).ListShoots), varargs...) } +// PatchBastion mocks base method. +func (m *MockClient) PatchBastion(arg0 context.Context, arg1, arg2 *v1alpha1.Bastion) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PatchBastion", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// PatchBastion indicates an expected call of PatchBastion. +func (mr *MockClientMockRecorder) PatchBastion(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchBastion", reflect.TypeOf((*MockClient)(nil).PatchBastion), arg0, arg1, arg2) +} + // RuntimeClient mocks base method. func (m *MockClient) RuntimeClient() client.Client { m.ctrl.T.Helper() diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index b9bc71c4..b69d5e35 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -122,6 +122,7 @@ Find more information at: https://github.com/gardener/gardenctl-v2/blob/master/R // add subcommands cmd.AddCommand(cmdssh.NewCmdSSH(f, cmdssh.NewSSHOptions(ioStreams))) + cmd.AddCommand(cmdssh.NewCmdSSHPatch(f, ioStreams)) cmd.AddCommand(cmdtarget.NewCmdTarget(f, ioStreams)) cmd.AddCommand(cmdversion.NewCmdVersion(f, cmdversion.NewVersionOptions(ioStreams))) cmd.AddCommand(cmdconfig.NewCmdConfig(f, ioStreams)) diff --git a/pkg/cmd/ssh/base_options.go b/pkg/cmd/ssh/base_options.go new file mode 100644 index 00000000..71152e30 --- /dev/null +++ b/pkg/cmd/ssh/base_options.go @@ -0,0 +1,72 @@ +package ssh + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/gardener/gardenctl-v2/internal/util" + "github.com/gardener/gardenctl-v2/pkg/cmd/base" +) + +// sshBaseOptions is a struct used by all ssh related commands +type sshBaseOptions struct { + base.Options + + // CIDRs is a list of IP address ranges to be allowed for accessing the + // created Bastion host. If not given, gardenctl will attempt to + // auto-detect the user's IP and allow only it (i.e. use a /32 netmask). + CIDRs []string + + // AutoDetected indicates if the public IPs of the user were automatically detected. + // AutoDetected is false in case the CIDRs were provided via flags. + AutoDetected bool +} + +func (o *sshBaseOptions) Complete(f util.Factory, cmd *cobra.Command, args []string) error { + if len(o.CIDRs) == 0 { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + publicIPs, err := f.PublicIPs(ctx) + if err != nil { + return fmt.Errorf("failed to determine your system's public IP addresses: %w", err) + } + + var cidrs []string + for _, ip := range publicIPs { + cidrs = append(cidrs, ipToCIDR(ip)) + } + + name := "CIDR" + if len(cidrs) != 1 { + name = "CIDRs" + } + + fmt.Fprintf(o.IOStreams.Out, "Auto-detected your system's %s as %s\n", name, strings.Join(cidrs, ", ")) + + o.CIDRs = cidrs + o.AutoDetected = true + } + + return nil +} + +func (o *sshBaseOptions) Validate() error { + if len(o.CIDRs) == 0 { + return errors.New("must at least specify a single CIDR to allow access to the bastion") + } + + for _, cidr := range o.CIDRs { + if _, _, err := net.ParseCIDR(cidr); err != nil { + return fmt.Errorf("CIDR %q is invalid: %w", cidr, err) + } + } + + return nil +} diff --git a/pkg/cmd/ssh/options.go b/pkg/cmd/ssh/options.go index bab1fbbb..f1bfb67a 100644 --- a/pkg/cmd/ssh/options.go +++ b/pkg/cmd/ssh/options.go @@ -157,8 +157,7 @@ var ( // //nolint:revive type SSHOptions struct { - base.Options - + sshBaseOptions // Interactive can be used to toggle between gardenctl just // providing the bastion host while keeping it alive (non-interactive), // or gardenctl opening the SSH connection itself (interactive). For @@ -170,15 +169,6 @@ type SSHOptions struct { // bastion host, but leave it up to the user to SSH themselves. NodeName string - // CIDRs is a list of IP address ranges to be allowed for accessing the - // created Bastion host. If not given, gardenctl will attempt to - // auto-detect the user's IP and allow only it (i.e. use a /32 netmask). - CIDRs []string - - // AutoDetected indicates if the public IPs of the user were automatically detected. - // AutoDetected is false in case the CIDRs were provided via flags. - AutoDetected bool - // SSHPublicKeyFile is the full path to the file containing the user's // public SSH key. If not given, gardenctl will create a new temporary keypair. SSHPublicKeyFile string @@ -204,8 +194,10 @@ type SSHOptions struct { // NewSSHOptions returns initialized SSHOptions func NewSSHOptions(ioStreams util.IOStreams) *SSHOptions { return &SSHOptions{ - Options: base.Options{ - IOStreams: ioStreams, + sshBaseOptions: sshBaseOptions{ + Options: base.Options{ + IOStreams: ioStreams, + }, }, Interactive: true, WaitTimeout: 10 * time.Minute, @@ -215,29 +207,8 @@ func NewSSHOptions(ioStreams util.IOStreams) *SSHOptions { // Complete adapts from the command line args to the data required. func (o *SSHOptions) Complete(f util.Factory, cmd *cobra.Command, args []string) error { - if len(o.CIDRs) == 0 { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - publicIPs, err := f.PublicIPs(ctx) - if err != nil { - return fmt.Errorf("failed to determine your system's public IP addresses: %w", err) - } - - cidrs := []string{} - for _, ip := range publicIPs { - cidrs = append(cidrs, ipToCIDR(ip)) - } - - name := "CIDR" - if len(cidrs) != 1 { - name = "CIDRs" - } - - fmt.Fprintf(o.IOStreams.Out, "Auto-detected your system's %s as %s\n", name, strings.Join(cidrs, ", ")) - - o.CIDRs = cidrs - o.AutoDetected = true + if err := o.sshBaseOptions.Complete(f, cmd, args); err != nil { + return err } if len(o.SSHPublicKeyFile) == 0 { diff --git a/pkg/cmd/ssh/ssh_patch.go b/pkg/cmd/ssh/ssh_patch.go new file mode 100644 index 00000000..a2e6208c --- /dev/null +++ b/pkg/cmd/ssh/ssh_patch.go @@ -0,0 +1,42 @@ +package ssh + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/gardener/gardenctl-v2/internal/util" + "github.com/gardener/gardenctl-v2/pkg/cmd/base" +) + +func NewCmdSSHPatch(f util.Factory, ioStreams util.IOStreams) *cobra.Command { + o := NewSSHPatchOptions(ioStreams) + cmd := &cobra.Command{ + Use: "ssh-patch [BASTION_NAME]", + Short: "Update a bastion host previously created through the ssh command", + Example: `# Update CIDRs on one of your bastion hosts. You can specify multiple CIDRs. + gardenctl ssh-patch cli-xxxxxxxx --cidr 8.8.8.8/20 --cidr dead:beaf::/64 + + # You can also omit the CIDR-flag and your system's public IPs (v4 and v6) will be auto-detected. + gardenctl ssh-patch cli-xxxxxxxx`, + Args: cobra.RangeArgs(0, 1), + RunE: base.WrapRunE(o, f), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + bastionNames, err := o.getBastionNameCompletions(f, cmd, toComplete) + if err != nil { + fmt.Fprintln(o.IOStreams.ErrOut, err.Error()) + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return bastionNames, cobra.ShellCompDirectiveNoFileComp + }, + } + + cmd.Flags().StringArrayVar(&o.CIDRs, "cidr", o.CIDRs, "CIDRs to allow access to the bastion host; if not given, your system's public IPs (v4 and v6) are auto-detected.") + + return cmd +} diff --git a/pkg/cmd/ssh/ssh_patch_export_test.go b/pkg/cmd/ssh/ssh_patch_export_test.go new file mode 100644 index 00000000..125b42ab --- /dev/null +++ b/pkg/cmd/ssh/ssh_patch_export_test.go @@ -0,0 +1,55 @@ +/* +SPDX-FileCopyrightText: 2021 SAP SE or an SAP affiliate company and Gardener contributors + +SPDX-License-Identifier: Apache-2.0 +*/ + +package ssh + +import ( + "context" + "time" + + "github.com/spf13/cobra" + "k8s.io/client-go/tools/clientcmd/api" + + gc "github.com/gardener/gardenctl-v2/internal/gardenclient" + "github.com/gardener/gardenctl-v2/internal/util" + "github.com/gardener/gardenctl-v2/pkg/target" +) + +var getCurrentUserOriginal = getCurrentUser + +func SetGetCurrentUser(f func(ctx context.Context, gardenClient gc.Client, authInfo *api.AuthInfo) (string, error)) { + getCurrentUser = f +} + +func GetGetCurrentUser() func(ctx context.Context, gardenClient gc.Client, authInfo *api.AuthInfo) (string, error) { + return getCurrentUser +} + +func ResetGetCurrentUser() { + getCurrentUser = getCurrentUserOriginal +} + +func GetGetAuthInfo() func(ctx context.Context, manager target.Manager) (*api.AuthInfo, error) { + return getAuthInfo +} + +func GetGetBastionNameCompletions(o *SSHPatchOptions) func(f util.Factory, cmd *cobra.Command, toComplete string) ([]string, error) { + return o.getBastionNameCompletions +} + +var timeNowOriginal = timeNow + +func SetTimeNow(f func() time.Time) { + timeNow = f +} + +func GetTimeNow() func() time.Time { + return timeNow +} + +func ResetTimeNow() { + timeNow = timeNowOriginal +} diff --git a/pkg/cmd/ssh/ssh_patch_options.go b/pkg/cmd/ssh/ssh_patch_options.go new file mode 100644 index 00000000..229fe0ac --- /dev/null +++ b/pkg/cmd/ssh/ssh_patch_options.go @@ -0,0 +1,400 @@ +package ssh + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net" + "os/exec" + "path" + "strings" + "time" + + gardenoperationsv1alpha1 "github.com/gardener/gardener/pkg/apis/operations/v1alpha1" + "github.com/spf13/cobra" + networkingv1 "k8s.io/api/networking/v1" + clientauthentication "k8s.io/client-go/pkg/apis/clientauthentication" + "k8s.io/client-go/tools/clientcmd/api" + + gardenClient "github.com/gardener/gardenctl-v2/internal/gardenclient" + "github.com/gardener/gardenctl-v2/internal/util" + "github.com/gardener/gardenctl-v2/pkg/cmd/base" + "github.com/gardener/gardenctl-v2/pkg/target" +) + +// wrappers used for unit tests only +var ( + timeNow = time.Now + getCurrentUser = func(ctx context.Context, gardenClient gardenClient.Client, authInfo *api.AuthInfo) (string, error) { + baseDir, err := api.MakeAbs(path.Dir(authInfo.LocationOfOrigin), "") + if err != nil { + return "", fmt.Errorf("Could not parse location of kubeconfig origin") + } + + if len(authInfo.ClientCertificateData) == 0 && len(authInfo.ClientCertificate) > 0 { + err := api.FlattenContent(&authInfo.ClientCertificate, &authInfo.ClientCertificateData, baseDir) + if err != nil { + return "", err + } + } else if len(authInfo.Token) == 0 && len(authInfo.TokenFile) > 0 { + var tmpValue = []byte{} + err := api.FlattenContent(&authInfo.TokenFile, &tmpValue, baseDir) + if err != nil { + return "", err + } + authInfo.Token = string(tmpValue) + } else if authInfo.Exec != nil && len(authInfo.Exec.Command) > 0 { + // The command originates from the users kubeconfig and is also executed when using kubectl. + // So it should be safe to execute it here as well. + execCmd := exec.Command(authInfo.Exec.Command, authInfo.Exec.Args...) + var out bytes.Buffer + execCmd.Stdout = &out + + err := execCmd.Run() + if err != nil { + return "", err + } + + var execCredential clientauthentication.ExecCredential + err = json.Unmarshal(out.Bytes(), &execCredential) + if err != nil { + return "", err + } + + if token := execCredential.Status.Token; len(token) > 0 { + authInfo.Token = token + } else if cert := execCredential.Status.ClientCertificateData; len(cert) > 0 { + authInfo.ClientCertificateData = []byte(cert) + } + } + + if len(authInfo.ClientCertificateData) > 0 { + block, _ := pem.Decode([]byte(authInfo.ClientCertificateData)) // does not return an error, just nil + if block == nil { + return "", fmt.Errorf("Could not decode PEM certificate") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", err + } + + user := cert.Subject.CommonName + if len(user) > 0 { + return user, nil + } + } + + if len(authInfo.Token) > 0 { + tokenReview, err := gardenClient.CreateTokenReview(ctx, authInfo.Token) + if err != nil { + return "", err + } + if user := tokenReview.Status.User.Username; user != "" { + return user, nil + } + } + + return "", fmt.Errorf("Could not detect current user") + } +) + +//nolint:revive +type SSHPatchOptions struct { + sshBaseOptions + + // BastionName is the patch targets name + BastionName string + // Factory gives access to the gardenclient and target manager + Factory util.Factory + // Bastion is the Bastion corresponding to the provided BastionName + Bastion *gardenoperationsv1alpha1.Bastion +} + +func NewSSHPatchOptions(ioStreams util.IOStreams) *SSHPatchOptions { + return &SSHPatchOptions{ + sshBaseOptions: sshBaseOptions{ + Options: base.Options{ + IOStreams: ioStreams, + }, + }, + } +} + +func getBastion(ctx context.Context, o *SSHPatchOptions, gardenClient gardenClient.Client, currentTarget target.Target) (*gardenoperationsv1alpha1.Bastion, error) { + listOptions := currentTarget.AsListOption() + + shoot, err := gardenClient.FindShoot(ctx, listOptions) + if err != nil { + return nil, err + } + + bastion, err := gardenClient.GetBastion(ctx, shoot.Namespace, o.BastionName) + if err != nil { + return nil, err + } + + if bastion.ObjectMeta.UID == "" { + return nil, fmt.Errorf("Bastion '%s' in namespace '%s' not found", o.BastionName, shoot.Namespace) + } + + return bastion, nil +} + +func getAuthInfo(ctx context.Context, manager target.Manager) (*api.AuthInfo, error) { + currentTarget, err := manager.CurrentTarget() + if err != nil { + return nil, err + } + + gardenTarget := target.NewTarget(currentTarget.GardenName(), "", "", "") + + clientConfig, err := manager.ClientConfig(ctx, gardenTarget) + if err != nil { + return nil, fmt.Errorf("could not retrieve client config for target garden %s: %w", currentTarget.GardenName(), err) + } + + rawConfig, err := clientConfig.RawConfig() + if err != nil { + return nil, fmt.Errorf("could not retrieve raw config: %w", err) + } + + context, ok := rawConfig.Contexts[rawConfig.CurrentContext] + if !ok { + return nil, fmt.Errorf("no context found for current context %s", rawConfig.CurrentContext) + } + + authInfo, ok := rawConfig.AuthInfos[context.AuthInfo] + if !ok { + return nil, fmt.Errorf("no auth info found with name %s", context.AuthInfo) + } + + return authInfo, nil +} + +func getBastionsOfUser(ctx context.Context, gardenClient gardenClient.Client, authInfo *api.AuthInfo) ([]*gardenoperationsv1alpha1.Bastion, error) { + var bastionOfUser []*gardenoperationsv1alpha1.Bastion + + currentUser, err := getCurrentUser(ctx, gardenClient, authInfo) + if err != nil { + return nil, err + } + + list, err := gardenClient.ListBastions(ctx) + if err != nil || len(list.Items) == 0 { + return nil, err + } + + for i := range list.Items { + bastion := list.Items[i] + if createdBy, ok := bastion.Annotations["gardener.cloud/created-by"]; ok { + if createdBy == currentUser { + bastionOfUser = append(bastionOfUser, &bastion) + } + } + } + + return bastionOfUser, nil +} + +func patchBastionIngress(ctx context.Context, o *SSHPatchOptions, gardenClient gardenClient.Client, currentTarget target.Target) error { + var policies []gardenoperationsv1alpha1.BastionIngressPolicy + + oldBastion := o.Bastion.DeepCopy() + + for _, cidr := range o.CIDRs { + if *o.Bastion.Spec.ProviderType == "gcp" { + ip, _, err := net.ParseCIDR(cidr) + if err != nil { + return err + } + + if ip.To4() == nil { + if !o.AutoDetected { + return fmt.Errorf("GCP only supports IPv4: %s", cidr) + } + + fmt.Fprintf(o.IOStreams.Out, "GCP only supports IPv4, skipped CIDR: %s\n", cidr) + + continue // skip + } + } + + policies = append(policies, gardenoperationsv1alpha1.BastionIngressPolicy{ + IPBlock: networkingv1.IPBlock{ + CIDR: cidr, + }, + }) + } + + if len(policies) == 0 { + return errors.New("no ingress policies could be created") + } + + o.Bastion.Spec.Ingress = policies + + return gardenClient.PatchBastion(ctx, o.Bastion, oldBastion) +} + +func getManagerTargetAndGardenClient(f util.Factory) (target.Manager, target.Target, gardenClient.Client, error) { + manager, err := f.Manager() + if err != nil { + return nil, nil, nil, err + } + + currentTarget, err := manager.CurrentTarget() + if err != nil { + return nil, nil, nil, err + } + + gardenClient, err := manager.GardenClient(currentTarget.GardenName()) + if err != nil { + return nil, nil, nil, err + } + + return manager, currentTarget, gardenClient, nil +} + +func (o *SSHPatchOptions) Run(_ util.Factory) error { + _, currentTarget, gardenClient, err := getManagerTargetAndGardenClient(o.Factory) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(o.Factory.Context(), 30*time.Second) + defer cancel() + + return patchBastionIngress(ctx, o, gardenClient, currentTarget) +} + +func formatDuration(d time.Duration) string { + const ( + hFactor int = 3600 + mFactor int = 60 + ) + + s := int(d.Seconds()) + h := int(s / hFactor) + s -= h * hFactor + m := int(s / mFactor) + s -= m * mFactor + + if h > 0 { + return fmt.Sprintf("%dh%dm%ds", h, m, s) + } + + if m > 0 { + return fmt.Sprintf("%dm%ds", m, s) + } + + return fmt.Sprintf("%ds", s) +} + +func (o *SSHPatchOptions) Complete(f util.Factory, cmd *cobra.Command, args []string) error { + o.Factory = f + if len(args) > 0 { + o.BastionName = args[0] + } + + ctx, cancel := context.WithTimeout(o.Factory.Context(), 30*time.Second) + defer cancel() + + manager, currentTarget, gardenClient, err := getManagerTargetAndGardenClient(f) + if err != nil { + return err + } + + if err := o.sshBaseOptions.Complete(o.Factory, cmd, args); err != nil { + return err + } + + authInfo, err := getAuthInfo(ctx, manager) + if err != nil { + return fmt.Errorf("Could not get current authInfo: %w", err) + } + + if o.BastionName == "" { + bastions, err := getBastionsOfUser(ctx, gardenClient, authInfo) + if err != nil { + return err + } + + if len(bastions) == 1 { + b := bastions[0] + name := b.Name + age := formatDuration(timeNow().Sub(b.CreationTimestamp.Time)) + fmt.Fprintf(o.IOStreams.Out, "Auto-selected bastion %q created %s ago targeting shoot \"%s/%s\"\n", name, age, b.Namespace, b.Spec.ShootRef.Name) + o.BastionName = b.Name + o.Bastion = b + } else if len(bastions) == 0 { + fmt.Fprint(o.IOStreams.Out, "No bastions were found\n") + } else { + fmt.Fprint(o.IOStreams.Out, "Multiple bastions were found and the target bastion needs to be explictly defined\n") + } + } else { + bastion, err := getBastion(ctx, o, gardenClient, currentTarget) + if err != nil { + return err + } + o.Bastion = bastion + } + + return nil +} + +func (o *SSHPatchOptions) getBastionNameCompletions(f util.Factory, cmd *cobra.Command, toComplete string) ([]string, error) { + var completions []string + + manager, _, gardenClient, err := getManagerTargetAndGardenClient(f) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(f.Context(), 30*time.Second) + defer cancel() + + authInfo, err := getAuthInfo(ctx, manager) + if err != nil { + return nil, fmt.Errorf("Could not get current authInfo: %w", err) + } + + bastions, err := getBastionsOfUser(ctx, gardenClient, authInfo) + if err != nil { + return nil, err + } + + for _, b := range bastions { + if strings.HasPrefix(b.Name, toComplete) { + age := formatDuration(timeNow().Sub(b.CreationTimestamp.Time)) + + completion := fmt.Sprintf("%s\t created %s ago targeting shoot \"%s/%s\"", b.Name, age, b.Namespace, b.Spec.ShootRef.Name) + completions = append(completions, completion) + } + } + + return completions, nil +} + +func (o *SSHPatchOptions) Validate() error { + if err := o.sshBaseOptions.Validate(); err != nil { + return err + } + + if o.BastionName == "" { + return fmt.Errorf("BastionName is required") + } + + if o.Bastion == nil { + return fmt.Errorf("Bastion is required") + } + + if o.Bastion.Name != o.BastionName { + return fmt.Errorf("BastionName does not match Bastion.Name in SSHPatchOptions") + } + + return nil +} diff --git a/pkg/cmd/ssh/ssh_patch_test.go b/pkg/cmd/ssh/ssh_patch_test.go new file mode 100644 index 00000000..3bfe163f --- /dev/null +++ b/pkg/cmd/ssh/ssh_patch_test.go @@ -0,0 +1,455 @@ +/* +SPDX-FileCopyrightText: 2021 SAP SE or an SAP affiliate company and Gardener contributors + +SPDX-License-Identifier: Apache-2.0 +*/ + +package ssh_test + +import ( + "context" + "reflect" + "time" + + gardencorev1beta1 "github.com/gardener/gardener/pkg/apis/core/v1beta1" + gardenoperationsv1alpha1 "github.com/gardener/gardener/pkg/apis/operations/v1alpha1" + gardensecrets "github.com/gardener/gardener/pkg/utils/secrets" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/clientcmd/api" + "k8s.io/utils/pointer" + + internalfake "github.com/gardener/gardenctl-v2/internal/fake" + gc "github.com/gardener/gardenctl-v2/internal/gardenclient" + gcmocks "github.com/gardener/gardenctl-v2/internal/gardenclient/mocks" + "github.com/gardener/gardenctl-v2/internal/util" + "github.com/gardener/gardenctl-v2/pkg/cmd/ssh" + "github.com/gardener/gardenctl-v2/pkg/config" + "github.com/gardener/gardenctl-v2/pkg/target" + targetmocks "github.com/gardener/gardenctl-v2/pkg/target/mocks" +) + +var _ = Describe("SSH Patch Command", func() { + const ( + gardenName = "mygarden" + gardenKubeconfigFile = "/not/a/real/kubeconfig" + seedName = "test-seed" + shootName = "test-shoot" + ) + + // populated in top level BeforeEach + var ( + ctrl *gomock.Controller + clientProvider *targetmocks.MockClientProvider + cfg *config.Config + streams util.IOStreams + stdout *util.SafeBytesBuffer + factory *internalfake.Factory + gardenClient *gcmocks.MockClient + manager *targetmocks.MockManager + now time.Time + ctx context.Context + cancel context.CancelFunc + currentTarget target.Target + sampleClientCertficate []byte + testProject *gardencorev1beta1.Project + testSeed *gardencorev1beta1.Seed + testShoot *gardencorev1beta1.Shoot + apiConfig *api.Config + bastionDefaultPolicies []gardenoperationsv1alpha1.BastionIngressPolicy + ) + + // helpers + var ( + ctxType = reflect.TypeOf((*context.Context)(nil)).Elem() + isCtx = gomock.AssignableToTypeOf(ctxType) + getMockGetCurrentUserFunc = func(username string, err error) func(context.Context, gc.Client, *api.AuthInfo) (string, error) { + return func(_ context.Context, _ gc.Client, _ *api.AuthInfo) (string, error) { + return username, err + } + } + createBastion = func(createdBy, bastionName string) gardenoperationsv1alpha1.Bastion { + return gardenoperationsv1alpha1.Bastion{ + ObjectMeta: metav1.ObjectMeta{ + Name: bastionName, + Namespace: testShoot.Namespace, + UID: "some UID", + Annotations: map[string]string{ + "gardener.cloud/created-by": createdBy, + }, + CreationTimestamp: metav1.Time{ + Time: now, + }, + }, + Spec: gardenoperationsv1alpha1.BastionSpec{ + ShootRef: corev1.LocalObjectReference{ + Name: testShoot.Name, + }, + SSHPublicKey: "some-dummy-public-key", + Ingress: bastionDefaultPolicies, + ProviderType: pointer.String("aws"), + }, + } + } + ) + + // TODO: after migration to ginkgo v2: move to BeforeAll + func() { + // only run it once and not in BeforeEach as it is an expensive operation + caCertCSC := &gardensecrets.CertificateSecretConfig{ + Name: "issuer-name", + CommonName: "issuer-cn", + CertType: gardensecrets.CACert, + } + caCert, _ := caCertCSC.GenerateCertificate() + + csc := &gardensecrets.CertificateSecretConfig{ + Name: "client-name", + CommonName: "client-cn", + Organization: []string{user.SystemPrivilegedGroup}, + CertType: gardensecrets.ClientCert, + SigningCA: caCert, + } + generatedClientCert, _ := csc.GenerateCertificate() + sampleClientCertficate = generatedClientCert.CertificatePEM + }() + + BeforeEach(func() { + now, _ = time.Parse(time.RFC3339, "2006-01-02T15:04:05Z") + + cfg = &config.Config{ + LinkKubeconfig: pointer.Bool(false), + Gardens: []config.Garden{{ + Name: gardenName, + Kubeconfig: gardenKubeconfigFile, + }}, + } + + apiConfig = api.NewConfig() + apiConfig.Clusters["cluster"] = &api.Cluster{ + Server: "https://kubernetes:6443/", + InsecureSkipTLSVerify: true, + } + apiConfig.Contexts["client-cert"] = &api.Context{ + AuthInfo: "client-cert", + Namespace: "default", + Cluster: "cluster", + } + apiConfig.AuthInfos["client-cert"] = &api.AuthInfo{ + ClientCertificateData: sampleClientCertficate, + } + apiConfig.Contexts["no-auth"] = &api.Context{ + AuthInfo: "no-auth", + Namespace: "default", + Cluster: "cluster", + } + apiConfig.AuthInfos["no-auth"] = &api.AuthInfo{} + apiConfig.CurrentContext = "client-cert" + + testProject = &gardencorev1beta1.Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "prod1", + }, + Spec: gardencorev1beta1.ProjectSpec{ + Namespace: pointer.String("garden-prod1"), + }, + } + + testSeed = &gardencorev1beta1.Seed{ + ObjectMeta: metav1.ObjectMeta{ + Name: seedName, + }, + } + + testShoot = &gardencorev1beta1.Shoot{ + ObjectMeta: metav1.ObjectMeta{ + Name: shootName, + Namespace: *testProject.Spec.Namespace, + }, + Spec: gardencorev1beta1.ShootSpec{ + SeedName: pointer.String(testSeed.Name), + Kubernetes: gardencorev1beta1.Kubernetes{ + Version: "1.20.0", // >= 1.20.0 for non-legacy shoot kubeconfigs + }, + }, + Status: gardencorev1beta1.ShootStatus{ + AdvertisedAddresses: []gardencorev1beta1.ShootAdvertisedAddress{ + { + Name: "shoot-address1", + URL: "https://api.bar.baz", + }, + }, + }, + } + + bastionDefaultPolicies = []gardenoperationsv1alpha1.BastionIngressPolicy{{ + IPBlock: networkingv1.IPBlock{ + CIDR: "1.1.1.1/16", + }, + }, { + IPBlock: networkingv1.IPBlock{ + CIDR: "dead:beef::/64", + }, + }} + + streams, _, stdout, _ = util.NewTestIOStreams() + currentTarget = target.NewTarget(gardenName, testProject.Name, testSeed.Name, testShoot.Name) + + ctrl = gomock.NewController(GinkgoT()) + gardenClient = gcmocks.NewMockClient(ctrl) + clientProvider = targetmocks.NewMockClientProvider(ctrl) + targetProvider := internalfake.NewFakeTargetProvider(currentTarget) + + manager = targetmocks.NewMockManager(ctrl) + manager.EXPECT().ClientConfig(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _ target.Target) (clientcmd.ClientConfig, error) { + // DoAndReturn allows us to modify the apiConfig within the testcase + clientcmdConfig := clientcmd.NewDefaultClientConfig(*apiConfig, nil) + return clientcmdConfig, nil + }).AnyTimes() + manager.EXPECT().CurrentTarget().Return(currentTarget, nil).AnyTimes() + manager.EXPECT().GardenClient(gomock.Eq(gardenName)).Return(gardenClient, nil).AnyTimes() + + factory = internalfake.NewFakeFactory(cfg, nil, clientProvider, targetProvider) + factory.ManagerImpl = manager + + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) + factory.ContextImpl = ctx + }) + + AfterEach(func() { + cancel() + ctrl.Finish() + ssh.ResetGetCurrentUser() + ssh.ResetTimeNow() + }) + + Describe("Validate", func() { + var fakeBastion gardenoperationsv1alpha1.Bastion + + BeforeEach(func() { + fakeBastion = createBastion("user", "bastion-name") + }) + + It("Should fail when no CIDRs are provided", func() { + o := ssh.NewSSHPatchOptions(streams) + o.BastionName = fakeBastion.Name + o.Bastion = &fakeBastion + Expect(o.Validate()).NotTo(Succeed()) + }) + + It("Should fail when Bastion is nil", func() { + o := ssh.NewSSHPatchOptions(streams) + o.CIDRs = append(o.CIDRs, "1.1.1.1/16") + o.BastionName = fakeBastion.Name + Expect(o.Validate()).NotTo(Succeed()) + }) + + It("Should fail when BastionName is nil", func() { + o := ssh.NewSSHPatchOptions(streams) + o.CIDRs = append(o.CIDRs, "1.1.1.1/16") + o.Bastion = &fakeBastion + Expect(o.Validate()).NotTo(Succeed()) + }) + + It("Should fail when BastionName does not equal Bastion.Name", func() { + o := ssh.NewSSHPatchOptions(streams) + o.CIDRs = append(o.CIDRs, "1.1.1.1/16") + o.BastionName = "foo" + o.Bastion = &fakeBastion + Expect(o.Validate()).NotTo(Succeed()) + }) + }) + + Describe("Complete", func() { + var fakeBastionList *gardenoperationsv1alpha1.BastionList + + BeforeEach(func() { + fakeBastionList = &gardenoperationsv1alpha1.BastionList{ + Items: []gardenoperationsv1alpha1.Bastion{ + createBastion("user1", "user1-bastion1"), + createBastion("user2", "user2-bastion1"), + createBastion("user2", "user2-bastion2"), + }, + } + }) + + Describe("Auto-completion of the bastion name when it is not provided by user", func() { + It("should fail if no bastions created by current user exist", func() { + o := ssh.NewSSHPatchOptions(streams) + cmd := ssh.NewCmdSSHPatch(factory, streams) + + gardenClient.EXPECT().ListBastions(isCtx, gomock.Any()).Return(fakeBastionList, nil).Times(1) + ssh.SetGetCurrentUser(getMockGetCurrentUserFunc("user-wo-bastions", nil)) + + err := o.Complete(factory, cmd, []string{}) + out := stdout.String() + + Expect(err).To(BeNil(), "Should not return an error") + Expect(o.BastionName).To(Equal(""), "bastion name should not be set in SSHPatchOptions") + Expect(out).To(ContainSubstring("No bastions were found")) + }) + + It("should succeed if exactly one bastion created by current user exists", func() { + o := ssh.NewSSHPatchOptions(streams) + cmd := ssh.NewCmdSSHPatch(factory, streams) + + gardenClient.EXPECT().ListBastions(isCtx, gomock.Any()).Return(fakeBastionList, nil).Times(1) + ssh.SetGetCurrentUser(getMockGetCurrentUserFunc("user1", nil)) + + err := o.Complete(factory, cmd, []string{}) + out := stdout.String() + + Expect(out).To(ContainSubstring("Auto-selected bastion")) + Expect(err).To(BeNil(), "Should not return an error") + Expect(o.BastionName).To(Equal("user1-bastion1"), "Should set bastion name in SSHPatchOptions to the one bastion the user has created") + Expect(o.Bastion).ToNot(BeNil()) + Expect(o.Factory).ToNot(BeNil()) + }) + + It("should fail if more then one bastion created by current user exists", func() { + o := ssh.NewSSHPatchOptions(streams) + cmd := ssh.NewCmdSSHPatch(factory, streams) + + gardenClient.EXPECT().ListBastions(isCtx, gomock.Any()).Return(fakeBastionList, nil).Times(1) + ssh.SetGetCurrentUser(getMockGetCurrentUserFunc("user2", nil)) + + err := o.Complete(factory, cmd, []string{}) + out := stdout.String() + + Expect(err).To(BeNil(), "Should not return an error") + Expect(o.BastionName).To(Equal(""), "bastion name should not be set in SSHPatchOptions") + Expect(out).To(ContainSubstring("Multiple bastions were found")) + }) + }) + + Describe("Bastion for provided bastion name should be loaded", func() { + It("should succeed if the bastion with the name provided exists", func() { + bastionName := "user1-bastion1" + fakeBastion := createBastion("user1", bastionName) + o := ssh.NewSSHPatchOptions(streams) + cmd := ssh.NewCmdSSHPatch(factory, streams) + + gardenClient.EXPECT().GetBastion(isCtx, gomock.Any(), gomock.Eq(bastionName)).Return(&fakeBastion, nil).Times(1) + gardenClient.EXPECT().FindShoot(isCtx, gomock.Any()).Return(testShoot, nil).Times(1) + ssh.SetGetCurrentUser(getMockGetCurrentUserFunc("user1", nil)) + + err := o.Complete(factory, cmd, []string{bastionName}) + + Expect(err).To(BeNil(), "Should not return an error") + Expect(o.BastionName).To(Equal(bastionName), "Should set bastion name in SSHPatchOptions to the value of args[0]") + Expect(o.Bastion).ToNot(BeNil()) + Expect(o.Factory).ToNot(BeNil()) + }) + }) + + It("The flag completion (suggestion) func should return names of ", func() { + o := ssh.NewSSHPatchOptions(streams) + cmd := ssh.NewCmdSSHPatch(factory, streams) + + ssh.SetGetCurrentUser(getMockGetCurrentUserFunc("user2", nil)) + ssh.SetTimeNow(func() time.Time { + return now.Add(time.Second * 3728) + }) + gardenClient.EXPECT().ListBastions(isCtx, gomock.Any()).Return(fakeBastionList, nil).Times(1) + + sut := ssh.GetGetBastionNameCompletions(o) + suggestions, err := sut(factory, cmd, "") + + Expect(err).To(BeNil()) + Expect(suggestions).ToNot(BeNil()) + Expect(len(suggestions)).To(Equal(2)) + Expect(suggestions[0]).To(ContainSubstring("user2-bastion1\t created 1h2m8s ago")) + Expect(suggestions[1]).To(ContainSubstring("user2-bastion2\t created 1h2m8s ago")) + }) + }) + + Describe("Run", func() { + var fakeBastion *gardenoperationsv1alpha1.Bastion + + BeforeEach(func() { + tmp := createBastion("fake-created-by", "fake-bastion-name") + fakeBastion = &tmp + }) + + It("It should update the bastion ingress policy", func() { + o := ssh.NewSSHPatchOptions(streams) + o.CIDRs = []string{"8.8.8.8/16"} + o.BastionName = fakeBastion.Name + o.Factory = factory + o.Bastion = fakeBastion + + ctxType := reflect.TypeOf((*context.Context)(nil)).Elem() + isCtx := gomock.AssignableToTypeOf(ctxType) + gardenClient.EXPECT().PatchBastion(isCtx, gomock.Any(), gomock.Any()).Return(nil).Times(1) + + err := o.Run(nil) + Expect(err).To(BeNil()) + + Expect(len(fakeBastion.Spec.Ingress)).To(Equal(1), "Should only have one Ingress policy (had 2)") + Expect(fakeBastion.Spec.Ingress[0].IPBlock.CIDR).To(Equal(o.CIDRs[0])) + }) + }) + + Describe("getCurrentUser", func() { + var getCurrentUserFn func(ctx context.Context, gardenclient gc.Client, authInfo *api.AuthInfo) (string, error) + var getAuthInfoFn func(ctx context.Context, manager target.Manager) (*api.AuthInfo, error) + + BeforeEach(func() { + ssh.ResetGetCurrentUser() + getCurrentUserFn = ssh.GetGetCurrentUser() + getAuthInfoFn = ssh.GetGetAuthInfo() + }) + + It("getAuthInfo should return the currently active auth info", func() { + apiConfig.CurrentContext = "client-cert" + resultingAuthInfo, err := getAuthInfoFn(ctx, manager) + + Expect(err).To(BeNil()) + Expect(resultingAuthInfo).ToNot(BeNil()) + Expect(len(resultingAuthInfo.ClientCertificateData)).ToNot(Equal(0)) + + apiConfig.CurrentContext = "no-auth" + resultingAuthInfo, err = getAuthInfoFn(ctx, manager) + + Expect(err).To(BeNil()) + Expect(resultingAuthInfo).ToNot(BeNil()) + Expect(len(resultingAuthInfo.ClientCertificateData)).To(Equal(0)) + }) + + It("Should return the user when a Token is used", func() { + token := "an-arbitrary-token" + user := "an-arbitrary-user" + + reviewResult := &authenticationv1.TokenReview{ + Status: authenticationv1.TokenReviewStatus{ + User: authenticationv1.UserInfo{ + Username: user, + }, + }, + } + gardenClient.EXPECT().CreateTokenReview(gomock.Eq(ctx), gomock.Eq(token)).Return(reviewResult, nil).Times(1) + + username, err := getCurrentUserFn(ctx, gardenClient, &api.AuthInfo{ + Token: token, + }) + + Expect(err).To(BeNil()) + Expect(username).To(Equal(user)) + }) + + It("Should return the user when a client certificate is used", func() { + username, err := getCurrentUserFn(ctx, gardenClient, &api.AuthInfo{ + ClientCertificateData: sampleClientCertficate, + }) + Expect(err).To(BeNil()) + Expect(username).To(Equal("client-cn")) + }) + }) +})