|
| 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