Skip to content

Commit 1bcd864

Browse files
authored
Merge pull request #37 from alicefr/ssh-command
Add the ability to execute any command via ssh
2 parents e476d9b + 4bbe55b commit 1bcd864

6 files changed

Lines changed: 125 additions & 13 deletions

File tree

cmd/bink/main.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package main
55

66
import (
7+
"errors"
78
"fmt"
89
"os"
910

@@ -54,6 +55,9 @@ Common workflows:
5455
# SSH into a node
5556
bink node ssh node1
5657
58+
# Run a command on a node
59+
bink node ssh node1 -- uname -a
60+
5761
# Tear down
5862
bink cluster stop --remove-data`,
5963
PersistentPreRun: func(cmd *cobra.Command, args []string) {
@@ -120,6 +124,10 @@ func initConfig() {
120124

121125
func main() {
122126
if err := rootCmd.Execute(); err != nil {
127+
var exitErr *cli.ExitCodeError
128+
if errors.As(err, &exitErr) {
129+
os.Exit(exitErr.Code)
130+
}
123131
fmt.Fprintln(os.Stderr, err)
124132
os.Exit(1)
125133
}

internal/cli/exitcode.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// SPDX-FileCopyrightText: 2026 The bink Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package cli
5+
6+
import "fmt"
7+
8+
type ExitCodeError struct {
9+
Code int
10+
}
11+
12+
func (e *ExitCodeError) Error() string {
13+
return fmt.Sprintf("exit code %d", e.Code)
14+
}

internal/cli/node/ssh.go

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package node
66
import (
77
"context"
88
"fmt"
9+
"strings"
910

1011
"github.com/sirupsen/logrus"
1112
"github.com/spf13/cobra"
@@ -18,17 +19,42 @@ import (
1819

1920
func newSSHCmd() *cobra.Command {
2021
cmd := &cobra.Command{
21-
Use: "ssh <node-name>",
22+
Use: "ssh <node-name> [-- command [args...]]",
2223
Short: "SSH into a node's VM",
23-
Long: "Start an interactive SSH session to a node's VM",
24+
Long: "Start an interactive SSH session to a node's VM, or run a command and exit.",
2425
Example: ` # SSH into the control-plane node
2526
bink node ssh node1
2627
2728
# SSH into a worker node in a named cluster
28-
bink node ssh node2 --cluster-name dev`,
29-
Args: cobra.ExactArgs(1),
30-
ValidArgsFunction: cli.CompleteNodeNames,
31-
RunE: runSSH,
29+
bink node ssh node2 --cluster-name dev
30+
31+
# Run a command on a node
32+
bink node ssh node1 -- uname -a
33+
34+
# Run kubectl on the control-plane node
35+
bink node ssh node1 -- sudo kubectl --kubeconfig=/etc/kubernetes/admin.conf get nodes`,
36+
Args: func(cmd *cobra.Command, args []string) error {
37+
dash := cmd.ArgsLenAtDash()
38+
switch {
39+
case dash == -1 && len(args) == 1:
40+
return nil
41+
case dash == 1:
42+
return nil
43+
case dash == -1 && len(args) == 0:
44+
return fmt.Errorf("requires a node name")
45+
case dash == -1:
46+
return fmt.Errorf("extra arguments %v; use -- to separate the remote command", args[1:])
47+
default:
48+
return fmt.Errorf("exactly one node name is required before --")
49+
}
50+
},
51+
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
52+
if len(args) > 0 {
53+
return nil, cobra.ShellCompDirectiveNoFileComp
54+
}
55+
return cli.CompleteNodeNames(cmd, args, toComplete)
56+
},
57+
RunE: runSSH,
3258
}
3359

3460
return cmd
@@ -40,19 +66,25 @@ func runSSH(cmd *cobra.Command, args []string) error {
4066
ctx := context.Background()
4167
logger := logrus.New()
4268

43-
// Get cluster name
4469
clusterName := viper.GetString("cluster.name")
45-
46-
// Get node IP for display
47-
clusterIP := node.CalculateClusterIP(clusterName, nodeName)
48-
49-
// Create SSH client
5070
sshClient := ssh.NewClientForNode(clusterName, nodeName, logger)
5171

72+
if cmd.ArgsLenAtDash() == 1 {
73+
command := strings.Join(args[1:], " ")
74+
exitCode, err := sshClient.ExecStream(ctx, command)
75+
if err != nil {
76+
return fmt.Errorf("failed to execute command on %s: %w", nodeName, err)
77+
}
78+
if exitCode != 0 {
79+
return &cli.ExitCodeError{Code: exitCode}
80+
}
81+
return nil
82+
}
83+
84+
clusterIP := node.CalculateClusterIP(clusterName, nodeName)
5285
fmt.Printf("Connecting to %s (SSH: %s:%s, cluster: %s) as user core\n",
5386
nodeName, ssh.DefaultSSHHost, ssh.DefaultSSHPort, clusterIP)
5487

55-
// Start interactive session
5688
if err := sshClient.Interactive(ctx); err != nil {
5789
return fmt.Errorf("failed to connect to %s: %w", nodeName, err)
5890
}

internal/podman/client.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,41 @@ func (c *Client) ContainerExecQuiet(ctx context.Context, name string, cmd []stri
405405
return nil
406406
}
407407

408+
func (c *Client) ContainerExecStream(ctx context.Context, name string, cmd []string) (int, error) {
409+
if err := c.ensureConnection(); err != nil {
410+
return -1, err
411+
}
412+
413+
logrus.Debugf("Executing (stream): %s %s", name, strings.Join(cmd, " "))
414+
415+
execConfig := &handlers.ExecCreateConfig{}
416+
execConfig.Cmd = cmd
417+
execConfig.AttachStdout = true
418+
execConfig.AttachStderr = true
419+
420+
sessionID, err := containers.ExecCreate(c.withCtx(ctx), name, execConfig)
421+
if err != nil {
422+
return -1, fmt.Errorf("creating exec session: %w", err)
423+
}
424+
425+
startOptions := new(containers.ExecStartAndAttachOptions).
426+
WithOutputStream(os.Stdout).
427+
WithErrorStream(os.Stderr).
428+
WithAttachOutput(true).
429+
WithAttachError(true)
430+
431+
if err := containers.ExecStartAndAttach(c.withCtx(ctx), sessionID, startOptions); err != nil {
432+
return -1, fmt.Errorf("executing command: %w", err)
433+
}
434+
435+
inspectData, err := containers.ExecInspect(c.withCtx(ctx), sessionID, nil)
436+
if err != nil {
437+
return -1, fmt.Errorf("inspecting exec session: %w", err)
438+
}
439+
440+
return inspectData.ExitCode, nil
441+
}
442+
408443
func (c *Client) ContainerExecInteractive(ctx context.Context, name string, cmd []string) error {
409444
if err := c.ensureConnection(); err != nil {
410445
return err

internal/ssh/ssh.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@ func (c *Client) ExecWithOutput(ctx context.Context, command string) error {
8585
return nil
8686
}
8787

88+
// ExecStream executes a command via SSH, streaming output to stdout/stderr without TTY.
89+
// Returns the remote command's exit code.
90+
func (c *Client) ExecStream(ctx context.Context, command string) (int, error) {
91+
sshArgs := c.buildSSHArgs(command)
92+
execCmd := append([]string{"ssh"}, sshArgs...)
93+
94+
c.logger.Debugf("Running in container %s: %s", c.containerName, strings.Join(execCmd, " "))
95+
96+
return c.podmanClient.ContainerExecStream(ctx, c.containerName, execCmd)
97+
}
98+
8899
// Interactive starts an interactive SSH session
89100
func (c *Client) Interactive(ctx context.Context) error {
90101
sshArgs := c.buildSSHArgs("")

test/integration/cluster_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,18 @@ var _ = Describe("Cluster Lifecycle", func() {
8686
_, hasCP := n1.Labels["node-role.kubernetes.io/control-plane"]
8787
Expect(hasCP).To(BeTrue(), "%s should have control-plane role", customNodeName)
8888

89+
By("Verifying bink node ssh can run a command on the node")
90+
sshCmd := helpers.BinkCmd("node", "ssh", customNodeName, "--cluster-name", clusterName, "--", "uname", "-n")
91+
sshSession := helpers.RunCommand(sshCmd)
92+
Expect(sshSession.ExitCode()).To(Equal(0))
93+
sshOutput := string(sshSession.Out.Contents())
94+
Expect(sshOutput).To(ContainSubstring(customNodeName), "SSH command output should contain the node hostname")
95+
96+
By("Verifying bink node ssh propagates non-zero exit codes")
97+
failCmd := helpers.BinkCmd("node", "ssh", customNodeName, "--cluster-name", clusterName, "--", "exit", "42")
98+
failSession := helpers.RunCommand(failCmd)
99+
Expect(failSession.ExitCode()).To(Equal(42), "SSH command should propagate the remote exit code")
100+
89101
By("Verifying Calico CNI is running")
90102
helpers.WaitForPodReady(kubeClient, "kube-system", "k8s-app=calico-node", 3*time.Minute)
91103

0 commit comments

Comments
 (0)