Skip to content

Commit b7cca9d

Browse files
Merge pull request #111 from open-source-cloud/test/e2e-command-pipeline
test(e2e): command-surface release gate (db/s3/expose/localstack)
2 parents 27cbba1 + bba9a2d commit b7cca9d

2 files changed

Lines changed: 235 additions & 0 deletions

File tree

‎.github/workflows/nightly.yml‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,29 @@ jobs:
4848
- name: nightly gate (fmt-check + vet + cross-build + test-race + determinism)
4949
run: make nightly
5050

51+
# Heavy cloud-engine command e2e (localstack + the `aws` shim, plus the full
52+
# db/s3/expose command surface). It pulls large images (localstack), so it runs
53+
# here as a nightly RELEASE GATE rather than on every PR — the per-PR CI already
54+
# runs the lighter postgres+minio+redis command e2e (DEVSTACK_E2E=1). This is the
55+
# "every command actually works against a live stack" gate.
56+
e2e-cloud:
57+
if: github.repository == 'open-source-cloud/devstack'
58+
runs-on: ubuntu-latest
59+
steps:
60+
- uses: actions/checkout@v4
61+
- uses: actions/setup-go@v5
62+
with:
63+
go-version: ${{ env.GO_VERSION }}
64+
check-latest: true
65+
cache: true
66+
- name: docker available
67+
run: docker version
68+
- name: cloud command e2e (localstack health gate + aws shim + db/s3/expose)
69+
env:
70+
DEVSTACK_E2E: "1"
71+
DEVSTACK_E2E_CLOUD: "1"
72+
run: go test -tags=e2e ./tests/e2e/... -run 'Commands|LocalStack' -count=1 -v
73+
5174
# Optional rolling pre-release. Default OFF: enable by setting the repo variable
5275
# gh variable set NIGHTLY_PRERELEASE --body true
5376
# Produces goreleaser SNAPSHOT artifacts (no real version, no git tag) and uploads

‎tests/e2e/commands_test.go‎

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
//go:build e2e
2+
3+
package e2e
4+
5+
import (
6+
"encoding/json"
7+
"net"
8+
"os"
9+
"os/exec"
10+
"strconv"
11+
"strings"
12+
"testing"
13+
"time"
14+
)
15+
16+
// This file is the command-surface release gate (owner directive): it drives the
17+
// real devstack binary against a live daemon and asserts every data-plane command
18+
// group WORKS end to end — the coverage that would have caught the shared-localstack
19+
// health deadlock and the `db create` connect race before they reached main.
20+
//
21+
// Two tiers:
22+
// - CoreResources (postgres + minio + redis): db / s3 / shared expose+ports —
23+
// the commands a developer hits daily. Runs under DEVSTACK_E2E=1 (per-PR CI).
24+
// - CloudEngines (adds localstack): the localstack health-gate regression + the
25+
// `aws` shim. Heavy image → additionally gated on DEVSTACK_E2E_CLOUD=1 (nightly).
26+
27+
const wsCoreCloud = `apiVersion: devstack/v1
28+
kind: Workspace
29+
name: e2ecmd
30+
shared:
31+
postgres: { template: postgres, params: { version: "18" } }
32+
minio: { template: minio }
33+
redis: { template: redis }
34+
projects:
35+
- { name: app, path: app }
36+
`
37+
38+
const projUsesAll = `apiVersion: devstack/v1
39+
kind: Project
40+
name: app
41+
services:
42+
web:
43+
template: node.vite
44+
uses: [workspace.shared.postgres, workspace.shared.minio, workspace.shared.redis]
45+
`
46+
47+
func coreCloudWorkspace() map[string]string {
48+
return map[string]string{"workspace.yaml": wsCoreCloud, "app/devstack.yaml": projUsesAll}
49+
}
50+
51+
// requireCloudE2E additionally gates the heavy localstack tier.
52+
func requireCloudE2E(t *testing.T) {
53+
t.Helper()
54+
requireDaemon(t)
55+
if os.Getenv("DEVSTACK_E2E_CLOUD") != "1" {
56+
t.Skip("cloud-engine e2e pulls heavy images (localstack); set DEVSTACK_E2E_CLOUD=1 to run")
57+
}
58+
}
59+
60+
func cleanupSharedStack(t *testing.T, s *sandbox) {
61+
t.Helper()
62+
t.Cleanup(func() {
63+
_, _ = s.tryRun("shared", "expose", "--off")
64+
_, _ = s.tryRun("down")
65+
dockerComposeDown("devstack-shared")
66+
dockerComposeDown("devstack-app")
67+
_ = exec.Command("docker", "network", "rm", "devstack_shared").Run()
68+
})
69+
}
70+
71+
// TestE2E_Commands_CoreResources is the daily-driver command gate: bring up a
72+
// postgres+minio+redis stack, then exercise db / s3 / shared expose+ports and
73+
// assert each works (the db path is exactly the one that used to fail with the
74+
// connect-reset race).
75+
func TestE2E_Commands_CoreResources(t *testing.T) {
76+
requireDaemon(t)
77+
s := newSandbox(t, coreCloudWorkspace())
78+
cleanupSharedStack(t, s)
79+
80+
// up: three shared engines health-gated (minio via `mc ready`, postgres via
81+
// pg_isready, redis via redis-cli ping) + app's postgres role/db provisioned.
82+
up := s.run(t, "up")
83+
if !strings.Contains(up, "[ok]") || strings.Contains(up, "[failed]") {
84+
t.Fatalf("up did not complete cleanly:\n%s", up)
85+
}
86+
for _, alias := range []string{"shared-postgres", "shared-minio", "shared-redis"} {
87+
if st := s.run(t, "shared", "status"); !strings.Contains(st, alias) {
88+
t.Errorf("shared status missing %s:\n%s", alias, st)
89+
}
90+
}
91+
92+
// --- db (postgres, in-process pgx) --------------------------------------
93+
if out := s.run(t, "db", "create", "orders"); !strings.Contains(out, "app_orders") {
94+
t.Errorf("db create did not report app_orders:\n%s", out)
95+
}
96+
// Idempotent second create.
97+
s.run(t, "db", "create", "orders")
98+
if out := s.run(t, "db", "list", "--json"); !strings.Contains(out, "app_orders") {
99+
t.Errorf("db list --json missing app_orders:\n%s", out)
100+
}
101+
s.run(t, "db", "drop", "orders", "--yes")
102+
103+
// --- s3 (minio, in-process aws-sdk-go-v2) -------------------------------
104+
if out := s.run(t, "s3", "mb", "uploads"); !strings.Contains(out, "app-uploads") {
105+
t.Errorf("s3 mb did not report app-uploads:\n%s", out)
106+
}
107+
if out := s.run(t, "s3", "ls"); !strings.Contains(out, "app-uploads") {
108+
t.Errorf("s3 ls missing app-uploads:\n%s", out)
109+
}
110+
s.run(t, "s3", "rb", "uploads", "--yes", "--force")
111+
112+
// --- resource list (the generic surface) --------------------------------
113+
if out := s.run(t, "resource", "list", "--json"); !strings.Contains(out, "resources") && !strings.Contains(out, "[]") {
114+
t.Errorf("resource list --json not valid:\n%s", out)
115+
}
116+
117+
// --- messaging degrades cleanly when the engine isn't in the workspace ---
118+
if out, err := s.tryRun("queue", "create", "jobs", "--engine", "nats"); err == nil {
119+
t.Errorf("queue create with no nats engine should fail cleanly, got:\n%s", out)
120+
}
121+
122+
// --- shared expose + ports (GUI-client host access) ---------------------
123+
if out := s.run(t, "shared", "expose", "postgres"); !strings.Contains(out, "127.0.0.1") {
124+
t.Errorf("shared expose did not print a host address:\n%s", out)
125+
}
126+
assertExposedPortReachable(t, s, "postgres")
127+
s.run(t, "shared", "expose", "--off")
128+
129+
s.run(t, "down")
130+
}
131+
132+
// assertExposedPortReachable parses `shared ports --json`, finds the engine's
133+
// primary port, and dials it to prove the publish is live (what DataGrip needs).
134+
func assertExposedPortReachable(t *testing.T, s *sandbox, engine string) {
135+
t.Helper()
136+
out := s.run(t, "shared", "ports", "--json")
137+
var payload struct {
138+
Exposed []struct {
139+
Engine string `json:"engine"`
140+
Port int `json:"port"`
141+
Primary bool `json:"primary"`
142+
} `json:"exposed"`
143+
}
144+
if err := json.Unmarshal([]byte(out), &payload); err != nil {
145+
t.Fatalf("shared ports --json invalid: %v\n%s", err, out)
146+
}
147+
var port int
148+
for _, p := range payload.Exposed {
149+
if p.Engine == engine && p.Primary {
150+
port = p.Port
151+
}
152+
}
153+
if port == 0 {
154+
t.Fatalf("no exposed primary port for %s:\n%s", engine, out)
155+
}
156+
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
157+
deadline := time.Now().Add(15 * time.Second)
158+
for {
159+
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
160+
if err == nil {
161+
_ = conn.Close()
162+
return
163+
}
164+
if time.Now().After(deadline) {
165+
t.Fatalf("exposed %s port %s not reachable: %v", engine, addr, err)
166+
}
167+
time.Sleep(300 * time.Millisecond)
168+
}
169+
}
170+
171+
// TestE2E_Commands_LocalStackHealthAndAws is the localstack health-gate
172+
// regression: a workspace whose only shared engine is localstack must reach
173+
// [ok] on `up` (the deadlock made this [failed] "unhealthy after 1 attempt"),
174+
// and the `aws` shim must resolve the endpoint and list buckets.
175+
func TestE2E_Commands_LocalStackHealthAndAws(t *testing.T) {
176+
requireCloudE2E(t)
177+
if _, err := exec.LookPath("aws"); err != nil {
178+
t.Skip("aws CLI not installed; the shim test needs it")
179+
}
180+
ws := map[string]string{
181+
"workspace.yaml": `apiVersion: devstack/v1
182+
kind: Workspace
183+
name: e2eaws
184+
shared:
185+
localstack: { template: localstack }
186+
projects:
187+
- { name: app, path: app }
188+
`,
189+
"app/devstack.yaml": `apiVersion: devstack/v1
190+
kind: Project
191+
name: app
192+
services:
193+
web:
194+
template: node.vite
195+
uses: [workspace.shared.localstack]
196+
`,
197+
}
198+
s := newSandbox(t, ws)
199+
cleanupSharedStack(t, s)
200+
201+
up := s.run(t, "up")
202+
if !strings.Contains(up, "[ok]") || strings.Contains(up, "[failed]") {
203+
t.Fatalf("localstack up did not go healthy (the health-gate regression):\n%s", up)
204+
}
205+
// The aws shim prepends --endpoint-url + dev creds; `s3 ls` on a fresh
206+
// localstack succeeds with empty output.
207+
if _, err := s.tryRun("aws", "--", "s3", "ls"); err != nil {
208+
out, _ := s.tryRun("aws", "--", "s3", "ls")
209+
t.Errorf("aws shim `s3 ls` failed:\n%s", out)
210+
}
211+
s.run(t, "down")
212+
}

0 commit comments

Comments
 (0)