Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,61 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- name: setup Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod

- name: install login shells
run: sudo apt-get update && sudo apt-get install -y bash zsh fish openssh-server

- name: unit tests
run: go test -race -covermode=atomic . ./cmd/... ./hack/... ./pkg/...
env:
CGO_ENABLED: "1"

- name: install goreleaser
- name: disk usage before snapshot
run: |
df -h /tmp "$RUNNER_TEMP" "$GITHUB_WORKSPACE"
du -sh "$(go env GOCACHE)" "$(go env GOMODCACHE)"

- name: build snapshot artifacts
uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7
with:
version: "~> v2"
args: release --snapshot --clean
args: release --snapshot --clean --parallelism=1

- name: disk usage after snapshot
if: always()
run: |
df -h /tmp "$RUNNER_TEMP" "$GITHUB_WORKSPACE"
du -sh "$(go env GOCACHE)" "$(go env GOMODCACHE)"

- name: provision login shell accounts
run: |
sudo service ssh start
for shell in bash zsh fish; do
sudo useradd --create-home --shell "$(command -v "$shell")" "devsy-$shell"
done

- name: integration tests
working-directory: ./e2e
run: go test -v -ginkgo.v -timeout 3600s --ginkgo.label-filter=integration
env:
DEVSY_E2E_SHELL_MATRIX: "true"

windows-stdin:
name: Windows redirected SSH stdin
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
Comment thread
skevetter marked this conversation as resolved.
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
- name: test redirected pipe bytes and cancellation
run: go test ./pkg/ssh -run TestWindows -timeout 60s
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,17 @@ This provider has the following options:
# Extra

For more detail, see the [Devsy Documentation](https://devsy.sh/docs/managing-providers/what-are-providers).

### Remote shell requirements

The SSH server must run Linux with `/bin/sh` and the standard base tools used by
Devsy (`uname`, `id`, `mkdir`, `test`, `cat`, `chmod`, and `mv`). Bash, Zsh, and Fish
are supported as the SSH account login shell. Bootstrap scripts execute through
`/bin/sh`; the login shell does not need to understand POSIX script syntax.
Login startup files must leave stdout clean because it carries the bootstrap
handshake and tunnel bytes. Send diagnostics to stderr.

The SSH login shell is independent of the shell inside the workspace container.
`DOCKER_PATH` selects one executable path, including paths containing spaces; it
is not a command line with flags. The provider sends the bootstrap as a command
argument and reserves stdin for protocol data. It does not require SCP.
17 changes: 11 additions & 6 deletions cmd/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,23 @@ import (
"github.com/spf13/cobra"
)

// CommandCmd holds the cmd flags
// CommandCmd holds the cmd flags.
type CommandCmd struct{}

// NewCommandCmd defines a command
// NewCommandCmd defines a command.
func NewCommandCmd() *cobra.Command {
cmd := &CommandCmd{}
commandCmd := &cobra.Command{
Use: "command",
Short: "Command an instance",
RunE: func(_ *cobra.Command, args []string) error {
RunE: func(cobraCmd *cobra.Command, args []string) error {
sshProvider, err := ssh.NewProvider()
if err != nil {
return err
}

return cmd.Run(
context.Background(),
cobraCmd.Context(),
sshProvider,
)
},
Expand All @@ -34,7 +34,7 @@ func NewCommandCmd() *cobra.Command {
return commandCmd
}

// Run runs the command logic
// Run runs the command logic.
func (cmd *CommandCmd) Run(
ctx context.Context,
providerSSH *ssh.SSHProvider,
Expand All @@ -44,5 +44,10 @@ func (cmd *CommandCmd) Run(
return fmt.Errorf("command environment variable is missing")
}

return ssh.Command(providerSSH, command)
return ssh.Command(
ctx,
providerSSH,
command,
ssh.Streams{Stdin: os.Stdin, Stdout: os.Stdout, Stderr: os.Stderr},
)
}
17 changes: 11 additions & 6 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,29 @@ package cmd

import (
"context"
"os"

"github.com/devsy-org/devsy-provider-ssh/pkg/ssh"
"github.com/spf13/cobra"
)

// InitCmd holds the cmd flags
// InitCmd holds the cmd flags.
type InitCmd struct{}

// NewInitCmd defines a init
// NewInitCmd defines a init.
func NewInitCmd() *cobra.Command {
cmd := &InitCmd{}
initCmd := &cobra.Command{
Use: "init",
Short: "Init account",
RunE: func(_ *cobra.Command, args []string) error {
RunE: func(cobraCmd *cobra.Command, args []string) error {
sshProvider, err := ssh.NewProvider()
if err != nil {
return err
}

return cmd.Run(
context.Background(),
cobraCmd.Context(),
sshProvider,
)
},
Expand All @@ -32,10 +33,14 @@ func NewInitCmd() *cobra.Command {
return initCmd
}

// Run runs the init logic
// Run runs the init logic.
func (cmd *InitCmd) Run(
ctx context.Context,
providerSSH *ssh.SSHProvider,
) error {
return ssh.Init(providerSSH)
return ssh.Init(
ctx,
providerSSH,
ssh.Streams{Stdout: os.Stdout, Stderr: os.Stderr},
)
}
37 changes: 23 additions & 14 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
package cmd

import (
"context"
"errors"
"os"
"os/exec"
"os/signal"
"syscall"

"github.com/devsy-org/devsy/pkg/log"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
)

// NewRootCmd returns a new root command
// NewRootCmd returns a new root command.
func NewRootCmd() *cobra.Command {
sshCmd := &cobra.Command{
Use: "devsy-provider-ssh",
Expand Down Expand Up @@ -38,23 +42,28 @@ func Execute() {
rootCmd := BuildRoot()

// execute command
err := rootCmd.Execute()
if err != nil {
if exitErr, ok := err.(*ssh.ExitError); ok {
os.Exit(exitErr.ExitStatus())
}
if exitErr, ok := err.(*exec.ExitError); ok {
if len(exitErr.Stderr) > 0 {
log.Error(string(exitErr.Stderr))
}
os.Exit(exitErr.ExitCode())
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
err := rootCmd.ExecuteContext(ctx)
stop()
if err == nil {
return
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
log.Fatal(err)
}
if sshExit, ok := errors.AsType[*ssh.ExitError](err); ok {
os.Exit(sshExit.ExitStatus())
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
if len(exitErr.Stderr) > 0 {
log.Error(string(exitErr.Stderr))
}
os.Exit(exitErr.ExitCode())
}
log.Fatal(err)
}

// BuildRoot creates a new root command from the
// BuildRoot creates the SSH provider command tree.
func BuildRoot() *cobra.Command {
rootCmd := NewRootCmd()

Expand Down
95 changes: 95 additions & 0 deletions cmd/root_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//go:build !windows

package cmd

import (
"bufio"
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)

func TestExecuteProcess(t *testing.T) {
if os.Getenv("DEVSY_TEST_EXECUTE") != "1" {
return
}
os.Args = []string{"devsy-provider-ssh", "command"}
Execute()
}

func executeTestProcess(t *testing.T, script string) *exec.Cmd {
t.Helper()
dir := t.TempDir()
// #nosec G306 -- Controlled executable models the SSH child at the CLI boundary.
if err := os.WriteFile(
filepath.Join(dir, "ssh"),
[]byte("#!/bin/sh\n"+script),
0o700,
); err != nil {
t.Fatal(err)
}
for key, value := range map[string]string{
"DEVSY_TEST_EXECUTE": "1", "DOCKER_PATH": "docker", "AGENT_PATH": "/tmp/agent",
"HOST": "fixture", "PORT": "22", "USE_BUILTIN_SSH": "false", "COMMAND": "fixture",
"EXTRA_FLAGS": "", "PATH": dir + string(os.PathListSeparator) + os.Getenv("PATH"),
} {
t.Setenv(key, value)
}
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.Cleanup(cancel)
// #nosec G204 -- Re-execute only this test binary with a controlled helper selector.
return exec.CommandContext(ctx, executable, "-test.run=^TestExecuteProcess$")
}

func TestExecuteCancellation(t *testing.T) {
cmd := executeTestProcess(t, "printf 'ready\\n'\nexec sleep 30\n")
var stderr bytes.Buffer
cmd.Stderr = &stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err = cmd.Start(); err != nil {
t.Fatal(err)
}
ready, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil || ready != "ready\n" {
t.Fatalf("SSH child did not start: %q, %v", ready, err)
}
if err = cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatal(err)
}
assertProcessExit(t, cmd.Wait(), 1)
if !strings.Contains(stderr.String(), "context canceled") {
t.Fatalf("missing cancellation diagnostic: %q", stderr.String())
}
}

func TestExecuteRemoteExit(t *testing.T) {
cmd := executeTestProcess(t, "printf 'remote diagnostic' >&2\nexit 37\n")
var stderr bytes.Buffer
cmd.Stderr = &stderr
assertProcessExit(t, cmd.Run(), 37)
if stderr.String() != "remote diagnostic" {
t.Fatalf("remote diagnostic changed: %q", stderr.String())
}
}

func assertProcessExit(t *testing.T, err error, code int) {
t.Helper()
var exit *exec.ExitError
if !errors.As(err, &exit) || exit.ExitCode() != code {
t.Fatalf("expected exit %d, got %v", code, err)
}
}
25 changes: 19 additions & 6 deletions e2e/tests/integration/integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,19 @@ func setupSSHKeys() {
}

func setupDevsyCLI() {
if local := os.Getenv("DEVSY_E2E_BINARY"); local != "" {
// #nosec G304,G703 -- Test runner explicitly selects the local core binary.
data, err := os.ReadFile(local)
framework.ExpectNoError(err)
framework.ExpectNoError(os.MkdirAll("bin", 0o750))
// #nosec G306,G703 -- Local core binary must be executable.
framework.ExpectNoError(os.WriteFile("bin/devsy", data, 0o700))
return
}

client := &http.Client{Timeout: time.Second * 30}
resp, err := client.Get(
"https://github.com/devsy-org/devsy/releases/latest/download/devsy-linux-amd64",
"https://github.com/devsy-org/devsy/releases/download/v1.24.0/devsy-linux-amd64",
)
framework.ExpectNoError(err)
framework.ExpectEqual(resp.StatusCode, http.StatusOK)
Expand Down Expand Up @@ -241,12 +251,15 @@ echo line3`,
"PORT=22",
"USE_BUILTIN_SSH=false",
}...)
output, err := cmd.CombinedOutput()
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
framework.ExpectError(err)
output = bytes.TrimSpace(output)
msg := string(output)
gomega.Expect(msg).To(gomega.ContainSubstring("not-a-command"))
gomega.Expect(msg).To(gomega.ContainSubstring("command not found"))
gomega.Expect(cmd.ProcessState).NotTo(gomega.BeNil())
gomega.Expect(cmd.ProcessState.ExitCode()).To(gomega.Equal(127))
gomega.Expect(stdout.Bytes()).To(gomega.BeEmpty())
gomega.Expect(stderr.Bytes()).NotTo(gomega.BeEmpty())
gomega.Expect(stderr.String()).To(gomega.ContainSubstring("not-a-command"))
})

ginkgo.It("should run devsy up", func() {
Expand Down
Loading
Loading