Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ssh: Add output flag #258

Merged
merged 10 commits into from
Mar 29, 2023
Prev Previous commit
Next Next commit
add output flag
  • Loading branch information
petersutter committed Mar 29, 2023
commit 7a470a1c7fbbceafc7c78729a43ca53d82c918e0
7 changes: 5 additions & 2 deletions pkg/cmd/base/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,11 @@ func (o *Options) AddFlags(flags *pflag.FlagSet) {
func (o *Options) PrintObject(obj interface{}) error {
switch o.Output {
case "":
fmt.Fprintf(o.IOStreams.Out, "%v", obj)

if _, ok := obj.(fmt.Stringer); ok {
fmt.Fprintf(o.IOStreams.Out, "%s", obj)
} else {
fmt.Fprintf(o.IOStreams.Out, "%v", obj)
}
case "yaml":
marshalled, err := yaml.Marshal(&obj)
if err != nil {
Expand Down
5 changes: 1 addition & 4 deletions pkg/cmd/ssh/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@ import (
"context"
"os"
"time"

operationsv1alpha1 "github.com/gardener/gardener/pkg/apis/operations/v1alpha1"
"sigs.k8s.io/controller-runtime/pkg/client"
)

func SetBastionAvailabilityChecker(f func(hostname string, privateKey []byte) error) {
Expand Down Expand Up @@ -46,6 +43,6 @@ func SetKeepAliveInterval(d time.Duration) {
keepAliveInterval = d
}

func SetWaitForSignal(f func(ctx context.Context, o *SSHOptions, shootClient client.Client, bastion *operationsv1alpha1.Bastion, nodeHostname string, nodePrivateKeyFiles []string, signalChan <-chan struct{}) error) {
func SetWaitForSignal(f func(ctx context.Context, o *SSHOptions, signalChan <-chan struct{})) {
waitForSignal = f
}
151 changes: 42 additions & 109 deletions pkg/cmd/ssh/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,8 @@ import (
networkingv1 "k8s.io/api/networking/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/cli-runtime/pkg/printers"
"k8s.io/klog/v2"
"sigs.k8s.io/controller-runtime/pkg/client"

Expand Down Expand Up @@ -152,102 +150,15 @@ var (
return cmd.Run()
}

// waitForSignal informs the user about their SSHOptions and keeps the
// waitForSignal informs the user how to stop gardenctl and keeps the
// bastion alive until gardenctl exits.
waitForSignal = func(ctx context.Context, o *SSHOptions, shootClient client.Client, bastion *operationsv1alpha1.Bastion, nodeHostname string, nodePrivateKeyFiles []string, signalChan <-chan struct{}) error {
if nodeHostname == "" {
nodeHostname = "IP_OR_HOSTNAME"

nodes, err := getNodes(ctx, shootClient)
if err != nil {
return fmt.Errorf("failed to list shoot cluster nodes: %w", err)
}

table := &metav1beta1.Table{
ColumnDefinitions: []metav1.TableColumnDefinition{
{
Name: "Node Name",
Type: "string",
Format: "name",
},
{
Name: "Status",
Type: "string",
},
{
Name: "IP",
Type: "string",
},
{
Name: "Hostname",
Type: "string",
},
},
Rows: []metav1.TableRow{},
}

for _, node := range nodes {
ip := ""
hostname := ""
status := "Ready"

if !isNodeReady(node) {
status = "Not Ready"
}

for _, addr := range node.Status.Addresses {
switch addr.Type {
case corev1.NodeInternalIP:
ip = addr.Address

case corev1.NodeInternalDNS:
hostname = addr.Address

// internal names have priority, as we jump via a bastion host,
// but in case the cloud provider does not offer internal IPs,
// we fallback to external values

case corev1.NodeExternalIP:
if ip == "" {
ip = addr.Address
}

case corev1.NodeExternalDNS:
if hostname == "" {
hostname = addr.Address
}
}
}

table.Rows = append(table.Rows, metav1.TableRow{
Cells: []interface{}{node.Name, status, ip, hostname},
})
}

fmt.Fprintln(o.IOStreams.Out, "The shoot cluster has the following nodes:")
fmt.Fprintln(o.IOStreams.Out, "")

printer := printers.NewTablePrinter(printers.PrintOptions{})
if err := printer.PrintObj(table, o.IOStreams.Out); err != nil {
return fmt.Errorf("failed to output node table: %w", err)
}

fmt.Fprintln(o.IOStreams.Out, "")
waitForSignal = func(ctx context.Context, o *SSHOptions, signalChan <-chan struct{}) {
if o.Output == "" {
fmt.Fprintln(o.IOStreams.Out, "Press Ctrl-C to stop gardenctl, after which the bastion will be removed.")
}

bastionAddr := preferredBastionAddress(bastion)
connectCmd := sshCommandLine(o, bastionAddr, nodePrivateKeyFiles, nodeHostname)

fmt.Fprintln(o.IOStreams.Out, "Connect to shoot nodes by using the bastion as a proxy/jump host, for example:")
fmt.Fprintln(o.IOStreams.Out, "")
fmt.Fprintln(o.IOStreams.Out, connectCmd)
fmt.Fprintln(o.IOStreams.Out, "")

fmt.Fprintln(o.IOStreams.Out, "Press Ctrl-C to stop gardenctl, after which the bastion will be removed.")

// keep the bastion alive until gardenctl exits
<-signalChan

return nil
}
)

Expand Down Expand Up @@ -322,10 +233,14 @@ func (o *SSHOptions) AddFlags(flagSet *pflag.FlagSet) {
flagSet.BoolVar(&o.KeepBastion, "keep-bastion", o.KeepBastion, "Do not delete immediately when gardenctl exits (Bastions will be garbage-collected after some time)")
flagSet.BoolVar(&o.SkipAvailabilityCheck, "skip-availability-check", o.SkipAvailabilityCheck, "Skip checking for SSH bastion host availability.")
flagSet.BoolVar(&o.NoKeepalive, "no-keepalive", o.NoKeepalive, "Exit after the bastion host became available without keeping the bastion alive or establishing an SSH connection. Note that this flag requires the flags --interactive=false and --keep-bastion to be set")
o.Options.AddFlags(flagSet)
}

// Complete adapts from the command line args to the data required.
func (o *SSHOptions) Complete(f util.Factory, cmd *cobra.Command, args []string) error {
ctx := f.Context()
logger := klog.FromContext(ctx)

if err := o.AccessConfig.Complete(f, cmd, args); err != nil {
return err
}
Expand All @@ -352,6 +267,12 @@ func (o *SSHOptions) Complete(f util.Factory, cmd *cobra.Command, args []string)
o.NodeName = strings.TrimSpace(args[0])
}

if o.NodeName == "" && o.Interactive {
logger.V(4).Info("no node name given, switching to non-interactive mode")

o.Interactive = false
}

return nil
}

Expand Down Expand Up @@ -404,6 +325,12 @@ func (o *SSHOptions) Validate() error {
}
}

if o.Output != "" {
if o.Interactive {
return errors.New("set --interactive=false when using the output flag")
}
}

content, err := os.ReadFile(o.SSHPublicKeyFile.String())
if err != nil {
return fmt.Errorf("invalid SSH public key file: %w", err)
Expand Down Expand Up @@ -673,15 +600,31 @@ func (o *SSHOptions) Run(f util.Factory) error {

logger.Info("Bastion host became available.", "address", toAdress(bastion.Status.Ingress).String())

if !o.Interactive {
var nodes []corev1.Node
if nodeHostname == "" {
nodes, err = getNodes(ctx, shootClient)
if err != nil {
return fmt.Errorf("failed to list shoot cluster nodes: %w", err)
}
}

connectInformation, err := NewConnectInformation(bastion, nodeHostname, o.SSHPublicKeyFile, o.SSHPrivateKeyFile, nodePrivateKeyFiles, nodes)
if err != nil {
return err
}

if err := o.PrintObject(connectInformation); err != nil {
return err
}

if o.NoKeepalive {
return nil
}
if o.NoKeepalive {
return nil
}

if nodeHostname == "" || !o.Interactive {
return waitForSignal(ctx, o, shootClient, bastion, nodeHostname, nodePrivateKeyFiles, ctx.Done())
waitForSignal(ctx, o, ctx.Done())

return nil
}

return remoteShell(ctx, o, bastion, nodeHostname, nodePrivateKeyFiles)
Expand Down Expand Up @@ -924,16 +867,6 @@ func remoteShell(ctx context.Context, o *SSHOptions, bastion *operationsv1alpha1
return execCommand(ctx, "ssh", args, o)
}

func isNodeReady(node corev1.Node) bool {
for _, cond := range node.Status.Conditions {
if cond.Type == corev1.NodeReady {
return cond.Status == corev1.ConditionTrue
}
}

return false
}

func sshCommandLine(sshPrivateKeyFile PrivateKeyFile, bastionAddr string, nodePrivateKeyFiles []string, nodeName string) string {
proxyPrivateKeyFlag := ""
if sshPrivateKeyFile != "" {
Expand Down
61 changes: 56 additions & 5 deletions pkg/cmd/ssh/ssh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package ssh_test

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -491,13 +492,15 @@ var _ = Describe("SSH Command", func() {
})

// Once the waitForSignal function is called we delete the bastion
ssh.SetWaitForSignal(func(ctx context.Context, o *ssh.SSHOptions, shootClient client.Client, bastion *operationsv1alpha1.Bastion, nodeHostname string, nodePrivateKeyFiles []string, signalChan <-chan struct{}) error {
ssh.SetWaitForSignal(func(ctx context.Context, o *ssh.SSHOptions, signalChan <-chan struct{}) {
By("deleting bastion")
bastion := &operationsv1alpha1.Bastion{}
key := types.NamespacedName{Name: bastionName, Namespace: *testProject.Spec.Namespace}
Expect(gardenClient.Get(ctx, key, bastion)).To(Succeed())

Expect(gardenClient.Delete(ctx, bastion)).To(Succeed())

<-signalChan

return nil
})

// let the magic happen
Expand Down Expand Up @@ -534,11 +537,36 @@ var _ = Describe("SSH Command", func() {

cmd := ssh.NewCmdSSH(factory, options)

ssh.SetWaitForSignal(func(ctx context.Context, o *ssh.SSHOptions, shootClient client.Client, bastion *operationsv1alpha1.Bastion, nodeHostname string, nodePrivateKeyFiles []string, signalChan <-chan struct{}) error {
ssh.SetWaitForSignal(func(ctx context.Context, o *ssh.SSHOptions, signalChan <-chan struct{}) {
Fail("this function should not be executed as of NoKeepalive = true")
})
ssh.SetExecCommand(func(ctx context.Context, command string, args []string, o *ssh.SSHOptions) error {
err := errors.New("this function should not be executed as of NoKeepalive = true")
Fail(err.Error())
return err
})

// simulate an external controller processing the bastion and proving a successful status
go waitForBastionThenSetBastionReady(ctx, gardenClient, bastionName, *testProject.Spec.Namespace, bastionHostname, bastionIP)

Expect(cmd.RunE(cmd, nil)).To(Succeed())

Expect(logs.String()).To(ContainSubstring("Bastion host became available."))
})

It("should output as json", func() {
options := ssh.NewSSHOptions(streams)
options.NoKeepalive = true
options.KeepBastion = true
options.Interactive = false

options.Output = "json"

cmd := ssh.NewCmdSSH(factory, options)

ssh.SetWaitForSignal(func(ctx context.Context, o *ssh.SSHOptions, signalChan <-chan struct{}) {
Fail("this function should not be executed as of NoKeepalive = true")
})
ssh.SetExecCommand(func(ctx context.Context, command string, args []string, o *ssh.SSHOptions) error {
err := errors.New("this function should not be executed as of NoKeepalive = true")
Fail(err.Error())
Expand All @@ -550,7 +578,13 @@ var _ = Describe("SSH Command", func() {

Expect(cmd.RunE(cmd, nil)).To(Succeed())

Expect(logs.String()).To(ContainSubstring("Bastion host became available."))
var info ssh.ConnectInformation
Expect(json.Unmarshal([]byte(out.String()), &info)).To(Succeed())
Expect(info.Bastion.Name).To(Equal(bastionName))
Expect(info.Bastion.PreferredAddress).To(Equal("0.0.0.0"))
Expect(info.Bastion.SSHPrivateKeyFile).To(Equal(options.SSHPrivateKeyFile))
Expect(info.Bastion.SSHPublicKeyFile).To(Equal(options.SSHPublicKeyFile))
Expect(info.NodePrivateKeyFiles).NotTo(BeEmpty())
})
})

Expand Down Expand Up @@ -648,6 +682,23 @@ var _ = Describe("SSH Options", func() {
})
})

Describe("output flag not empty", func() {
BeforeEach(func() {
o.Output = "yaml" // or json - does not matter

o.Interactive = false
})

It("should validate", func() {
Expect(o.Validate()).Should(Succeed())
})

It("should require non-interactive mode", func() {
o.Interactive = true

Expect(o.Validate()).NotTo(Succeed())
})
})
It("should require a public SSH key file", func() {
o := ssh.NewSSHOptions(streams)
o.CIDRs = []string{"8.8.8.8/32"}
Expand Down
Loading