Skip to content

Commit c46d667

Browse files
committed
dashboard/app: make it possible to test code that uses spanner
Start spanner emulator for tests. Create isolated per-test instance+database. Test that DDL migration scripts are work.
1 parent 034cff1 commit c46d667

File tree

5 files changed

+193
-0
lines changed

5 files changed

+193
-0
lines changed

dashboard/app/ai_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2025 syzkaller project authors. All rights reserved.
2+
// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
3+
4+
package main
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestAIMigrations(t *testing.T) {
13+
// Ensure spanner DDL files are syntax-correct and idempotent.
14+
// NewSpannerCtx already run the "up" statements, so we start with "down".
15+
c := NewSpannerCtx(t)
16+
defer c.Close()
17+
18+
up, err := loadDDLStatements("1_initialize.up.sql")
19+
require.NoError(t, err)
20+
down, err := loadDDLStatements("1_initialize.down.sql")
21+
require.NoError(t, err)
22+
23+
require.NoError(t, executeSpannerDDL(c.ctx, down))
24+
require.NoError(t, executeSpannerDDL(c.ctx, up))
25+
require.NoError(t, executeSpannerDDL(c.ctx, down))
26+
}

dashboard/app/aidb/crud.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Copyright 2025 syzkaller project authors. All rights reserved.
2+
// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
3+
4+
package aidb
5+
6+
const (
7+
Instance = "syzbot"
8+
Database = "ai"
9+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE Test;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
CREATE TABLE Test (
2+
Name STRING(1000) NOT NULL,
3+
) PRIMARY KEY (Name);

dashboard/app/util_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,31 @@ import (
2222
"runtime"
2323
"strings"
2424
"sync"
25+
"sync/atomic"
2526
"testing"
2627
"time"
2728

29+
"cloud.google.com/go/spanner/admin/database/apiv1"
30+
"cloud.google.com/go/spanner/admin/database/apiv1/databasepb"
31+
"cloud.google.com/go/spanner/admin/instance/apiv1"
32+
"cloud.google.com/go/spanner/admin/instance/apiv1/instancepb"
2833
"github.com/google/go-cmp/cmp"
2934
"github.com/google/syzkaller/dashboard/api"
35+
"github.com/google/syzkaller/dashboard/app/aidb"
3036
"github.com/google/syzkaller/dashboard/dashapi"
3137
"github.com/google/syzkaller/pkg/coveragedb/spannerclient"
3238
"github.com/google/syzkaller/pkg/covermerger"
3339
"github.com/google/syzkaller/pkg/email"
40+
"github.com/google/syzkaller/pkg/osutil"
3441
"github.com/google/syzkaller/pkg/subsystem"
42+
"google.golang.org/api/option"
43+
"google.golang.org/appengine/v2"
3544
"google.golang.org/appengine/v2/aetest"
3645
db "google.golang.org/appengine/v2/datastore"
3746
"google.golang.org/appengine/v2/log"
3847
aemail "google.golang.org/appengine/v2/mail"
48+
"google.golang.org/grpc"
49+
"google.golang.org/grpc/credentials/insecure"
3950
)
4051

4152
type Ctx struct {
@@ -58,11 +69,29 @@ var skipDevAppserverTests = func() bool {
5869
}()
5970

6071
func NewCtx(t *testing.T) *Ctx {
72+
return newCtx(t, false)
73+
}
74+
75+
func newCtx(t *testing.T, needSpanner bool) *Ctx {
6176
if skipDevAppserverTests {
6277
t.Skip("skipping test (no dev_appserver.py)")
6378
}
6479
t.Parallel()
80+
appID := ""
81+
if needSpanner {
82+
// We need a unique AppID b/c spanner database is attached to the AppID.
83+
// But don't use it if spanner is not used b/c it alters some outputs
84+
// checked by existing tests.
85+
appID = fmt.Sprintf("testapp-%v", atomic.AddUint32(&appIDSeq, 1))
86+
initSpannerOnce.Do(func() {
87+
initSpannerErr = initSpanner()
88+
})
89+
if initSpannerErr != nil {
90+
t.Fatalf("failed to init spanner emulator: %v", initSpannerErr)
91+
}
92+
}
6593
inst, err := aetest.NewInstance(&aetest.Options{
94+
AppID: appID,
6695
StartupTimeout: 120 * time.Second,
6796
// Without this option datastore queries return data with slight delay,
6897
// which fails reporting tests.
@@ -89,6 +118,131 @@ func NewCtx(t *testing.T) *Ctx {
89118
return c
90119
}
91120

121+
var (
122+
appIDSeq = uint32(0)
123+
initSpannerOnce sync.Once
124+
initSpannerErr error
125+
)
126+
127+
// Use fixed address for now, assuming it's not used.
128+
// If this does not work, we could randomize the address,
129+
// or start with port 0, and parse out the actual port from the initial output.
130+
const spannerAddr = "localhost:47931"
131+
132+
func NewSpannerCtx(t *testing.T) *Ctx {
133+
c := newCtx(t, true)
134+
if err := createSpannerDatabase(c.ctx); err != nil {
135+
t.Fatalf("spanner: %v", err)
136+
}
137+
return c
138+
}
139+
140+
func initSpanner() error {
141+
appServerPath, err := exec.LookPath("dev_appserver.py")
142+
if err != nil {
143+
return err
144+
}
145+
bin := filepath.Join(filepath.Dir(appServerPath), "cloud_spanner_emulator", "emulator_main")
146+
// Use osutil.Command to set PDEATHSIG.
147+
cmd := osutil.Command(bin, "--host_port", spannerAddr, "--log_requests")
148+
cmd.Stdout = os.Stdout
149+
cmd.Stderr = os.Stderr
150+
if err := cmd.Start(); err != nil {
151+
return err
152+
}
153+
os.Setenv("SPANNER_EMULATOR_HOST", spannerAddr)
154+
// Without this connections to emulator hang, probably some bug somewhere.
155+
os.Setenv("GOOGLE_CLOUD_SPANNER_MULTIPLEXED_SESSIONS", "false")
156+
return nil
157+
}
158+
159+
func createSpannerDatabase(ctx context.Context) error {
160+
// Don't bother destroying instances/databases.
161+
// We create isolated per-test instances, and the emulator is all in-memory.
162+
// So when the emulator is killed with the test binary, everything is gone.
163+
admin, err := instance.NewInstanceAdminClient(ctx,
164+
option.WithEndpoint(spannerAddr),
165+
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
166+
)
167+
if err != nil {
168+
return fmt.Errorf("failed NewInstanceAdminClient: %w", err)
169+
}
170+
defer admin.Close()
171+
req := &instancepb.CreateInstanceRequest{
172+
Parent: fmt.Sprintf("projects/%s", appengine.AppID(ctx)),
173+
InstanceId: aidb.Instance,
174+
Instance: &instancepb.Instance{
175+
Config: fmt.Sprintf("projects/%s/instanceConfigs/emulator-config", appengine.AppID(ctx)),
176+
DisplayName: "Test instance",
177+
NodeCount: 1,
178+
},
179+
}
180+
op, err := admin.CreateInstance(ctx, req)
181+
if err != nil {
182+
return fmt.Errorf("failed CreateInstance: %w", err)
183+
}
184+
if _, err := op.Wait(ctx); err != nil {
185+
return fmt.Errorf("failed CreateInstance: %w", err)
186+
}
187+
dbAdmin, err := database.NewDatabaseAdminClient(ctx,
188+
option.WithEndpoint(spannerAddr),
189+
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
190+
)
191+
if err != nil {
192+
return fmt.Errorf("failed NewDatabaseAdminClient: %w", err)
193+
}
194+
defer dbAdmin.Close()
195+
ddlStatements, err := loadDDLStatements("1_initialize.up.sql")
196+
if err != nil {
197+
return err
198+
}
199+
dbOp, err := dbAdmin.CreateDatabase(ctx, &databasepb.CreateDatabaseRequest{
200+
Parent: fmt.Sprintf("projects/%s/instances/%v", appengine.AppID(ctx), aidb.Instance),
201+
CreateStatement: fmt.Sprintf("CREATE DATABASE `%v`", aidb.Database),
202+
ExtraStatements: ddlStatements,
203+
})
204+
if err != nil {
205+
return fmt.Errorf("failed CreateDatabase: %w", err)
206+
}
207+
if _, err := dbOp.Wait(ctx); err != nil {
208+
return fmt.Errorf("failed CreateDatabase: %w", err)
209+
}
210+
return nil
211+
}
212+
213+
func executeSpannerDDL(ctx context.Context, statements []string) error {
214+
dbAdmin, err := database.NewDatabaseAdminClient(ctx,
215+
option.WithEndpoint(spannerAddr),
216+
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
217+
)
218+
if err != nil {
219+
return fmt.Errorf("failed NewDatabaseAdminClient: %w", err)
220+
}
221+
defer dbAdmin.Close()
222+
dbOp, err := dbAdmin.UpdateDatabaseDdl(ctx, &databasepb.UpdateDatabaseDdlRequest{
223+
Database: fmt.Sprintf("projects/%s/instances/%v/databases/%v",
224+
appengine.AppID(ctx), aidb.Instance, aidb.Database),
225+
Statements: statements,
226+
})
227+
if err != nil {
228+
return fmt.Errorf("failed UpdateDatabaseDdl: %w", err)
229+
}
230+
if err := dbOp.Wait(ctx); err != nil {
231+
return fmt.Errorf("failed UpdateDatabaseDdl: %w", err)
232+
}
233+
return nil
234+
}
235+
236+
func loadDDLStatements(file string) ([]string, error) {
237+
data, err := os.ReadFile(filepath.Join("aidb", "migrations", file))
238+
if err != nil {
239+
return nil, err
240+
}
241+
// We need individual statements. Assume semicolon is not used in other places than statements end.
242+
statements := strings.Split(string(data), ";")
243+
return statements[:len(statements)-1], nil
244+
}
245+
92246
func (c *Ctx) config() *GlobalConfig {
93247
return getConfig(c.ctx)
94248
}

0 commit comments

Comments
 (0)