Skip to content
Open
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
29 changes: 29 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: e2e

on:
push:
tags:
- v*
branches:
- master
pull_request:

permissions:
contents: read

jobs:
e2e:
name: e2e
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
with:
fetch-depth: 0
submodules: 'recursive'

- name: Run e2e tests in docker compose runner
run: |
docker compose -f e2e/docker-compose.yml up -d docker-sshd-e2e-target
trap "docker compose -f e2e/docker-compose.yml down || true" EXIT
docker compose -f e2e/docker-compose.yml run --rm e2e-runner
22 changes: 0 additions & 22 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,25 +30,3 @@ jobs:

- name: Run unit tests
run: go test ./...

e2e:
name: e2e
runs-on: ubuntu-latest
needs: ut
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v3
with:
fetch-depth: 0
submodules: 'recursive'

- name: Set up Go 1.x
uses: actions/setup-go@v4
with:
go-version: '1.26.x'
cache: true

- name: Run e2e smoke checks
run: |
go run ./cmd/docker-sshd --help > /dev/null
go run ./cmd/kube-sshd --help > /dev/null
6 changes: 6 additions & 0 deletions e2e/Dockerfile.testrunner
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
FROM golang:1.26

RUN apt-get update && apt-get install -y --no-install-recommends openssh-client curl ca-certificates docker-cli && rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://dl.k8s.io/release/v1.34.1/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \
&& GOBIN=/usr/local/bin go install sigs.k8s.io/kind@v0.30.0 \
&& chmod +x /usr/local/bin/kubectl
Comment on lines +2 to +6

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Dockerfile hardcodes downloading kubectl for linux/amd64, which will break the runner image build on ARM64 hosts (e.g., Apple Silicon) and any non-amd64 CI. Also, the direct curl download is not integrity-verified. Consider using ARG TARGETARCH (BuildKit) to select the correct kubectl binary, and verify the download via the published SHA256 (or install via a trusted package source).

Suggested change
RUN apt-get update && apt-get install -y --no-install-recommends openssh-client curl ca-certificates docker.io && rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://dl.k8s.io/release/v1.34.1/bin/linux/amd64/kubectl -o /usr/local/bin/kubectl \
&& GOBIN=/usr/local/bin go install sigs.k8s.io/kind@v0.30.0 \
&& chmod +x /usr/local/bin/kubectl
ARG TARGETARCH
RUN apt-get update && apt-get install -y --no-install-recommends openssh-client curl ca-certificates docker.io && rm -rf /var/lib/apt/lists/* \
&& KUBECTL_VERSION=v1.34.1 \
&& KUBECTL_ARCH="${TARGETARCH:-amd64}" \
&& curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${KUBECTL_ARCH}/kubectl" -o /tmp/kubectl \
&& curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${KUBECTL_ARCH}/kubectl.sha256" -o /tmp/kubectl.sha256 \
&& echo "$(cat /tmp/kubectl.sha256) /tmp/kubectl" | sha256sum -c - \
&& install -m 0755 /tmp/kubectl /usr/local/bin/kubectl \
&& rm -f /tmp/kubectl /tmp/kubectl.sha256 \
&& GOBIN=/usr/local/bin go install sigs.k8s.io/kind@v0.30.0

Copilot uses AI. Check for mistakes.
40 changes: 40 additions & 0 deletions e2e/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
services:
docker-sshd-e2e-target:
Comment thread
tg123 marked this conversation as resolved.
image: alpine:3.20
container_name: docker-sshd-e2e-test
command: ["sleep", "300"]
e2e-runner:
build:
context: ..
dockerfile: e2e/Dockerfile.testrunner
depends_on:
- docker-sshd-e2e-target
privileged: true
working_dir: /src
environment:
- KUBECONFIG=/root/.kube/config
volumes:
- ..:/src
- /var/run/docker.sock:/var/run/docker.sock
- ${KUBECONFIG:-/tmp/invalid-kubeconfig-path}:/root/.kube/config:ro
command:
- /bin/sh
- -ec
- |
export GOFLAGS=-buildvcs=false
mkdir -p /tmp/bin
go build -o /tmp/bin/docker-sshd ./cmd/docker-sshd
go build -o /tmp/bin/kube-sshd ./cmd/kube-sshd
if [ "$${KUBE_SSHD_E2E:-1}" = "1" ]; then
kind get clusters | grep -qx docker-sshd-e2e || kind create cluster --name docker-sshd-e2e
kind export kubeconfig --name docker-sshd-e2e --internal
kubectl wait --for=condition=Ready pod -n kube-system --all --timeout=2m
Comment on lines +14 to +31

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kind export kubeconfig writes to $KUBECONFIG (set to /root/.kube/config), but that path is bind-mounted read-only from the host (:ro). On CI where KUBECONFIG is typically unset, Docker will also create /tmp/invalid-kubeconfig-path as a directory and mount it onto /root/.kube/config, which will cause kind export kubeconfig/kubectl to fail. Consider removing this volume mount by default (let the runner generate its own kubeconfig), or mount a writable file path and only override it via a separate compose override/profile for local runs.

Copilot uses AI. Check for mistakes.
fi
DOCKER_SSHD_E2E=$${DOCKER_SSHD_E2E:-1} \
KUBE_SSHD_E2E=$${KUBE_SSHD_E2E:-1} \
DOCKER_SSHD_BIN=/tmp/bin/docker-sshd \
KUBE_SSHD_BIN=/tmp/bin/kube-sshd \
go test ./e2e -v
if [ "$${KUBE_SSHD_E2E:-1}" = "1" ]; then
kind delete cluster --name docker-sshd-e2e || true
fi
Comment on lines +20 to +40

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the shell runs with -e, if go test ./e2e fails the script will exit before the kind delete cluster block runs, leaving Kind containers/networks behind (especially noticeable on self-hosted/local runners). Use a trap inside this script to always delete the cluster on EXIT, or temporarily disable -e around go test and ensure cleanup runs in all cases.

Copilot uses AI. Check for mistakes.
134 changes: 134 additions & 0 deletions e2e/e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package e2e

import (
"os"
"os/exec"
"strings"
"testing"
"time"
)

func TestDockerSSHD(t *testing.T) {
if os.Getenv("DOCKER_SSHD_E2E") != "1" {
t.Skip("set DOCKER_SSHD_E2E=1 to run")
}

bin := os.Getenv("DOCKER_SSHD_BIN")
if bin == "" {
t.Fatal("DOCKER_SSHD_BIN is required")
}

container := os.Getenv("DOCKER_E2E_CONTAINER")
if container == "" {
container = "docker-sshd-e2e-test"
}

key := t.TempDir() + "/docker-sshd-e2e-key"
mustRun(t, "ssh-keygen", "-t", "ed25519", "-N", "", "-f", key)

cmd := exec.Command(bin, "--address", "127.0.0.1", "--port", "2232", "--server-key", key)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("start docker-sshd: %v", err)
}
defer func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}()

out := retrySSH(t, "2232", container+"@127.0.0.1")
if !hasOKLine(out) {
Comment on lines +29 to +41

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests bind docker-sshd/kube-sshd to fixed ports 2232 and 2233. If those ports are already in use on the runner (or if tests ever run concurrently), the tests will fail even when the code is correct. Consider selecting an ephemeral free port (e.g., by binding a listener to 127.0.0.1:0, reading the chosen port, then starting the process with that port).

Copilot uses AI. Check for mistakes.
t.Fatalf("expected ok, got %q", out)
}
}

func TestKubeSSHD(t *testing.T) {
if os.Getenv("KUBE_SSHD_E2E") != "1" {
t.Skip("set KUBE_SSHD_E2E=1 to run")
}

bin := os.Getenv("KUBE_SSHD_BIN")
if bin == "" {
t.Fatal("KUBE_SSHD_BIN is required")
}

pod := "kube-sshd-e2e-" + strings.ToLower(time.Now().Format("150405.000000000"))
pod = strings.ReplaceAll(pod, ".", "-")
mustRun(t, "kubectl", "run", pod, "--image=busybox:1.36", "--restart=Never", "--command", "--", "sleep", "300")
defer func() {
if out, err := run(t, "kubectl", "delete", "pod", pod, "--ignore-not-found=true", "--wait=false"); err != nil {
t.Logf("cleanup pod %s failed: %v\n%s", pod, err, out)
}
}()
mustRun(t, "kubectl", "wait", "--for=condition=Ready", "pod/"+pod, "--timeout=120s")

key := t.TempDir() + "/kube-sshd-e2e-key"
mustRun(t, "ssh-keygen", "-t", "ed25519", "-N", "", "-f", key)

cmd := exec.Command(bin, "--address", "127.0.0.1", "--port", "2233", "--server-key", key, "--namespace", "default")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("start kube-sshd: %v", err)
}
defer func() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}()

out := retrySSH(t, "2233", pod+"@127.0.0.1")
if !hasOKLine(out) {
t.Fatalf("expected ok, got %q", out)
}
}

func hasOKLine(out string) bool {
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "ok" {
return true
}
}
return false
}

func retrySSH(t *testing.T, port, userHost string) string {
t.Helper()

var out string
var err error
for range 30 {
out, err = run(t,
"ssh",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "PreferredAuthentications=none",
"-p", port,
userHost,
"echo", "ok",
)
if err == nil {
return out
}
time.Sleep(time.Second)
}

t.Fatalf("ssh did not succeed: %v", err)
return ""
}

func mustRun(t *testing.T, name string, args ...string) string {
t.Helper()
out, err := run(t, name, args...)
if err != nil {
t.Fatalf("%s %v failed: %v\n%s", name, args, err, out)
}
return out
}

func run(t *testing.T, name string, args ...string) (string, error) {
t.Helper()
cmd := exec.Command(name, args...)
out, err := cmd.CombinedOutput()
return string(out), err
}