go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/common/spantest/spantest_common.go (about) 1 // Copyright 2019 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package spantest implements: 16 // * start/stop the Cloud Spanner Emulator, 17 // * creation/removal of a temporary gcloud config, 18 // * creation of a temporary Spanner instance, 19 // * creation/destruction of a temporary Spanner database. 20 package spantest 21 22 import ( 23 "context" 24 "crypto/rand" 25 "encoding/binary" 26 "fmt" 27 "io/ioutil" 28 "os" 29 "os/exec" 30 "regexp" 31 "strings" 32 "testing" 33 "time" 34 35 "cloud.google.com/go/spanner" 36 spandb "cloud.google.com/go/spanner/admin/database/apiv1" 37 dbpb "cloud.google.com/go/spanner/admin/database/apiv1/databasepb" 38 spanins "cloud.google.com/go/spanner/admin/instance/apiv1" 39 inspb "cloud.google.com/go/spanner/admin/instance/apiv1/instancepb" 40 "golang.org/x/oauth2" 41 "google.golang.org/api/option" 42 "google.golang.org/grpc" 43 "google.golang.org/grpc/credentials/insecure" 44 45 "go.chromium.org/luci/auth" 46 "go.chromium.org/luci/common/clock" 47 "go.chromium.org/luci/common/errors" 48 "go.chromium.org/luci/server/span" 49 50 "go.chromium.org/luci/hardcoded/chromeinfra" 51 ) 52 53 const ( 54 // emulatorCfg is the gcloud config name for Cloud Spanner Emulator. 55 emulatorCfg = "spanner-emulator" 56 57 // IntegrationTestEnvVar is the name of the environment variable which controls 58 // whether spanner tests are executed. 59 // The value must be "1" for integration tests to run. 60 IntegrationTestEnvVar = "INTEGRATION_TESTS" 61 ) 62 63 // TempDBConfig specifies how to create a temporary database. 64 type TempDBConfig struct { 65 // InstanceName is the name of Spannner instance where to create the 66 // temporary database. 67 // Format: projects/{project}/instances/{instance}. 68 // Defaults to chromeinfra.TestSpannerInstance. 69 InstanceName string 70 71 // InitScriptPath is a path to a DDL script to initialize the database. 72 // 73 // In lieu of a proper DDL parser, it is parsed using regexes. 74 // Therefore the script MUST: 75 // - Use `#`` and/or `--`` for comments. No block comments. 76 // - Separate DDL statements with `;\n`. 77 // 78 // If empty, the database is created with no tables. 79 InitScriptPath string 80 } 81 82 var ddlStatementSepRe = regexp.MustCompile(`;\s*\n`) 83 var commentRe = regexp.MustCompile(`(--|#)[^\n]*`) 84 85 // readDDLStatements read the file at cfg.InitScriptPath as a sequence of DDL 86 // statements. If the path is empty, returns (nil, nil). 87 func (cfg *TempDBConfig) readDDLStatements() ([]string, error) { 88 if cfg.InitScriptPath == "" { 89 return nil, nil 90 } 91 92 contents, err := os.ReadFile(cfg.InitScriptPath) 93 if err != nil { 94 return nil, err 95 } 96 97 statements := ddlStatementSepRe.Split(string(contents), -1) 98 ret := statements[:0] 99 for _, stmt := range statements { 100 stmt = commentRe.ReplaceAllString(stmt, "") 101 stmt = strings.TrimSpace(stmt) 102 if stmt != "" { 103 ret = append(ret, stmt) 104 } 105 } 106 return ret, nil 107 } 108 109 // TempDB is a temporary Spanner database. 110 type TempDB struct { 111 Name string 112 opts []option.ClientOption 113 } 114 115 // Client returns a spanner client connected to the database. 116 func (db *TempDB) Client(ctx context.Context) (*spanner.Client, error) { 117 return spanner.NewClient(ctx, db.Name, db.opts...) 118 } 119 120 // Drop deletes the database. 121 func (db *TempDB) Drop(ctx context.Context) error { 122 client, err := spandb.NewDatabaseAdminClient(ctx, db.opts...) 123 if err != nil { 124 return err 125 } 126 defer client.Close() 127 128 return client.DropDatabase(ctx, &dbpb.DropDatabaseRequest{ 129 Database: db.Name, 130 }) 131 } 132 133 var dbNameAlphabetInversedRe = regexp.MustCompile(`[^\w]+`) 134 135 // NewTempDB creates a temporary database with a random name. 136 // The caller is responsible for calling Drop on the returned TempDB to 137 // cleanup resources after usage. Unless it uses Cloud Spanner Emulator, then 138 // the database will be dropped when emulator stops. 139 func NewTempDB(ctx context.Context, cfg TempDBConfig, e *Emulator) (*TempDB, error) { 140 instanceName := cfg.InstanceName 141 if instanceName == "" { 142 instanceName = chromeinfra.TestSpannerInstance 143 } 144 145 initStatements, err := cfg.readDDLStatements() 146 if err != nil { 147 return nil, errors.Annotate(err, "failed to read %q", cfg.InitScriptPath).Err() 148 } 149 150 // Use Spanner emulator if available. 151 // TODO(crbug.com/1066993): require Spanner emulator, then NewTempDB function 152 // can be moved in Emulator struct. 153 var opts []option.ClientOption 154 if e != nil { 155 opts = append(opts, e.opts()...) 156 } else { 157 tokenSource, err := tokenSource(ctx) 158 if err != nil { 159 return nil, err 160 } 161 opts = append(opts, option.WithTokenSource(tokenSource)) 162 } 163 164 client, err := spandb.NewDatabaseAdminClient(ctx, opts...) 165 if err != nil { 166 return nil, err 167 } 168 defer client.Close() 169 170 // Generate a random database name. 171 var random uint32 172 if err := binary.Read(rand.Reader, binary.LittleEndian, &random); err != nil { 173 panic(err) 174 } 175 dbName := fmt.Sprintf("tmp%s-%d", time.Now().Format("20060102-"), random) 176 dbName = SanitizeDBName(dbName) 177 178 dbOp, err := client.CreateDatabase(ctx, &dbpb.CreateDatabaseRequest{ 179 Parent: instanceName, 180 CreateStatement: "CREATE DATABASE " + dbName, 181 ExtraStatements: initStatements, 182 }) 183 if err != nil { 184 return nil, errors.Annotate(err, "failed to create database").Err() 185 } 186 db, err := dbOp.Wait(ctx) 187 if err != nil { 188 return nil, errors.Annotate(err, "failed to create database").Err() 189 } 190 191 return &TempDB{ 192 Name: db.Name, 193 opts: opts, 194 }, nil 195 } 196 197 func tokenSource(ctx context.Context) (oauth2.TokenSource, error) { 198 opts := chromeinfra.DefaultAuthOptions() 199 opts.Scopes = spandb.DefaultAuthScopes() 200 a := auth.NewAuthenticator(ctx, auth.SilentLogin, opts) 201 if err := a.CheckLoginRequired(); err != nil { 202 return nil, errors.Annotate(err, "please login with `luci-auth login -scopes %q`", strings.Join(opts.Scopes, " ")).Err() 203 } 204 return a.TokenSource() 205 } 206 207 // SanitizeDBName tranforms name to a valid one. 208 // If name is already valid, returns it without changes. 209 func SanitizeDBName(name string) string { 210 name = strings.ToLower(name) 211 name = dbNameAlphabetInversedRe.ReplaceAllLiteralString(name, "_") 212 const maxLen = 30 213 if len(name) > maxLen { 214 name = name[:maxLen] 215 } 216 name = strings.TrimRight(name, "_") 217 return name 218 } 219 220 // Emulator is for starting and stopping a Cloud Spanner Emulator process. 221 // TODO(crbug.com/1066993): Make Emulator an interfact with two implementations (*nativeEmulator and *dockerEmulator). 222 type Emulator struct { 223 // hostport is the address at which emulator process is running. 224 hostport string 225 // cmd is the command corresponding to in-process emulator, set if running. 226 cmd *exec.Cmd 227 // cancel cancels the command context to kill the emulator process. 228 cancel func() 229 // cfgDir is the path to the temporary dircetory holding the gcloud config for emulator. 230 cfgDir string 231 // containerName is used to explicitly stop the docker container. 232 containerName string 233 } 234 235 func (e *Emulator) opts() []option.ClientOption { 236 return []option.ClientOption{ 237 option.WithEndpoint(e.hostport), 238 option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), 239 option.WithoutAuthentication(), 240 } 241 } 242 243 // createSpannerEmulatorConfig creates a temporary CLOUDSDK_CONFIG, then creates 244 // a temporary gcloud config for the emulator. 245 // 246 // It returns the directory with the configuration. The caller is responsible 247 // for deleting it when the configuration is no longer needed. 248 func (e *Emulator) createSpannerEmulatorConfig() (string, error) { 249 // TODO(crbug.com/1066993): use os.MkdirTemp after go version is bumped to 1.16. 250 tdir, err := ioutil.TempDir("", "gcloud_config") 251 if err != nil { 252 return "", err 253 } 254 cmd := exec.Command( 255 "/bin/sh", 256 "-c", 257 fmt.Sprintf("gcloud config configurations create %s;"+ 258 " gcloud config set auth/disable_credentials true;"+ 259 " gcloud config set project chops-spanner-testing;"+ 260 " gcloud config set api_endpoint_overrides/spanner http://localhost:9020/;", emulatorCfg)) 261 cmd.Env = append(os.Environ(), fmt.Sprintf("CLOUDSDK_CONFIG=%s", tdir)) 262 return tdir, cmd.Run() 263 } 264 265 // NewInstance creates a temporary instance using Cloud Spanner Emulator. 266 func (e *Emulator) NewInstance(ctx context.Context, projectName string) (string, error) { 267 ctx, cancel := context.WithTimeout(ctx, 30*time.Second) 268 defer cancel() 269 270 if projectName == "" { 271 // TODO(crbug.com/1066993): add the default to chromeinfra. 272 projectName = "projects/chops-spanner-testing" 273 } 274 opts := append(e.opts(), option.WithGRPCDialOption(grpc.WithBlock())) 275 276 client, err := spanins.NewInstanceAdminClient(ctx, opts...) 277 if err != nil { 278 return "", err 279 } 280 defer client.Close() 281 282 insID := "testing" 283 insOp, err := client.CreateInstance(ctx, &inspb.CreateInstanceRequest{ 284 Parent: projectName, 285 InstanceId: insID, 286 Instance: &inspb.Instance{ 287 Config: emulatorCfg, 288 DisplayName: insID, 289 NodeCount: 1, 290 }, 291 }) 292 if err != nil { 293 return "", errors.Annotate(err, "failed to create instance").Err() 294 } 295 296 switch ins, err := insOp.Wait(ctx); { 297 case err != nil: 298 return "", errors.Annotate(err, "failed to get instance state").Err() 299 case ins.State != inspb.Instance_READY: 300 return "", fmt.Errorf("instance is not ready, got state %v", ins.State) 301 default: 302 return ins.Name, nil 303 } 304 } 305 306 var spannerClient *spanner.Client 307 308 // CleanupDatabase deletes all data from all tables. 309 type CleanupDatabase func(ctx context.Context, client *spanner.Client) error 310 311 // FindInitScript returns path to a .sql file which contains the schema of the 312 // tested Spanner database. 313 type FindInitScript func() (string, error) 314 315 // runIntegrationTests returns true if integration tests should run. 316 func runIntegrationTests() bool { 317 return os.Getenv(IntegrationTestEnvVar) == "1" 318 } 319 320 // SpannerTestContext returns a context for testing code that talks to Spanner. 321 // Skips the test if integration tests are not enabled. 322 // 323 // Tests that use Spanner MUST NOT call t.Parallel(). 324 func SpannerTestContext(tb testing.TB, cleanupDatabase CleanupDatabase) context.Context { 325 switch { 326 case !runIntegrationTests(): 327 tb.Skipf("env var %s=1 is missing", IntegrationTestEnvVar) 328 case spannerClient == nil: 329 tb.Fatalf("spanner client is not initialized; forgot to call SpannerTestMain?") 330 } 331 332 ctx := context.Background() 333 err := cleanupDatabase(ctx, spannerClient) 334 if err != nil { 335 tb.Fatal(err) 336 } 337 338 ctx = span.UseClient(ctx, spannerClient) 339 340 return ctx 341 } 342 343 // SpannerTestMain is a test main function for packages that have tests that 344 // talk to spanner. It creates/destroys a temporary spanner database 345 // before/after running tests. 346 // 347 // This function never returns. Instead it calls os.Exit with the value returned 348 // by m.Run(). 349 func SpannerTestMain(m *testing.M, findInitScript FindInitScript) { 350 exitCode, err := spannerTestMain(m, findInitScript) 351 if err != nil { 352 fmt.Fprintln(os.Stderr, err) 353 os.Exit(1) 354 } 355 356 os.Exit(exitCode) 357 } 358 359 func spannerTestMain(m *testing.M, findInitScript FindInitScript) (exitCode int, err error) { 360 testing.Init() 361 362 if runIntegrationTests() { 363 ctx := context.Background() 364 start := clock.Now(ctx) 365 var instanceName string 366 var emulator *Emulator 367 368 var err error 369 // Start Cloud Spanner Emulator. 370 if emulator, err = StartEmulator(ctx); err != nil { 371 return 0, err 372 } 373 defer func() { 374 switch stopErr := emulator.Stop(); { 375 case stopErr == nil: 376 377 case err == nil: 378 err = stopErr 379 380 default: 381 fmt.Fprintf(os.Stderr, "failed to stop the emulator: %s\n", stopErr) 382 } 383 }() 384 385 // Create a Spanner instance. 386 if instanceName, err = emulator.NewInstance(ctx, ""); err != nil { 387 return 0, err 388 } 389 fmt.Printf("started cloud emulator instance and created a temporary Spanner instance %s in %s\n", instanceName, time.Since(start)) 390 start = clock.Now(ctx) 391 392 // Find init_db.sql 393 initScriptPath, err := findInitScript() 394 if err != nil { 395 return 0, err 396 } 397 398 // Create a Spanner database. 399 db, err := NewTempDB(ctx, TempDBConfig{InitScriptPath: initScriptPath, InstanceName: instanceName}, emulator) 400 if err != nil { 401 return 0, errors.Annotate(err, "failed to create a temporary Spanner database").Err() 402 } 403 fmt.Printf("created a temporary Spanner database %s in %s\n", db.Name, time.Since(start)) 404 405 defer func() { 406 switch dropErr := db.Drop(ctx); { 407 case dropErr == nil: 408 409 case err == nil: 410 err = dropErr 411 412 default: 413 fmt.Fprintf(os.Stderr, "failed to drop the database: %s\n", dropErr) 414 } 415 }() 416 417 // Create a global Spanner client. 418 spannerClient, err = db.Client(ctx) 419 if err != nil { 420 return 0, err 421 } 422 } 423 424 return m.Run(), nil 425 }