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
33 changes: 33 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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 }}
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

Expand Down
90 changes: 90 additions & 0 deletions test/e2e/README.md
Original file line number Diff line number Diff line change
@@ -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-<os>-<arch>.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/<sha>/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-<random>`, 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.
136 changes: 136 additions & 0 deletions test/e2e/cluster_helpers_test.go
Original file line number Diff line number Diff line change
@@ -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-<hex>" 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[:])
}
31 changes: 31 additions & 0 deletions test/e2e/cluster_test.go
Original file line number Diff line number Diff line change
@@ -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",
)
}
Loading
Loading