Skip to content
This repository was archived by the owner on Oct 8, 2026. It is now read-only.

Commit f34d08d

Browse files
authored
test: add Hoglake deployment E2E and rollout tooling
1 parent 7dd0546 commit f34d08d

4 files changed

Lines changed: 333 additions & 3 deletions

File tree

‎docs/runbooks/trino-hoglake-provisioning.md‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ admitted only after that refresh verifies them. Rollout certification also uses
6262
one inventory for the whole admitted tenant set.
6363

6464
Readiness also requires the existing authentication and cell gates. It verifies
65-
metadata and connector availability, but does not perform S3 writes. A live tenant write and compaction check must provide that verification. The administrative OPA grant permits
65+
metadata and connector availability, but does not perform S3 writes. The smoke
66+
test below provides that verification. The administrative OPA grant permits
6667
connector inventory; it does not grant tenant data writes.
6768

6869
Hoglake warehouse deprovisioning, organization deletion, and warehouse replacement
@@ -97,8 +98,7 @@ or ownership mismatches explicitly; do not drop catalogs to force a switch.
9798
6. Enable the managed configuration, create a dedicated pilot tenant, select its
9899
cell, then enable Trino. Hoglake is assigned automatically. Wait for
99100
reconciled readiness.
100-
7. Verify tenant writes, CTAS, and compaction against the deployed service. Keep
101-
pilot enablement limited until these checks succeed.
101+
7. Run the tenant smoke test. Keep pilot enablement limited until it succeeds.
102102

103103
The historical global `DUCKGRES_TRINO_HOGLAKE_URI` switch is deprecated and
104104
ignored, with a startup warning. It cannot override a client's stored backend.
@@ -107,3 +107,38 @@ The frozen performance runner still sets the historical switch: its old setup
107107
is insufficient for new-client onboarding. Updating that runner's storage and
108108
fixture setup is separate work; do not repurpose immutable fixture prefixes
109109
as managed write paths.
110+
111+
## Live smoke test
112+
113+
`just test-trino-hoglake-smoke` runs HTTP client regression tests and skips live
114+
operations by default. To opt in, provide these variables through the approved
115+
runtime credential mechanism:
116+
117+
```text
118+
HOGLAKE_SMOKE_TEST=1
119+
TRINO_SERVER=https://trino.example
120+
TRINO_USER=<tenant-principal>
121+
TRINO_PASSWORD=<tenant-password>
122+
TRINO_CATALOG=<tenant-trino-catalog>
123+
TRINO_ROUTING_GROUP=<cell-routing-group-if-required>
124+
HOGLAKE_URI=https://lake.example
125+
HOGLAKE_CATALOG=<tenant-hoglake-catalog>
126+
HOGLAKE_NAMESPACE=main
127+
```
128+
129+
Run only against a dedicated test tenant: compaction applies to the entire
130+
Hoglake catalog. Coordinate automatic maintenance so it does not consume the
131+
multi-file baseline before the assertion. The runner needs authorized network
132+
access to both endpoints. Use tenant credentials, not provisioner administrator
133+
credentials, for Trino operations.
134+
135+
The test creates randomly named tables, inserts eight separate batches including
136+
a decimal exceeding INT64, checks exact values, runs CTAS, and triggers Hoglake
137+
compaction. It checks unchanged source rows and a reduced source file count.
138+
Cleanup deletes only its generated tables using the Hoglake REST API, since the
139+
Trino connector does not currently implement DROP TABLE. A cleanup failure is
140+
reported as a test failure and requires explicit operator cleanup. No mutation
141+
is blindly retried after an uncertain response.
142+
143+
Passing unit tests is not evidence of a successful deployment. Record live smoke
144+
results separately after the prerequisites are applied.

‎justfile‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,11 @@ test-controlplane-k8s:
347347
test-trino-admin:
348348
go test -v -count=1 -tags kubernetes -run Trino ./controlplane/admin ./tests/configstore
349349

350+
# Set HOGLAKE_SMOKE_TEST=1 and the documented environment for a dedicated test tenant.
351+
[group('test')]
352+
test-trino-hoglake-smoke:
353+
go test -v -count=1 -timeout 5m ./tests/trino-hoglake-smoke
354+
350355
# Test isolated deployment fixtures without contacting a cluster.
351356
[group('test')]
352357
test-mw-fixtures:
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package trino_hoglake_smoke
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
)
12+
13+
func TestQueryRejectsCredentialRedirects(t *testing.T) {
14+
for _, mode := range []string{"redirect", "continuation"} {
15+
t.Run(mode, func(t *testing.T) {
16+
leaked := false
17+
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18+
leaked = true
19+
_, _ = w.Write([]byte(`{}`))
20+
}))
21+
defer target.Close()
22+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23+
if mode == "redirect" {
24+
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
25+
return
26+
}
27+
_ = json.NewEncoder(w).Encode(map[string]string{"nextUri": target.URL + "/next"})
28+
}))
29+
defer server.Close()
30+
client, err := newSmokeClient(server.URL, "test-user", "test-password")
31+
if err != nil {
32+
t.Fatal(err)
33+
}
34+
if _, err := client.query(context.Background(), "SELECT 1"); err == nil {
35+
t.Fatal("query accepted a redirect outside the configured origin")
36+
}
37+
if leaked {
38+
t.Fatal("query sent a request to a different origin")
39+
}
40+
})
41+
}
42+
}
43+
44+
func TestQueryPreservesTenantIdentityAcrossPages(t *testing.T) {
45+
var server *httptest.Server
46+
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47+
user, password, ok := r.BasicAuth()
48+
if !ok || user != "test-user" || password != "test-password" || r.Header.Get("X-Trino-User") != user || r.Header.Get("X-Trino-Routing-Group") != "test-cell" {
49+
t.Error("query lost credentials or routing identity")
50+
}
51+
if r.URL.Path == "/v1/statement" {
52+
_, _ = fmt.Fprintf(w, `{"nextUri":%q}`, server.URL+"/next")
53+
return
54+
}
55+
_, _ = w.Write([]byte(`{"data":[[1,"123456789012345678901234567890.12"]]}`))
56+
}))
57+
defer server.Close()
58+
client, err := newSmokeClient(server.URL, "test-user", "test-password")
59+
if err != nil {
60+
t.Fatal(err)
61+
}
62+
client.routingGroup = "test-cell"
63+
rows, err := client.query(context.Background(), "SELECT 1")
64+
if err != nil {
65+
t.Fatal(err)
66+
}
67+
if len(rows) != 1 || rows[0][0] != json.Number("1") || rows[0][1] != "123456789012345678901234567890.12" {
68+
t.Fatalf("unexpected rows: %v", rows)
69+
}
70+
}
71+
72+
func TestSmokeClientRejectsUnsafeURLs(t *testing.T) {
73+
for _, server := range []string{"http://example.test", "https://user:password@example.test", "https://example.test?token=secret", "https://example.test#fragment"} {
74+
if _, err := newSmokeClient(server, "test-user", "test-password"); err == nil {
75+
t.Fatalf("accepted unsafe URL %s", strings.Split(server, ":")[0])
76+
}
77+
}
78+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
package trino_hoglake_smoke
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"crypto/rand"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"net/url"
12+
"os"
13+
"reflect"
14+
"strings"
15+
"testing"
16+
"time"
17+
)
18+
19+
type statementResponse struct {
20+
Data [][]any `json:"data"`
21+
NextURI string `json:"nextUri"`
22+
Error *struct {
23+
Name string `json:"errorName"`
24+
} `json:"error"`
25+
}
26+
27+
type smokeClient struct {
28+
http *http.Client
29+
server *url.URL
30+
user, password string
31+
routingGroup string
32+
}
33+
34+
func newSmokeClient(server, user, password string) (*smokeClient, error) {
35+
u, err := url.Parse(server)
36+
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
37+
return nil, fmt.Errorf("invalid Trino server URL")
38+
}
39+
if u.Scheme != "https" && (u.Scheme != "http" || (u.Hostname() != "localhost" && u.Hostname() != "127.0.0.1" && u.Hostname() != "::1")) {
40+
return nil, fmt.Errorf("Trino credentials require HTTPS outside loopback")
41+
}
42+
return &smokeClient{
43+
server: u, user: user, password: password,
44+
http: &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }},
45+
}, nil
46+
}
47+
48+
func (c *smokeClient) query(ctx context.Context, sql string) ([][]any, error) {
49+
ctx, cancel := context.WithTimeout(ctx, time.Minute)
50+
defer cancel()
51+
next := strings.TrimRight(c.server.String(), "/") + "/v1/statement"
52+
method := http.MethodPost
53+
var body io.Reader = strings.NewReader(sql)
54+
var rows [][]any
55+
for page := 0; page < 1000; page++ {
56+
u, err := url.Parse(next)
57+
if err != nil || u.User != nil || u.Scheme != c.server.Scheme || u.Host != c.server.Host {
58+
return nil, fmt.Errorf("Trino continuation changed origin")
59+
}
60+
req, err := http.NewRequestWithContext(ctx, method, next, body)
61+
if err != nil {
62+
return nil, err
63+
}
64+
req.SetBasicAuth(c.user, c.password)
65+
req.Header.Set("X-Trino-User", c.user)
66+
req.Header.Set("X-Trino-Transaction-Id", "NONE")
67+
if c.routingGroup != "" {
68+
req.Header.Set("X-Trino-Routing-Group", c.routingGroup)
69+
}
70+
resp, err := c.http.Do(req)
71+
if err != nil {
72+
return nil, err
73+
}
74+
var result statementResponse
75+
decoder := json.NewDecoder(io.LimitReader(resp.Body, 1<<20))
76+
decoder.UseNumber()
77+
err = decoder.Decode(&result)
78+
_ = resp.Body.Close()
79+
if resp.StatusCode != http.StatusOK {
80+
return nil, fmt.Errorf("Trino returned HTTP %d", resp.StatusCode)
81+
}
82+
if err != nil {
83+
return nil, err
84+
}
85+
if result.Error != nil {
86+
return nil, fmt.Errorf("Trino query failed: %s", result.Error.Name)
87+
}
88+
rows = append(rows, result.Data...)
89+
if result.NextURI == "" {
90+
return rows, nil
91+
}
92+
next, method, body = result.NextURI, http.MethodGet, nil
93+
}
94+
return nil, fmt.Errorf("Trino query exceeded its page bound")
95+
}
96+
97+
func quote(name string) string { return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` }
98+
99+
// This opt-in test writes only generated tables, but compaction applies to the
100+
// whole catalog. Run it against a dedicated, provisioned Hoglake test tenant.
101+
func TestManagedHoglakeWritesAndCompaction(t *testing.T) {
102+
if os.Getenv("HOGLAKE_SMOKE_TEST") != "1" {
103+
t.Skip("set HOGLAKE_SMOKE_TEST=1 for the live managed-tenant smoke test")
104+
}
105+
env := func(key string) string {
106+
t.Helper()
107+
value := os.Getenv(key)
108+
if value == "" {
109+
t.Fatalf("%s is required", key)
110+
}
111+
return value
112+
}
113+
client, err := newSmokeClient(env("TRINO_SERVER"), env("TRINO_USER"), env("TRINO_PASSWORD"))
114+
if err != nil {
115+
t.Fatal(err)
116+
}
117+
client.routingGroup = os.Getenv("TRINO_ROUTING_GROUP")
118+
trinoCatalog, namespace := env("TRINO_CATALOG"), env("HOGLAKE_NAMESPACE")
119+
base, err := url.Parse(env("HOGLAKE_URI"))
120+
if err != nil || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || (base.Scheme != "http" && base.Scheme != "https") {
121+
t.Fatal("invalid Hoglake base URI")
122+
}
123+
catalogPath := strings.TrimRight(base.String(), "/") + "/v1/catalogs/" + url.PathEscape(env("HOGLAKE_CATALOG"))
124+
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
125+
defer cancel()
126+
query := func(sql string) [][]any {
127+
t.Helper()
128+
rows, err := client.query(ctx, sql)
129+
if err != nil {
130+
t.Fatal(err)
131+
}
132+
return rows
133+
}
134+
id := make([]byte, 8)
135+
if _, err := rand.Read(id); err != nil {
136+
t.Fatal(err)
137+
}
138+
name := fmt.Sprintf("hoglake_smoke_%x", id)
139+
table := quote(trinoCatalog) + "." + quote(namespace) + "." + quote(name)
140+
copyTable := quote(trinoCatalog) + "." + quote(namespace) + "." + quote(name+"_copy")
141+
for _, target := range []string{name, name + "_copy"} {
142+
t.Cleanup(func() {
143+
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second)
144+
defer cleanupCancel()
145+
endpoint := catalogPath + "/namespaces/" + url.PathEscape(namespace) + "/tables/" + url.PathEscape(target)
146+
req, err := http.NewRequestWithContext(cleanupCtx, http.MethodDelete, endpoint, nil)
147+
if err != nil {
148+
t.Errorf("cleanup request failed: %v", err)
149+
return
150+
}
151+
resp, err := client.http.Do(req)
152+
if err != nil {
153+
t.Errorf("cleanup %s failed: %v", target, err)
154+
return
155+
}
156+
_ = resp.Body.Close()
157+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound {
158+
t.Errorf("cleanup %s returned HTTP %d", target, resp.StatusCode)
159+
}
160+
})
161+
}
162+
query("CREATE TABLE " + table + " (id bigint, amount decimal(38,2))")
163+
var expected [][]any
164+
for i := 1; i <= 8; i++ {
165+
query(fmt.Sprintf("INSERT INTO %s VALUES (%d, DECIMAL '123456789012345678901234567890.12')", table, i))
166+
expected = append(expected, []any{json.Number(fmt.Sprint(i)), "123456789012345678901234567890.12"})
167+
}
168+
read := func(target string) [][]any {
169+
return query("SELECT id, CAST(amount AS varchar) FROM " + target + " ORDER BY id")
170+
}
171+
if rows := read(table); !reflect.DeepEqual(rows, expected) {
172+
t.Fatal("INSERT did not preserve the expected rows and large decimals")
173+
}
174+
query("CREATE TABLE " + copyTable + " AS SELECT * FROM " + table)
175+
if rows := read(copyTable); !reflect.DeepEqual(rows, expected) {
176+
t.Fatal("CTAS did not preserve the expected rows and large decimals")
177+
}
178+
rest := func(method, endpoint string, result any) {
179+
t.Helper()
180+
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(nil))
181+
if err != nil {
182+
t.Fatal(err)
183+
}
184+
resp, err := client.http.Do(req)
185+
if err != nil {
186+
t.Fatal(err)
187+
}
188+
defer func() { _ = resp.Body.Close() }()
189+
if resp.StatusCode != http.StatusOK {
190+
t.Fatalf("Hoglake returned HTTP %d", resp.StatusCode)
191+
}
192+
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(result); err != nil {
193+
t.Fatal(err)
194+
}
195+
}
196+
scanPath := catalogPath + "/namespaces/" + url.PathEscape(namespace) + "/tables/" + url.PathEscape(name) + "/scan"
197+
var before, after []json.RawMessage
198+
rest(http.MethodGet, scanPath, &before)
199+
if len(before) < 2 {
200+
t.Fatal("compaction baseline already has fewer than two files; rerun with automatic maintenance paused for the test catalog")
201+
}
202+
var compact json.RawMessage
203+
rest(http.MethodPost, catalogPath+"/maintenance/compact?batch=100", &compact)
204+
rest(http.MethodGet, scanPath, &after)
205+
if len(after) >= len(before) {
206+
t.Fatalf("compaction did not reduce data files: before=%d after=%d", len(before), len(after))
207+
}
208+
if rows := read(table); !reflect.DeepEqual(rows, expected) {
209+
t.Fatal("compaction changed rows or decimal values")
210+
}
211+
t.Logf("CREATE, INSERT, CTAS and compaction passed; files %d -> %d", len(before), len(after))
212+
}

0 commit comments

Comments
 (0)