diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..47a1cac --- /dev/null +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 88c5f8f..8051dd8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/e2e/Dockerfile.testrunner b/e2e/Dockerfile.testrunner new file mode 100644 index 0000000..270f979 --- /dev/null +++ b/e2e/Dockerfile.testrunner @@ -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 diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml new file mode 100644 index 0000000..db06588 --- /dev/null +++ b/e2e/docker-compose.yml @@ -0,0 +1,40 @@ +services: + docker-sshd-e2e-target: + 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 + 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 diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..718278e --- /dev/null +++ b/e2e/e2e_test.go @@ -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) { + 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 +}