From f015968eb7450625dc83a59991c5b4d62ecdf989 Mon Sep 17 00:00:00 2001 From: dhernando Date: Thu, 16 Apr 2026 13:26:29 +0200 Subject: [PATCH 1/5] chore: add e2e initial tests that check on cluster creation flow --- .github/workflows/e2e.yml | 33 +++++++++++++++ Makefile | 5 ++- test/e2e/binary_test.go | 85 +++++++++++++++++++++++++++++++++++++ test/e2e/e2e_test.go | 89 +++++++++++++++++++++++++++++++++++++++ test/e2e/runner_test.go | 76 +++++++++++++++++++++++++++++++++ 5 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 test/e2e/binary_test.go create mode 100644 test/e2e/e2e_test.go create mode 100644 test/e2e/runner_test.go diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..d9ab2bf --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,33 @@ +name: E2E + +permissions: + contents: read + +on: + schedule: + - cron: "0 8 * * *" + workflow_dispatch: + +env: + GOTOOLCHAIN: local + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up tools + uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 + with: + version: 2026.3.8 + + - name: Run E2E tests + run: make e2e + env: + QDRANT_CLOUD_API_KEY: ${{ secrets.E2E_QDRANT_CLOUD_API_KEY }} + QDRANT_CLOUD_ACCOUNT_ID: ${{ secrets.E2E_QDRANT_CLOUD_ACCOUNT_ID }} diff --git a/Makefile b/Makefile index 10cb87e..5a19a03 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo unknown) -.PHONY: build debug debug-run test lint format clean bootstrap docs docs-check +.PHONY: build debug debug-run test lint format clean bootstrap docs docs-check e2e build: CGO_ENABLED=0 go build -ldflags "-X main.version=$(VERSION)" -o build/qcloud ./cmd/qcloud @@ -32,6 +32,9 @@ bootstrap: { echo "mise is not installed. Install it from https://mise.jdx.dev/installing-mise.html"; exit 1; } mise install +e2e: + QCLOUD_E2E=1 go test -timeout 20m -v -count=1 ./test/e2e/ + docs: go run ./cmd/docgen ./docs/reference diff --git a/test/e2e/binary_test.go b/test/e2e/binary_test.go new file mode 100644 index 0000000..f0addd0 --- /dev/null +++ b/test/e2e/binary_test.go @@ -0,0 +1,85 @@ +package e2e_test + +import ( + "archive/tar" + "compress/gzip" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +const downloadURL = "https://github.com/qdrant/qcloud-cli/releases/latest/download" + +// setupBinary returns the path to a qcloud binary. If QCLOUD_E2E_BINARY is +// set it uses that path directly; otherwise it downloads the latest release +// from GitHub. +func setupBinary(t *testing.T) string { + t.Helper() + + if p := os.Getenv("QCLOUD_E2E_BINARY"); p != "" { + t.Logf("using binary from QCLOUD_E2E_BINARY: %s", p) + return p + } + + archiveName := fmt.Sprintf("qcloud-%s-%s.tar.gz", runtime.GOOS, runtime.GOARCH) + url := downloadURL + "/" + archiveName + + t.Logf("downloading %s", url) + + resp, err := http.Get(url) + require.NoError(t, err) + defer func() { require.NoError(t, resp.Body.Close()) }() + + require.Equal(t, http.StatusOK, resp.StatusCode, "GET %s returned %s", url, resp.Status) + + dir := t.TempDir() + binaryPath := extractQcloud(t, resp.Body, dir) + + require.NoError(t, os.Chmod(binaryPath, 0o755)) + t.Logf("binary at %s", binaryPath) + + return binaryPath +} + +// extractQcloud reads a gzip-compressed tar archive from r and extracts the +// "qcloud" binary into dir. It returns the path to the extracted binary. +func extractQcloud(t *testing.T, r io.Reader, dir string) string { + t.Helper() + + gr, err := gzip.NewReader(r) + require.NoError(t, err) + defer func() { require.NoError(t, gr.Close()) }() + + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + + if filepath.Base(hdr.Name) != "qcloud" { + continue + } + + dst := filepath.Join(dir, "qcloud") + f, err := os.Create(dst) + require.NoError(t, err) + + _, err = io.Copy(f, tr) + require.NoError(t, f.Close()) + require.NoError(t, err) + + return dst + } + + t.Fatal("qcloud binary not found in archive") + return "" +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go new file mode 100644 index 0000000..c81654d --- /dev/null +++ b/test/e2e/e2e_test.go @@ -0,0 +1,89 @@ +package e2e_test + +import ( + "context" + "os" + "os/exec" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var clusterIDRe = regexp.MustCompile(`Cluster\s+([0-9a-f-]{36})`) + +func TestE2EClusterCreationFlow(t *testing.T) { + if os.Getenv("QCLOUD_E2E") == "" { + t.Skip("set QCLOUD_E2E=1 to run e2e tests") + } + + apiKey := os.Getenv("QDRANT_CLOUD_API_KEY") + accountID := os.Getenv("QDRANT_CLOUD_ACCOUNT_ID") + require.NotEmpty(t, apiKey, "QDRANT_CLOUD_API_KEY must be set") + require.NotEmpty(t, accountID, "QDRANT_CLOUD_ACCOUNT_ID must be set") + + binaryPath := setupBinary(t) + + r := &runner{ + binaryPath: binaryPath, + apiKey: apiKey, + accountID: accountID, + endpoint: os.Getenv("QDRANT_CLOUD_ENDPOINT"), + homeDir: t.TempDir(), + } + + var clusterID string + + t.Cleanup(func() { + if clusterID == "" { + return + } + + // Best-effort cleanup with a fresh context. + t.Logf("cleanup: deleting cluster %s", clusterID) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, r.binaryPath, "cluster", "delete", clusterID, "--force") + cmd.Env = r.env() + _ = cmd.Run() + }) + + t.Run("cluster-create", func(t *testing.T) { + out := r.run(t, + "cluster", "create", + "--cloud-provider", "aws", + "--cloud-region", "eu-central-1", + "--package", "free2", + ) + + matches := clusterIDRe.FindStringSubmatch(out) + require.NotEmpty(t, matches, "could not extract cluster ID from output: %s", out) + clusterID = matches[1] + t.Logf("created cluster %s", clusterID) + }) + if clusterID == "" { + t.Fatal("cluster create failed, cannot continue") + } + + t.Run("cluster-wait", func(t *testing.T) { + r.runWithTimeout(t, 15*time.Minute, + "cluster", "wait", clusterID, "--timeout", "15m", + ) + }) + + t.Run("cluster-key-create", func(t *testing.T) { + r.run(t, + "cluster", "key", "create", clusterID, + "--name", "e2e-test-key", + "--wait", + ) + }) + + t.Run("cluster-delete", func(t *testing.T) { + r.run(t, + "cluster", "delete", clusterID, "--force", + ) + clusterID = "" // Prevent double-delete in t.Cleanup. + }) +} diff --git a/test/e2e/runner_test.go b/test/e2e/runner_test.go new file mode 100644 index 0000000..7e1b0ef --- /dev/null +++ b/test/e2e/runner_test.go @@ -0,0 +1,76 @@ +package e2e_test + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// runner executes the qcloud binary with the appropriate environment. +type runner struct { + binaryPath string + apiKey string + accountID string + endpoint string + homeDir string +} + +// run executes the qcloud binary with the given arguments. It fails the test +// on non-zero exit and returns stdout. +func (r *runner) run(t *testing.T, args ...string) string { + t.Helper() + return r.runWithTimeout(t, 2*time.Minute, args...) +} + +// runWithTimeout executes the qcloud binary with the given arguments and +// timeout. It fails the test on non-zero exit and returns stdout. +func (r *runner) runWithTimeout(t *testing.T, timeout time.Duration, args ...string) string { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, r.binaryPath, args...) + cmd.Env = r.env() + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + t.Logf("exec: qcloud %s", strings.Join(args, " ")) + + err := cmd.Run() + if err != nil { + t.Logf("stdout:\n%s", r.redact(stdout.String())) + t.Logf("stderr:\n%s", r.redact(stderr.String())) + require.NoError(t, err, "command failed: %s %s", r.binaryPath, strings.Join(args, " ")) + } + + return stdout.String() +} + +// redact replaces the API key value in s to prevent leaking secrets in CI logs. +func (r *runner) redact(s string) string { + if r.apiKey != "" { + s = strings.ReplaceAll(s, r.apiKey, "[REDACTED]") + } + return s +} + +func (r *runner) env() []string { + env := []string{ + fmt.Sprintf("QDRANT_CLOUD_API_KEY=%s", r.apiKey), + fmt.Sprintf("QDRANT_CLOUD_ACCOUNT_ID=%s", r.accountID), + "HOME=" + r.homeDir, + } + if r.endpoint != "" { + env = append(env, fmt.Sprintf("QDRANT_CLOUD_ENDPOINT=%s", r.endpoint)) + } + return env +} From a225f0d3c91fa196d774491d21b9a867bf140673 Mon Sep 17 00:00:00 2001 From: dhernando Date: Thu, 16 Apr 2026 15:36:07 +0200 Subject: [PATCH 2/5] refactor(e2e): refactor e2e test runner so that it's reusable --- test/e2e/e2e_test.go | 32 +++---------- test/e2e/env_test.go | 104 ++++++++++++++++++++++++++++++++++++++++ test/e2e/runner_test.go | 76 ----------------------------- 3 files changed, 111 insertions(+), 101 deletions(-) create mode 100644 test/e2e/env_test.go delete mode 100644 test/e2e/runner_test.go diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index c81654d..3a766b3 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -2,7 +2,6 @@ package e2e_test import ( "context" - "os" "os/exec" "regexp" "testing" @@ -14,24 +13,7 @@ import ( var clusterIDRe = regexp.MustCompile(`Cluster\s+([0-9a-f-]{36})`) func TestE2EClusterCreationFlow(t *testing.T) { - if os.Getenv("QCLOUD_E2E") == "" { - t.Skip("set QCLOUD_E2E=1 to run e2e tests") - } - - apiKey := os.Getenv("QDRANT_CLOUD_API_KEY") - accountID := os.Getenv("QDRANT_CLOUD_ACCOUNT_ID") - require.NotEmpty(t, apiKey, "QDRANT_CLOUD_API_KEY must be set") - require.NotEmpty(t, accountID, "QDRANT_CLOUD_ACCOUNT_ID must be set") - - binaryPath := setupBinary(t) - - r := &runner{ - binaryPath: binaryPath, - apiKey: apiKey, - accountID: accountID, - endpoint: os.Getenv("QDRANT_CLOUD_ENDPOINT"), - homeDir: t.TempDir(), - } + env := NewE2EEnv(t) var clusterID string @@ -44,13 +26,13 @@ func TestE2EClusterCreationFlow(t *testing.T) { t.Logf("cleanup: deleting cluster %s", clusterID) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - cmd := exec.CommandContext(ctx, r.binaryPath, "cluster", "delete", clusterID, "--force") - cmd.Env = r.env() + cmd := exec.CommandContext(ctx, env.BinaryPath, "cluster", "delete", clusterID, "--force") + cmd.Env = env.Env() _ = cmd.Run() }) t.Run("cluster-create", func(t *testing.T) { - out := r.run(t, + out := env.Run(t, "cluster", "create", "--cloud-provider", "aws", "--cloud-region", "eu-central-1", @@ -67,13 +49,13 @@ func TestE2EClusterCreationFlow(t *testing.T) { } t.Run("cluster-wait", func(t *testing.T) { - r.runWithTimeout(t, 15*time.Minute, + env.RunWithTimeout(t, 15*time.Minute, "cluster", "wait", clusterID, "--timeout", "15m", ) }) t.Run("cluster-key-create", func(t *testing.T) { - r.run(t, + env.Run(t, "cluster", "key", "create", clusterID, "--name", "e2e-test-key", "--wait", @@ -81,7 +63,7 @@ func TestE2EClusterCreationFlow(t *testing.T) { }) t.Run("cluster-delete", func(t *testing.T) { - r.run(t, + env.Run(t, "cluster", "delete", clusterID, "--force", ) clusterID = "" // Prevent double-delete in t.Cleanup. diff --git a/test/e2e/env_test.go b/test/e2e/env_test.go new file mode 100644 index 0000000..0e1b709 --- /dev/null +++ b/test/e2e/env_test.go @@ -0,0 +1,104 @@ +package e2e_test + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// E2EEnv bundles everything an e2e test needs: the binary path, credentials, +// endpoint, and an isolated home directory. It is the e2e counterpart of +// testutil.TestEnv. +type E2EEnv struct { + BinaryPath string + APIKey string + AccountID string + Endpoint string + HomeDir string +} + +// NewE2EEnv creates an E2EEnv by reading required environment variables, +// setting up the binary, and creating an isolated home directory. It skips the +// test if QCLOUD_E2E is not set and fails if required credentials are missing. +func NewE2EEnv(t *testing.T) *E2EEnv { + t.Helper() + + if os.Getenv("QCLOUD_E2E") == "" { + t.Skip("set QCLOUD_E2E=1 to run e2e tests") + } + + apiKey := os.Getenv("QDRANT_CLOUD_API_KEY") + accountID := os.Getenv("QDRANT_CLOUD_ACCOUNT_ID") + require.NotEmpty(t, apiKey, "QDRANT_CLOUD_API_KEY must be set") + require.NotEmpty(t, accountID, "QDRANT_CLOUD_ACCOUNT_ID must be set") + + return &E2EEnv{ + BinaryPath: setupBinary(t), + APIKey: apiKey, + AccountID: accountID, + Endpoint: os.Getenv("QDRANT_CLOUD_ENDPOINT"), + HomeDir: t.TempDir(), + } +} + +// Run executes the qcloud binary with the given arguments. It fails the test +// on non-zero exit and returns stdout. +func (e *E2EEnv) Run(t *testing.T, args ...string) string { + t.Helper() + return e.RunWithTimeout(t, 2*time.Minute, args...) +} + +// RunWithTimeout executes the qcloud binary with the given arguments and +// timeout. It fails the test on non-zero exit and returns stdout. +func (e *E2EEnv) RunWithTimeout(t *testing.T, timeout time.Duration, args ...string) string { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, e.BinaryPath, args...) + cmd.Env = e.Env() + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + t.Logf("exec: qcloud %s", strings.Join(args, " ")) + + err := cmd.Run() + if err != nil { + t.Logf("stdout:\n%s", e.Redact(stdout.String())) + t.Logf("stderr:\n%s", e.Redact(stderr.String())) + require.NoError(t, err, "command failed: %s %s", e.BinaryPath, strings.Join(args, " ")) + } + + return stdout.String() +} + +// Redact replaces the API key value in s to prevent leaking secrets in CI logs. +func (e *E2EEnv) Redact(s string) string { + if e.APIKey != "" { + s = strings.ReplaceAll(s, e.APIKey, "[REDACTED]") + } + return s +} + +// Env returns the environment variables for the qcloud binary. +func (e *E2EEnv) Env() []string { + env := []string{ + fmt.Sprintf("QDRANT_CLOUD_API_KEY=%s", e.APIKey), + fmt.Sprintf("QDRANT_CLOUD_ACCOUNT_ID=%s", e.AccountID), + "HOME=" + e.HomeDir, + } + if e.Endpoint != "" { + env = append(env, fmt.Sprintf("QDRANT_CLOUD_ENDPOINT=%s", e.Endpoint)) + } + return env +} diff --git a/test/e2e/runner_test.go b/test/e2e/runner_test.go deleted file mode 100644 index 7e1b0ef..0000000 --- a/test/e2e/runner_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package e2e_test - -import ( - "bytes" - "context" - "fmt" - "os/exec" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// runner executes the qcloud binary with the appropriate environment. -type runner struct { - binaryPath string - apiKey string - accountID string - endpoint string - homeDir string -} - -// run executes the qcloud binary with the given arguments. It fails the test -// on non-zero exit and returns stdout. -func (r *runner) run(t *testing.T, args ...string) string { - t.Helper() - return r.runWithTimeout(t, 2*time.Minute, args...) -} - -// runWithTimeout executes the qcloud binary with the given arguments and -// timeout. It fails the test on non-zero exit and returns stdout. -func (r *runner) runWithTimeout(t *testing.T, timeout time.Duration, args ...string) string { - t.Helper() - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, r.binaryPath, args...) - cmd.Env = r.env() - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - t.Logf("exec: qcloud %s", strings.Join(args, " ")) - - err := cmd.Run() - if err != nil { - t.Logf("stdout:\n%s", r.redact(stdout.String())) - t.Logf("stderr:\n%s", r.redact(stderr.String())) - require.NoError(t, err, "command failed: %s %s", r.binaryPath, strings.Join(args, " ")) - } - - return stdout.String() -} - -// redact replaces the API key value in s to prevent leaking secrets in CI logs. -func (r *runner) redact(s string) string { - if r.apiKey != "" { - s = strings.ReplaceAll(s, r.apiKey, "[REDACTED]") - } - return s -} - -func (r *runner) env() []string { - env := []string{ - fmt.Sprintf("QDRANT_CLOUD_API_KEY=%s", r.apiKey), - fmt.Sprintf("QDRANT_CLOUD_ACCOUNT_ID=%s", r.accountID), - "HOME=" + r.homeDir, - } - if r.endpoint != "" { - env = append(env, fmt.Sprintf("QDRANT_CLOUD_ENDPOINT=%s", r.endpoint)) - } - return env -} From b04479c915b040797be3e479f1d2b0472586ebff Mon Sep 17 00:00:00 2001 From: dhernando Date: Fri, 17 Apr 2026 10:44:49 +0200 Subject: [PATCH 3/5] refactor(e2e): framework package + domain helpers + --json --- test/e2e/README.md | 90 +++++++++++++ test/e2e/binary_test.go | 85 ------------ test/e2e/cluster_helpers_test.go | 136 +++++++++++++++++++ test/e2e/cluster_test.go | 31 +++++ test/e2e/e2e_test.go | 71 ---------- test/e2e/env_test.go | 104 --------------- test/e2e/framework/binary.go | 217 +++++++++++++++++++++++++++++++ test/e2e/framework/env.go | 206 +++++++++++++++++++++++++++++ test/e2e/main_test.go | 24 ++++ 9 files changed, 704 insertions(+), 260 deletions(-) create mode 100644 test/e2e/README.md delete mode 100644 test/e2e/binary_test.go create mode 100644 test/e2e/cluster_helpers_test.go create mode 100644 test/e2e/cluster_test.go delete mode 100644 test/e2e/e2e_test.go delete mode 100644 test/e2e/env_test.go create mode 100644 test/e2e/framework/binary.go create mode 100644 test/e2e/framework/env.go create mode 100644 test/e2e/main_test.go diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 0000000..29efa2d --- /dev/null +++ b/test/e2e/README.md @@ -0,0 +1,90 @@ +# qcloud e2e tests + +End-to-end tests that run the real `qcloud` binary against a real Qdrant Cloud +backend. These tests **create paid resources** on whatever account you point +them at — never run them against a production account without first +double-checking the credentials. + +## Running + +``` +make e2e +``` + +Or directly: + +``` +QCLOUD_E2E=1 \ +QDRANT_CLOUD_API_KEY=... \ +QDRANT_CLOUD_ACCOUNT_ID=... \ +go test -timeout 30m -v -count=1 ./test/e2e/... +``` + +Without `QCLOUD_E2E=1` every test in this tree is skipped, so `go test ./...` +from the repo root stays network-free. + +## Environment variables + +| Variable | Required | Purpose | +| -------------------------- | -------- | ----------------------------------------------------------------------- | +| `QCLOUD_E2E` | yes | Enables the suite. Any non-empty value works. | +| `QDRANT_CLOUD_API_KEY` | yes | Management API key used by every invocation. | +| `QDRANT_CLOUD_ACCOUNT_ID` | yes | Account ID the test resources are created under. | +| `QDRANT_CLOUD_ENDPOINT` | no | Override the gRPC endpoint (defaults to production). | +| `QCLOUD_E2E_BINARY` | no | Absolute path to a pre-built `qcloud` binary. Skips the download. | +| `QCLOUD_E2E_RELEASE` | no | GitHub release tag to download (default `latest`). | + +## Binary acquisition + +The binary is resolved exactly once per `go test` invocation in `TestMain`: + +1. If `QCLOUD_E2E_BINARY` is set, it is used as-is. +2. Otherwise the `qcloud--.tar.gz` archive from the release given by + `QCLOUD_E2E_RELEASE` (default `latest`) is downloaded, its sha256 is checked + against the release's `checksums.txt`, and the binary is extracted into + `$XDG_CACHE_HOME/qcloud-e2e//qcloud` (or the platform equivalent). + +Cache entries are keyed by the archive's sha256, so repeated runs on the same +host reuse the same extracted binary — and automatically pick up a fresh copy +when the upstream release changes. + +To test a locally-built binary: + +``` +make build +QCLOUD_E2E_BINARY=$PWD/build/qcloud make e2e +``` + +## Writing new tests + +Every test starts with `framework.NewEnv(t)`; it returns an `*Env` that wraps +the binary path, an isolated empty config file, and generic CLI wrappers: + +- `env.Run(t, args...)` — runs `qcloud`, fails the test on non-zero exit, + streams stdout/stderr to `t.Log`. +- `env.RunAllowFail(t, args...)` — same, but returns the result instead of + failing. Use it for negative tests. +- `env.RunJSON(t, &v, args...)` — appends `--json` and decodes stdout into `v`. + +Resource-specific helpers live next to the tests that use them — they're +ordinary `_test.go` files in `package e2e_test`, free to grow without +bloating the framework. For clusters, see `cluster_helpers_test.go`: + +- `createCluster(t, env, opts)` — creates a cluster and registers cleanup. + The cluster name defaults to `e2e-`, which makes leak sweeps safe. +- `waitCluster(t, env, id, timeout)` — blocks until the cluster is healthy. +- `sweepLeakedClusters(t, env, maxAge)` — best-effort cleanup of stale + `e2e-*` clusters; call it from a dedicated test if you want automatic + housekeeping in CI. + +Prefer `RunJSON` over scraping human output — the JSON shape is stable, +human output is not. + +## Safety notes + +- Every cluster created through `createCluster` is scheduled for deletion via + `t.Cleanup`. If a runner is killed mid-test, orphans remain; use + `sweepLeakedClusters` to catch them. +- Tests are **not** safe to run with `t.Parallel()` today — they share a + single account and a small quota. Don't add `t.Parallel()` without also + isolating each test's account or region. diff --git a/test/e2e/binary_test.go b/test/e2e/binary_test.go deleted file mode 100644 index f0addd0..0000000 --- a/test/e2e/binary_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package e2e_test - -import ( - "archive/tar" - "compress/gzip" - "errors" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "runtime" - "testing" - - "github.com/stretchr/testify/require" -) - -const downloadURL = "https://github.com/qdrant/qcloud-cli/releases/latest/download" - -// setupBinary returns the path to a qcloud binary. If QCLOUD_E2E_BINARY is -// set it uses that path directly; otherwise it downloads the latest release -// from GitHub. -func setupBinary(t *testing.T) string { - t.Helper() - - if p := os.Getenv("QCLOUD_E2E_BINARY"); p != "" { - t.Logf("using binary from QCLOUD_E2E_BINARY: %s", p) - return p - } - - archiveName := fmt.Sprintf("qcloud-%s-%s.tar.gz", runtime.GOOS, runtime.GOARCH) - url := downloadURL + "/" + archiveName - - t.Logf("downloading %s", url) - - resp, err := http.Get(url) - require.NoError(t, err) - defer func() { require.NoError(t, resp.Body.Close()) }() - - require.Equal(t, http.StatusOK, resp.StatusCode, "GET %s returned %s", url, resp.Status) - - dir := t.TempDir() - binaryPath := extractQcloud(t, resp.Body, dir) - - require.NoError(t, os.Chmod(binaryPath, 0o755)) - t.Logf("binary at %s", binaryPath) - - return binaryPath -} - -// extractQcloud reads a gzip-compressed tar archive from r and extracts the -// "qcloud" binary into dir. It returns the path to the extracted binary. -func extractQcloud(t *testing.T, r io.Reader, dir string) string { - t.Helper() - - gr, err := gzip.NewReader(r) - require.NoError(t, err) - defer func() { require.NoError(t, gr.Close()) }() - - tr := tar.NewReader(gr) - for { - hdr, err := tr.Next() - if errors.Is(err, io.EOF) { - break - } - require.NoError(t, err) - - if filepath.Base(hdr.Name) != "qcloud" { - continue - } - - dst := filepath.Join(dir, "qcloud") - f, err := os.Create(dst) - require.NoError(t, err) - - _, err = io.Copy(f, tr) - require.NoError(t, f.Close()) - require.NoError(t, err) - - return dst - } - - t.Fatal("qcloud binary not found in archive") - return "" -} diff --git a/test/e2e/cluster_helpers_test.go b/test/e2e/cluster_helpers_test.go new file mode 100644 index 0000000..b1ee90f --- /dev/null +++ b/test/e2e/cluster_helpers_test.go @@ -0,0 +1,136 @@ +package e2e_test + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/qdrant/qcloud-cli/test/e2e/framework" +) + +// e2eClusterPrefix is the name prefix used for every cluster created via +// createCluster. sweepLeakedClusters targets the same prefix. +const e2eClusterPrefix = "e2e-" + +// createClusterOpts captures the knobs tests commonly tweak when creating a +// cluster. Fields left at zero values pick sensible defaults. +type createClusterOpts struct { + // Name is the cluster name. If empty, a random "e2e-" name is used. + Name string + // CloudProvider defaults to "aws". + CloudProvider string + // CloudRegion defaults to "eu-central-1". + CloudRegion string + // Package defaults to "free2". + Package string +} + +// createCluster creates a cluster and registers a t.Cleanup that force-deletes +// it when the test finishes. The returned ID is the cluster's GUID. The +// cluster is not waited on; call waitCluster if readiness matters to the test. +func createCluster(t *testing.T, env *framework.Env, opts createClusterOpts) string { + t.Helper() + + if opts.Name == "" { + opts.Name = e2eClusterPrefix + randomSuffix(t) + } + if opts.CloudProvider == "" { + opts.CloudProvider = "aws" + } + if opts.CloudRegion == "" { + opts.CloudRegion = "eu-central-1" + } + if opts.Package == "" { + opts.Package = "free2" + } + + var cluster struct { + ID string `json:"id"` + } + env.RunJSON(t, &cluster, + "cluster", "create", + "--name", opts.Name, + "--cloud-provider", opts.CloudProvider, + "--cloud-region", opts.CloudRegion, + "--package", opts.Package, + ) + if cluster.ID == "" { + t.Fatalf("cluster create returned empty id") + } + + t.Logf("created cluster %s (%s)", cluster.ID, opts.Name) + t.Cleanup(func() { deleteCluster(t, env, cluster.ID) }) + return cluster.ID +} + +// waitCluster blocks until the cluster is healthy or the timeout elapses. +func waitCluster(t *testing.T, env *framework.Env, id string, timeout time.Duration) { + t.Helper() + env.RunWithTimeout(t, timeout+30*time.Second, + "cluster", "wait", id, "--timeout", timeout.String(), + ) +} + +// deleteCluster force-deletes a cluster, logging but not failing on error so +// it is safe to call from t.Cleanup. +func deleteCluster(t *testing.T, env *framework.Env, id string) { + t.Helper() + if id == "" { + return + } + res := env.RunAllowFail(t, "cluster", "delete", id, "--force") + if res.Err != nil { + t.Logf("cleanup: cluster delete %s failed: %v", id, res.Err) + } +} + +// sweepLeakedClusters lists clusters in the current account and force-deletes +// any whose name starts with e2eClusterPrefix and whose CreatedAt is older +// than maxAge. It never fails the test — leaks are a best-effort safety net. +func sweepLeakedClusters(t *testing.T, env *framework.Env, maxAge time.Duration) { + t.Helper() + + var resp struct { + Items []struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` + } `json:"items"` + } + + res := env.RunAllowFail(t, "cluster", "list", "--json") + if res.Err != nil { + t.Logf("leak sweep: cluster list failed: %v", res.Err) + return + } + if err := json.Unmarshal([]byte(res.Stdout), &resp); err != nil { + t.Logf("leak sweep: decoding cluster list: %v", err) + return + } + + cutoff := time.Now().Add(-maxAge) + for _, c := range resp.Items { + if !strings.HasPrefix(c.Name, e2eClusterPrefix) { + continue + } + if !c.CreatedAt.IsZero() && c.CreatedAt.After(cutoff) { + continue + } + t.Logf("leak sweep: deleting stale cluster %s (%s, created %s)", + c.ID, c.Name, c.CreatedAt.Format(time.RFC3339)) + deleteCluster(t, env, c.ID) + } +} + +// randomSuffix returns 8 hex chars for disambiguating parallel test runs. +func randomSuffix(t *testing.T) string { + t.Helper() + var b [4]byte + if _, err := rand.Read(b[:]); err != nil { + t.Fatalf("rand: %v", err) + } + return hex.EncodeToString(b[:]) +} diff --git a/test/e2e/cluster_test.go b/test/e2e/cluster_test.go new file mode 100644 index 0000000..909f6fa --- /dev/null +++ b/test/e2e/cluster_test.go @@ -0,0 +1,31 @@ +package e2e_test + +import ( + "testing" + "time" + + "github.com/qdrant/qcloud-cli/test/e2e/framework" +) + +// TestClusterLifecycle exercises the happy path of cluster create → wait → +// key create → delete against a real backend. +func TestClusterLifecycle(t *testing.T) { + env := framework.NewEnv(t) + + // Best-effort cleanup of clusters orphaned by killed previous runs. + sweepLeakedClusters(t, env, time.Hour) + + id := createCluster(t, env, createClusterOpts{ + CloudProvider: "aws", + CloudRegion: "eu-central-1", + Package: "free2", + }) + + waitCluster(t, env, id, 15*time.Minute) + + env.Run(t, + "cluster", "key", "create", id, + "--name", "e2e-test-key", + "--wait", + ) +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go deleted file mode 100644 index 3a766b3..0000000 --- a/test/e2e/e2e_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package e2e_test - -import ( - "context" - "os/exec" - "regexp" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -var clusterIDRe = regexp.MustCompile(`Cluster\s+([0-9a-f-]{36})`) - -func TestE2EClusterCreationFlow(t *testing.T) { - env := NewE2EEnv(t) - - var clusterID string - - t.Cleanup(func() { - if clusterID == "" { - return - } - - // Best-effort cleanup with a fresh context. - t.Logf("cleanup: deleting cluster %s", clusterID) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - cmd := exec.CommandContext(ctx, env.BinaryPath, "cluster", "delete", clusterID, "--force") - cmd.Env = env.Env() - _ = cmd.Run() - }) - - t.Run("cluster-create", func(t *testing.T) { - out := env.Run(t, - "cluster", "create", - "--cloud-provider", "aws", - "--cloud-region", "eu-central-1", - "--package", "free2", - ) - - matches := clusterIDRe.FindStringSubmatch(out) - require.NotEmpty(t, matches, "could not extract cluster ID from output: %s", out) - clusterID = matches[1] - t.Logf("created cluster %s", clusterID) - }) - if clusterID == "" { - t.Fatal("cluster create failed, cannot continue") - } - - t.Run("cluster-wait", func(t *testing.T) { - env.RunWithTimeout(t, 15*time.Minute, - "cluster", "wait", clusterID, "--timeout", "15m", - ) - }) - - t.Run("cluster-key-create", func(t *testing.T) { - env.Run(t, - "cluster", "key", "create", clusterID, - "--name", "e2e-test-key", - "--wait", - ) - }) - - t.Run("cluster-delete", func(t *testing.T) { - env.Run(t, - "cluster", "delete", clusterID, "--force", - ) - clusterID = "" // Prevent double-delete in t.Cleanup. - }) -} diff --git a/test/e2e/env_test.go b/test/e2e/env_test.go deleted file mode 100644 index 0e1b709..0000000 --- a/test/e2e/env_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package e2e_test - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// E2EEnv bundles everything an e2e test needs: the binary path, credentials, -// endpoint, and an isolated home directory. It is the e2e counterpart of -// testutil.TestEnv. -type E2EEnv struct { - BinaryPath string - APIKey string - AccountID string - Endpoint string - HomeDir string -} - -// NewE2EEnv creates an E2EEnv by reading required environment variables, -// setting up the binary, and creating an isolated home directory. It skips the -// test if QCLOUD_E2E is not set and fails if required credentials are missing. -func NewE2EEnv(t *testing.T) *E2EEnv { - t.Helper() - - if os.Getenv("QCLOUD_E2E") == "" { - t.Skip("set QCLOUD_E2E=1 to run e2e tests") - } - - apiKey := os.Getenv("QDRANT_CLOUD_API_KEY") - accountID := os.Getenv("QDRANT_CLOUD_ACCOUNT_ID") - require.NotEmpty(t, apiKey, "QDRANT_CLOUD_API_KEY must be set") - require.NotEmpty(t, accountID, "QDRANT_CLOUD_ACCOUNT_ID must be set") - - return &E2EEnv{ - BinaryPath: setupBinary(t), - APIKey: apiKey, - AccountID: accountID, - Endpoint: os.Getenv("QDRANT_CLOUD_ENDPOINT"), - HomeDir: t.TempDir(), - } -} - -// Run executes the qcloud binary with the given arguments. It fails the test -// on non-zero exit and returns stdout. -func (e *E2EEnv) Run(t *testing.T, args ...string) string { - t.Helper() - return e.RunWithTimeout(t, 2*time.Minute, args...) -} - -// RunWithTimeout executes the qcloud binary with the given arguments and -// timeout. It fails the test on non-zero exit and returns stdout. -func (e *E2EEnv) RunWithTimeout(t *testing.T, timeout time.Duration, args ...string) string { - t.Helper() - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, e.BinaryPath, args...) - cmd.Env = e.Env() - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - t.Logf("exec: qcloud %s", strings.Join(args, " ")) - - err := cmd.Run() - if err != nil { - t.Logf("stdout:\n%s", e.Redact(stdout.String())) - t.Logf("stderr:\n%s", e.Redact(stderr.String())) - require.NoError(t, err, "command failed: %s %s", e.BinaryPath, strings.Join(args, " ")) - } - - return stdout.String() -} - -// Redact replaces the API key value in s to prevent leaking secrets in CI logs. -func (e *E2EEnv) Redact(s string) string { - if e.APIKey != "" { - s = strings.ReplaceAll(s, e.APIKey, "[REDACTED]") - } - return s -} - -// Env returns the environment variables for the qcloud binary. -func (e *E2EEnv) Env() []string { - env := []string{ - fmt.Sprintf("QDRANT_CLOUD_API_KEY=%s", e.APIKey), - fmt.Sprintf("QDRANT_CLOUD_ACCOUNT_ID=%s", e.AccountID), - "HOME=" + e.HomeDir, - } - if e.Endpoint != "" { - env = append(env, fmt.Sprintf("QDRANT_CLOUD_ENDPOINT=%s", e.Endpoint)) - } - return env -} diff --git a/test/e2e/framework/binary.go b/test/e2e/framework/binary.go new file mode 100644 index 0000000..942509d --- /dev/null +++ b/test/e2e/framework/binary.go @@ -0,0 +1,217 @@ +// Package framework provides shared helpers for the qcloud e2e test suite. +package framework + +import ( + "archive/tar" + "bufio" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" +) + +const ( + envBinary = "QCLOUD_E2E_BINARY" + envRelease = "QCLOUD_E2E_RELEASE" + + releaseBaseURL = "https://github.com/qdrant/qcloud-cli/releases" + defaultRelease = "latest" + + downloadTimeout = 2 * time.Minute +) + +var ( + binaryOnce sync.Once + binaryPath string + binaryErr error +) + +// Binary returns the path to the qcloud binary to exercise. The binary is +// resolved exactly once per process: +// +// 1. $QCLOUD_E2E_BINARY, if set. +// 2. A cached download of the release named by $QCLOUD_E2E_RELEASE (default +// "latest"), verified against the release's checksums.txt. +// +// Downloads are cached under os.UserCacheDir()/qcloud-e2e keyed by the archive +// sha256, so repeated test runs on the same host reuse the same binary. +func Binary() (string, error) { + binaryOnce.Do(func() { + binaryPath, binaryErr = resolveBinary() + }) + return binaryPath, binaryErr +} + +func resolveBinary() (string, error) { + if p := os.Getenv(envBinary); p != "" { + if _, err := os.Stat(p); err != nil { + return "", fmt.Errorf("%s=%s: %w", envBinary, p, err) + } + return p, nil + } + + release := os.Getenv(envRelease) + if release == "" { + release = defaultRelease + } + + archive := fmt.Sprintf("qcloud-%s-%s.tar.gz", runtime.GOOS, runtime.GOARCH) + base := fmt.Sprintf("%s/%s/download", releaseBaseURL, release) + + expectedSHA, err := fetchExpectedSHA(base+"/checksums.txt", archive) + if err != nil { + return "", err + } + + cacheDir, err := binaryCacheDir(expectedSHA) + if err != nil { + return "", err + } + + binary := filepath.Join(cacheDir, "qcloud") + if _, err := os.Stat(binary); err == nil { + return binary, nil + } + + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return "", fmt.Errorf("creating cache dir: %w", err) + } + + tmp, err := downloadVerified(base+"/"+archive, expectedSHA) + if err != nil { + return "", err + } + defer func() { _ = os.Remove(tmp) }() + + if err := extractQcloud(tmp, binary); err != nil { + return "", err + } + if err := os.Chmod(binary, 0o755); err != nil { + return "", fmt.Errorf("chmod binary: %w", err) + } + return binary, nil +} + +func binaryCacheDir(sha string) (string, error) { + root, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("resolving user cache dir: %w", err) + } + return filepath.Join(root, "qcloud-e2e", sha[:16]), nil +} + +// fetchExpectedSHA downloads checksums.txt and returns the sha256 for the +// given archive file name. +func fetchExpectedSHA(url, archive string) (string, error) { + body, err := httpGetBody(url) + if err != nil { + return "", err + } + defer func() { _ = body.Close() }() + + scan := bufio.NewScanner(body) + for scan.Scan() { + fields := strings.Fields(scan.Text()) + if len(fields) != 2 { + continue + } + if fields[1] == archive { + return fields[0], nil + } + } + if err := scan.Err(); err != nil { + return "", fmt.Errorf("reading checksums: %w", err) + } + return "", fmt.Errorf("%s not listed in %s", archive, url) +} + +// downloadVerified writes the URL body to a temp file and verifies the +// sha256 against expected. Returns the temp file path on success. +func downloadVerified(url, expected string) (string, error) { + body, err := httpGetBody(url) + if err != nil { + return "", err + } + defer func() { _ = body.Close() }() + + tmp, err := os.CreateTemp("", "qcloud-e2e-*.tar.gz") + if err != nil { + return "", fmt.Errorf("creating temp archive: %w", err) + } + defer func() { _ = tmp.Close() }() + + h := sha256.New() + if _, err := io.Copy(io.MultiWriter(tmp, h), body); err != nil { + _ = os.Remove(tmp.Name()) + return "", fmt.Errorf("downloading %s: %w", url, err) + } + + got := hex.EncodeToString(h.Sum(nil)) + if got != expected { + _ = os.Remove(tmp.Name()) + return "", fmt.Errorf("checksum mismatch for %s: got %s, want %s", url, got, expected) + } + return tmp.Name(), nil +} + +func httpGetBody(url string) (io.ReadCloser, error) { + client := &http.Client{Timeout: downloadTimeout} + resp, err := client.Get(url) //nolint:noctx // bounded by client timeout + if err != nil { + return nil, fmt.Errorf("GET %s: %w", url, err) + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, fmt.Errorf("GET %s: %s", url, resp.Status) + } + return resp.Body, nil +} + +// extractQcloud pulls the "qcloud" binary out of a gzip-compressed tar at +// src and writes it to dst. +func extractQcloud(src, dst string) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + gr, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("gzip reader: %w", err) + } + defer func() { _ = gr.Close() }() + + tr := tar.NewReader(gr) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return fmt.Errorf("qcloud binary not found in %s", src) + } + if err != nil { + return fmt.Errorf("tar reader: %w", err) + } + if filepath.Base(hdr.Name) != "qcloud" { + continue + } + + out, err := os.Create(dst) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + _ = out.Close() + return err + } + return out.Close() + } +} diff --git a/test/e2e/framework/env.go b/test/e2e/framework/env.go new file mode 100644 index 0000000..37951f1 --- /dev/null +++ b/test/e2e/framework/env.go @@ -0,0 +1,206 @@ +package framework + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +const ( + envEnable = "QCLOUD_E2E" + envAPIKey = "QDRANT_CLOUD_API_KEY" + envAccountID = "QDRANT_CLOUD_ACCOUNT_ID" + envEndpoint = "QDRANT_CLOUD_ENDPOINT" + envConfig = "QDRANT_CLOUD_CONFIG" + + defaultRunTimeout = 2 * time.Minute +) + +// Env bundles everything an e2e test needs to shell out to qcloud against a +// real Qdrant Cloud backend. +type Env struct { + BinaryPath string + APIKey string + AccountID string + Endpoint string + + configPath string +} + +// NewEnv returns an Env wired up with credentials from the environment and an +// isolated empty config file. It skips the test if QCLOUD_E2E is not set. +// +// The binary is the one returned by Binary; it is resolved once per process. +func NewEnv(t *testing.T) *Env { + t.Helper() + + if os.Getenv(envEnable) == "" { + t.Skipf("set %s=1 to run e2e tests", envEnable) + } + + apiKey := mustEnv(t, envAPIKey) + accountID := mustEnv(t, envAccountID) + + bin, err := Binary() + if err != nil { + t.Fatalf("resolving qcloud binary: %v", err) + } + + configPath := writeEmptyConfig(t) + + return &Env{ + BinaryPath: bin, + APIKey: apiKey, + AccountID: accountID, + Endpoint: os.Getenv(envEndpoint), + configPath: configPath, + } +} + +func mustEnv(t *testing.T, name string) string { + t.Helper() + v := os.Getenv(name) + if v == "" { + t.Fatalf("%s must be set", name) + } + return v +} + +// writeEmptyConfig creates an empty config.yaml in t.TempDir() so the CLI +// doesn't try to read ~/.config/qcloud/config.yaml. +func writeEmptyConfig(t *testing.T) string { + t.Helper() + path := t.TempDir() + "/config.yaml" + if err := os.WriteFile(path, []byte{}, 0o600); err != nil { + t.Fatalf("writing empty config: %v", err) + } + return path +} + +// RunResult holds the outcome of a single binary invocation. +type RunResult struct { + Stdout string + Stderr string + ExitCode int + Err error +} + +// Run invokes the binary with the given args and the default timeout, failing +// the test if the command exits non-zero. Stdout and stderr are streamed to +// t.Log. +func (e *Env) Run(t *testing.T, args ...string) RunResult { + t.Helper() + return e.RunWithTimeout(t, defaultRunTimeout, args...) +} + +// RunWithTimeout is Run with a caller-supplied timeout. +func (e *Env) RunWithTimeout(t *testing.T, timeout time.Duration, args ...string) RunResult { + t.Helper() + res := e.runRaw(t, timeout, args) + if res.Err != nil { + t.Fatalf("qcloud %s failed: %v", strings.Join(args, " "), res.Err) + } + return res +} + +// RunAllowFail is like Run but returns the result without failing the test. +// Useful for commands where a non-zero exit is part of the assertion. +func (e *Env) RunAllowFail(t *testing.T, args ...string) RunResult { + t.Helper() + return e.runRaw(t, defaultRunTimeout, args) +} + +// RunJSON appends --json to args, runs the binary, and decodes stdout into v. +func (e *Env) RunJSON(t *testing.T, v any, args ...string) { + t.Helper() + res := e.Run(t, append(args, "--json")...) + if err := json.Unmarshal([]byte(res.Stdout), v); err != nil { + t.Fatalf("decoding JSON output of %q: %v\nstdout: %s", + strings.Join(args, " "), err, res.Stdout) + } +} + +func (e *Env) runRaw(t *testing.T, timeout time.Duration, args []string) RunResult { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, e.BinaryPath, args...) + cmd.Env = e.commandEnv() + + var stdout, stderr bytes.Buffer + cmd.Stdout = io.MultiWriter(&stdout, testLogWriter{t: t, prefix: "stdout: ", redact: e.APIKey}) + cmd.Stderr = io.MultiWriter(&stderr, testLogWriter{t: t, prefix: "stderr: ", redact: e.APIKey}) + + t.Logf("exec: qcloud %s", strings.Join(args, " ")) + + err := cmd.Run() + exitCode := -1 + if cmd.ProcessState != nil { + exitCode = cmd.ProcessState.ExitCode() + } + return RunResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + Err: err, + } +} + +func (e *Env) commandEnv() []string { + env := []string{ + fmt.Sprintf("%s=%s", envAPIKey, e.APIKey), + fmt.Sprintf("%s=%s", envAccountID, e.AccountID), + fmt.Sprintf("%s=%s", envConfig, e.configPath), + "HOME=" + e.homeDir(), + "PATH=" + os.Getenv("PATH"), + } + if e.Endpoint != "" { + env = append(env, fmt.Sprintf("%s=%s", envEndpoint, e.Endpoint)) + } + return env +} + +// homeDir points at the directory containing the empty config file so any +// code that falls through to $HOME still lands inside the test sandbox. +func (e *Env) homeDir() string { + if i := strings.LastIndex(e.configPath, "/"); i > 0 { + return e.configPath[:i] + } + return os.TempDir() +} + +// Redact masks the API key in s. Useful when asserting against stdout/stderr. +func (e *Env) Redact(s string) string { + if e.APIKey == "" { + return s + } + return strings.ReplaceAll(s, e.APIKey, "[REDACTED]") +} + +// testLogWriter forwards writes to t.Log, splitting on newlines and redacting +// the API key so it never ends up in CI output. +type testLogWriter struct { + t *testing.T + prefix string + redact string +} + +func (w testLogWriter) Write(p []byte) (int, error) { + s := string(p) + if w.redact != "" { + s = strings.ReplaceAll(s, w.redact, "[REDACTED]") + } + for line := range strings.SplitSeq(strings.TrimRight(s, "\n"), "\n") { + w.t.Log(w.prefix + line) + } + return len(p), nil +} diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go new file mode 100644 index 0000000..28a502d --- /dev/null +++ b/test/e2e/main_test.go @@ -0,0 +1,24 @@ +package e2e_test + +import ( + "log" + "os" + "testing" + + "github.com/qdrant/qcloud-cli/test/e2e/framework" +) + +// TestMain pre-resolves the qcloud binary once before any test runs. Without +// this, every test that calls framework.NewEnv would otherwise trigger a +// fresh download attempt on its first invocation. +// +// When QCLOUD_E2E is unset we skip setup entirely so `go test ./...` stays +// free of network calls. +func TestMain(m *testing.M) { + if os.Getenv("QCLOUD_E2E") != "" { + if _, err := framework.Binary(); err != nil { + log.Fatalf("e2e: resolving qcloud binary: %v", err) + } + } + os.Exit(m.Run()) +} From 9467a511394647988d86d9ea9d280ceb5142d440 Mon Sep 17 00:00:00 2001 From: dhernando Date: Fri, 17 Apr 2026 11:06:08 +0200 Subject: [PATCH 4/5] fix: refact cluster keys using a JWT expression --- test/e2e/framework/env.go | 20 ++++++++++---- test/e2e/framework/redact_test.go | 46 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 test/e2e/framework/redact_test.go diff --git a/test/e2e/framework/env.go b/test/e2e/framework/env.go index 37951f1..6e7e51c 100644 --- a/test/e2e/framework/env.go +++ b/test/e2e/framework/env.go @@ -8,6 +8,7 @@ import ( "io" "os" "os/exec" + "regexp" "strings" "testing" "time" @@ -178,16 +179,24 @@ func (e *Env) homeDir() string { return os.TempDir() } -// Redact masks the API key in s. Useful when asserting against stdout/stderr. +// Redact masks the API key and JWT-like tokens in s. Useful when asserting +// against stdout/stderr. func (e *Env) Redact(s string) string { - if e.APIKey == "" { - return s + if e.APIKey != "" { + s = strings.ReplaceAll(s, e.APIKey, "[REDACTED]") } - return strings.ReplaceAll(s, e.APIKey, "[REDACTED]") + return jwtLikeRe.ReplaceAllString(s, "[REDACTED]") } +// jwtLikeRe matches strings that look like JWTs: three base64url segments +// separated by dots, where the first two segments decode to JSON objects. +// We anchor on word boundaries so we don't clip larger tokens. +var jwtLikeRe = regexp.MustCompile(`\b[A-Za-z0-9_-]{4,}\.` + + `[A-Za-z0-9_-]{4,}\.` + + `[A-Za-z0-9_-]{4,}\b`) + // testLogWriter forwards writes to t.Log, splitting on newlines and redacting -// the API key so it never ends up in CI output. +// secrets so they never end up in CI output. type testLogWriter struct { t *testing.T prefix string @@ -199,6 +208,7 @@ func (w testLogWriter) Write(p []byte) (int, error) { if w.redact != "" { s = strings.ReplaceAll(s, w.redact, "[REDACTED]") } + s = jwtLikeRe.ReplaceAllString(s, "[REDACTED]") for line := range strings.SplitSeq(strings.TrimRight(s, "\n"), "\n") { w.t.Log(w.prefix + line) } diff --git a/test/e2e/framework/redact_test.go b/test/e2e/framework/redact_test.go new file mode 100644 index 0000000..51f33d4 --- /dev/null +++ b/test/e2e/framework/redact_test.go @@ -0,0 +1,46 @@ +package framework + +import "testing" + +func TestJWTLikeRedaction(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "jwt token", + input: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123_signature-value", + want: "[REDACTED]", + }, + { + name: "jwt embedded in text", + input: "Your key: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123_signature-value here", + want: "Your key: [REDACTED] here", + }, + { + name: "no jwt", + input: "just a normal string", + want: "just a normal string", + }, + { + name: "dotted but too short segments", + input: "a.b.c", + want: "a.b.c", + }, + { + name: "version-like string not matched", + input: "v1.2.3", + want: "v1.2.3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := jwtLikeRe.ReplaceAllString(tt.input, "[REDACTED]") + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} From 4260d138391d7be149f65f55b2081c96c199dd90 Mon Sep 17 00:00:00 2001 From: dhernando Date: Fri, 17 Apr 2026 11:17:02 +0200 Subject: [PATCH 5/5] docs: add a sample on how the checksums file looks like --- test/e2e/framework/binary.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/e2e/framework/binary.go b/test/e2e/framework/binary.go index 942509d..ae62c1c 100644 --- a/test/e2e/framework/binary.go +++ b/test/e2e/framework/binary.go @@ -110,7 +110,11 @@ func binaryCacheDir(sha string) (string, error) { } // fetchExpectedSHA downloads checksums.txt and returns the sha256 for the -// given archive file name. +// given archive file name. The file is generated by goreleaser and looks like: +// +// a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2 qcloud-linux-amd64.tar.gz +// f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5 qcloud-linux-arm64.tar.gz +// 9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b qcloud-darwin-arm64.tar.gz func fetchExpectedSHA(url, archive string) (string, error) { body, err := httpGetBody(url) if err != nil {