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  }