go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/server/gaeemulation/module.go (about)

     1  // Copyright 2020 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 gaeemulation provides a server module that adds implementation of
    16  // some https://godoc.org/go.chromium.org/luci/gae APIs to the global server context.
    17  //
    18  // The implementation is based on regular Cloud APIs and works from anywhere
    19  // (not necessarily from Appengine).
    20  //
    21  // Usage:
    22  //
    23  //	func main() {
    24  //	  modules := []module.Module{
    25  //	    gaeemulation.NewModuleFromFlags(),
    26  //	  }
    27  //	  server.Main(nil, modules, func(srv *server.Server) error {
    28  //	    srv.Routes.GET("/", ..., func(c *router.Context) {
    29  //	      ent := Entity{ID: "..."}
    30  //	      err := datastore.Get(c.Context, &ent)
    31  //	      ...
    32  //	    })
    33  //	    return nil
    34  //	  })
    35  //	}
    36  //
    37  // TODO(vadimsh): Currently provides datastore API only.
    38  package gaeemulation
    39  
    40  import (
    41  	"context"
    42  	"flag"
    43  	"os"
    44  
    45  	"cloud.google.com/go/datastore"
    46  	"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
    47  	"google.golang.org/api/option"
    48  	"google.golang.org/grpc"
    49  
    50  	"go.chromium.org/luci/appengine/gaesecrets"
    51  	"go.chromium.org/luci/common/errors"
    52  	"go.chromium.org/luci/common/logging"
    53  	"go.chromium.org/luci/gae/filter/dscache"
    54  	"go.chromium.org/luci/gae/filter/txndefer"
    55  	"go.chromium.org/luci/gae/impl/cloud"
    56  	"go.chromium.org/luci/grpc/grpcmon"
    57  
    58  	"go.chromium.org/luci/server/auth"
    59  	"go.chromium.org/luci/server/module"
    60  	"go.chromium.org/luci/server/redisconn"
    61  	"go.chromium.org/luci/server/secrets"
    62  )
    63  
    64  // ModuleName can be used to refer to this module when declaring dependencies.
    65  var ModuleName = module.RegisterName("go.chromium.org/luci/server/gaeemulation")
    66  
    67  // ModuleOptions contain configuration of the GAE Emulation server module
    68  type ModuleOptions struct {
    69  	DSCache                  string // currently either "disable" (default) or "redis"
    70  	RandomSecretsInDatastore bool   // true to replace the random secrets store with the GAEv1-one
    71  	DSConnectionPoolSize     int    // passed to WithGRPCConnectionPool, if > 0.
    72  }
    73  
    74  // Register registers the command line flags.
    75  func (o *ModuleOptions) Register(f *flag.FlagSet) {
    76  	f.StringVar(
    77  		&o.DSCache,
    78  		"ds-cache",
    79  		o.DSCache,
    80  		`What datastore caching layer to use ("disable" or "redis").`,
    81  	)
    82  	f.BoolVar(
    83  		&o.RandomSecretsInDatastore,
    84  		"random-secrets-in-datastore",
    85  		o.RandomSecretsInDatastore,
    86  		`If set, use datastore to store random secrets instead of deriving them from a -root-secret. `+
    87  			`Can be used for compatibility with older GAE services. Do not use in new services.`,
    88  	)
    89  	f.IntVar(
    90  		&o.DSConnectionPoolSize,
    91  		"ds-connection-pool-size",
    92  		o.DSConnectionPoolSize,
    93  		"If set, DS client is constructed with WithGRPCConnectionPool() and this value. ",
    94  	)
    95  }
    96  
    97  // NewModule returns a server module that adds implementation of
    98  // some https://godoc.org/go.chromium.org/luci/gae APIs to the global server
    99  // context.
   100  func NewModule(opts *ModuleOptions) module.Module {
   101  	if opts == nil {
   102  		opts = &ModuleOptions{}
   103  	}
   104  	return &gaeModule{opts: opts}
   105  }
   106  
   107  // NewModuleFromFlags is a variant of NewModule that initializes options through
   108  // command line flags.
   109  //
   110  // Calling this function registers flags in flag.CommandLine. They are usually
   111  // parsed in server.Main(...).
   112  func NewModuleFromFlags() module.Module {
   113  	opts := &ModuleOptions{}
   114  	opts.Register(flag.CommandLine)
   115  	return NewModule(opts)
   116  }
   117  
   118  // gaeModule implements module.Module.
   119  type gaeModule struct {
   120  	opts *ModuleOptions
   121  }
   122  
   123  // Name is part of module.Module interface.
   124  func (*gaeModule) Name() module.Name {
   125  	return ModuleName
   126  }
   127  
   128  // Dependencies is part of module.Module interface.
   129  func (*gaeModule) Dependencies() []module.Dependency {
   130  	return []module.Dependency{
   131  		module.OptionalDependency(redisconn.ModuleName), // for dscache, if enabled
   132  		module.OptionalDependency(secrets.ModuleName),   // to install DS random secrets backend
   133  	}
   134  }
   135  
   136  // Initialize is part of module.Module interface.
   137  func (m *gaeModule) Initialize(ctx context.Context, host module.Host, opts module.HostOptions) (context.Context, error) {
   138  	// Use zstd in luci/server's dscache. It will be default at some point.
   139  	dscache.UseZstd = true
   140  
   141  	var cacheImpl dscache.Cache
   142  	switch m.opts.DSCache {
   143  	case "", "disable":
   144  		// don't use caching
   145  	case "redis":
   146  		pool := redisconn.GetPool(ctx)
   147  		if pool == nil {
   148  			return nil, errors.Reason("can't use `-ds-cache redis`: redisconn module is not configured").Err()
   149  		}
   150  		cacheImpl = redisCache{pool: pool}
   151  	default:
   152  		return nil, errors.Reason("unsupported -ds-cache %q", m.opts.DSCache).Err()
   153  	}
   154  
   155  	if m.opts.RandomSecretsInDatastore {
   156  		store, _ := secrets.CurrentStore(ctx).(*secrets.SecretManagerStore)
   157  		if store == nil {
   158  			return nil, errors.Reason("-random-secrets-in-datastore requires module %q", secrets.ModuleName).Err()
   159  		}
   160  		store.SetRandomSecretsStore(gaesecrets.New(nil))
   161  	}
   162  
   163  	if s := m.opts.DSConnectionPoolSize; s < 0 {
   164  		return nil, errors.Reason("-ds-connection-pool-size: must be >= 0, but %d", s).Err()
   165  	}
   166  
   167  	var client *datastore.Client
   168  	if opts.CloudProject != "" {
   169  		var err error
   170  		if client, err = m.initDSClient(ctx, host, opts.CloudProject, m.opts.DSConnectionPoolSize); err != nil {
   171  			return nil, err
   172  		}
   173  	}
   174  	cfg := &cloud.ConfigLite{
   175  		IsDev:     !opts.Prod,
   176  		ProjectID: opts.CloudProject,
   177  		DS:        client, // if nil, datastore calls will fail gracefully(-ish)
   178  	}
   179  
   180  	ctx = cfg.Use(ctx)
   181  	if cacheImpl != nil {
   182  		ctx = dscache.FilterRDS(ctx, cacheImpl)
   183  	}
   184  	return txndefer.FilterRDS(ctx), nil
   185  }
   186  
   187  // initDSClient sets up Cloud Datastore client that uses AsSelf server token
   188  // source.
   189  func (m *gaeModule) initDSClient(ctx context.Context, host module.Host, cloudProject string, poolSize int) (*datastore.Client, error) {
   190  	logging.Infof(ctx, "Setting up datastore client for project %q", cloudProject)
   191  
   192  	// Enable auth only when using the real datastore.
   193  	var clientOpts []option.ClientOption
   194  	if addr := os.Getenv("DATASTORE_EMULATOR_HOST"); addr == "" {
   195  		ts, err := auth.GetTokenSource(ctx, auth.AsSelf, auth.WithScopes(auth.CloudOAuthScopes...))
   196  		if err != nil {
   197  			return nil, errors.Annotate(err, "failed to initialize the token source").Err()
   198  		}
   199  		clientOpts = []option.ClientOption{
   200  			option.WithTokenSource(ts),
   201  			option.WithGRPCDialOption(grpc.WithStatsHandler(&grpcmon.ClientRPCStatsMonitor{})),
   202  			option.WithGRPCDialOption(grpc.WithUnaryInterceptor(otelgrpc.UnaryClientInterceptor())),
   203  			option.WithGRPCDialOption(grpc.WithStreamInterceptor(otelgrpc.StreamClientInterceptor())),
   204  		}
   205  	}
   206  
   207  	if poolSize > 0 {
   208  		clientOpts = append(clientOpts, option.WithGRPCConnectionPool(poolSize))
   209  	}
   210  	client, err := datastore.NewClient(ctx, cloudProject, clientOpts...)
   211  	if err != nil {
   212  		return nil, errors.Annotate(err, "failed to instantiate the datastore client").Err()
   213  	}
   214  
   215  	host.RegisterCleanup(func(ctx context.Context) {
   216  		if err := client.Close(); err != nil {
   217  			logging.Warningf(ctx, "Failed to close the datastore client - %s", err)
   218  		}
   219  	})
   220  
   221  	// TODO(vadimsh): "Ping" the datastore to verify the credentials are correct?
   222  
   223  	return client, nil
   224  }