go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/client/casclient/client.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 casclient provides remote-apis-sdks client with luci integration.
    16  package casclient
    17  
    18  import (
    19  	"context"
    20  	"fmt"
    21  	"net"
    22  	"runtime"
    23  	"strings"
    24  	"time"
    25  
    26  	"github.com/bazelbuild/remote-apis-sdks/go/pkg/cas"
    27  	"github.com/bazelbuild/remote-apis-sdks/go/pkg/client"
    28  	"github.com/bazelbuild/remote-apis-sdks/go/pkg/contextmd"
    29  	"google.golang.org/grpc/credentials"
    30  
    31  	"go.chromium.org/luci/auth"
    32  	"go.chromium.org/luci/common/errors"
    33  	"go.chromium.org/luci/common/logging"
    34  	"go.chromium.org/luci/hardcoded/chromeinfra"
    35  )
    36  
    37  // AddrProd is the PROD CAS service address.
    38  const AddrProd = "remotebuildexecution.googleapis.com:443"
    39  
    40  // New returns luci auth configured Client for RBE-CAS.
    41  func New(ctx context.Context, addr string, instance string, opts auth.Options, readOnly bool) (*cas.Client, error) {
    42  	var dialParams client.DialParams
    43  	useLocal, err := isLocalAddr(addr)
    44  	if err != nil {
    45  		return nil, errors.Annotate(err, "invalid addr").Err()
    46  	}
    47  	if useLocal {
    48  		// Connect to local fake CAS server.
    49  		// See also go.chromium.org/luci/tools/cmd/fakecas
    50  		if instance != "" {
    51  			return nil, errors.Reason("do not specify instance with local address").Err()
    52  		}
    53  		instance = "instance"
    54  		dialParams = client.DialParams{
    55  			Service:    addr,
    56  			NoSecurity: true,
    57  		}
    58  	} else {
    59  		creds, err := perRPCCreds(ctx, instance, opts, readOnly)
    60  		if err != nil {
    61  			return nil, err
    62  		}
    63  
    64  		dialParams = client.DialParams{
    65  			Service:              addr,
    66  			UseExternalAuthToken: true,
    67  			ExternalPerRPCCreds:  &client.PerRPCCreds{Creds: creds},
    68  		}
    69  	}
    70  
    71  	conn, _, err := client.Dial(ctx, dialParams.Service, dialParams)
    72  	if err != nil {
    73  		return nil, errors.Annotate(err, "failed to dial RBE").Err()
    74  	}
    75  
    76  	cl, err := cas.NewClientWithConfig(ctx, conn, instance, DefaultConfig())
    77  	if err != nil {
    78  		return nil, errors.Annotate(err, "failed to create client").Err()
    79  	}
    80  	return cl, nil
    81  }
    82  
    83  // DefaultConfig returns default CAS client configuration.
    84  func DefaultConfig() cas.ClientConfig {
    85  	cfg := cas.DefaultClientConfig()
    86  	cfg.CompressedBytestreamThreshold = 0 // compress always
    87  
    88  	// Do not read file less than 10MiB twice.
    89  	cfg.SmallFileThreshold = 10 * 1024 * 1024
    90  
    91  	return cfg
    92  }
    93  
    94  func perRPCCreds(ctx context.Context, instance string, opts auth.Options, readOnly bool) (credentials.PerRPCCredentials, error) {
    95  	project := strings.Split(instance, "/")[1]
    96  	var role string
    97  	if readOnly {
    98  		role = "cas-read-only"
    99  	} else {
   100  		role = "cas-read-write"
   101  	}
   102  
   103  	// Construct auth.Options.
   104  	opts.ActAsServiceAccount = fmt.Sprintf("%s@%s.iam.gserviceaccount.com", role, project)
   105  	opts.ActViaLUCIRealm = fmt.Sprintf("@internal:%s/%s", project, role)
   106  	opts.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"}
   107  
   108  	if strings.HasSuffix(project, "-dev") || strings.HasSuffix(project, "-staging") {
   109  		// use dev token server for dev/staging projects.
   110  		opts.TokenServerHost = chromeinfra.TokenServerDevHost
   111  	}
   112  
   113  	creds, err := auth.NewAuthenticator(ctx, auth.SilentLogin, opts).PerRPCCredentials()
   114  	if err != nil {
   115  		return nil, errors.Annotate(err, "failed to get PerRPCCredentials").Err()
   116  	}
   117  	return creds, nil
   118  }
   119  
   120  // NewLegacy returns luci auth configured legacy Client for RBE.
   121  // In general, NewClient is preferred.
   122  // TODO(crbug.com/1225524): remove this.
   123  func NewLegacy(ctx context.Context, addr string, instance string, opts auth.Options, readOnly bool) (*client.Client, error) {
   124  	useLocal, err := isLocalAddr(addr)
   125  	if err != nil {
   126  		return nil, errors.Annotate(err, "invalid addr").Err()
   127  	}
   128  	if useLocal {
   129  		// Connect to local fake CAS server.
   130  		// See also go.chromium.org/luci/tools/cmd/fakecas
   131  		if instance != "" {
   132  			logging.Warningf(ctx, "instance %q is given, but will be ignored.", instance)
   133  		}
   134  		dialParams := client.DialParams{
   135  			Service:    addr,
   136  			NoSecurity: true,
   137  		}
   138  		cl, err := client.NewClient(ctx, "instance", dialParams)
   139  		if err != nil {
   140  			return nil, errors.Annotate(err, "failed to create client").Err()
   141  		}
   142  		return cl, nil
   143  	}
   144  
   145  	creds, err := perRPCCreds(ctx, instance, opts, readOnly)
   146  	if err != nil {
   147  		return nil, err
   148  	}
   149  	dialParams := client.DialParams{
   150  		Service:              "remotebuildexecution.googleapis.com:443",
   151  		UseExternalAuthToken: true,
   152  		ExternalPerRPCCreds:  &client.PerRPCCreds{Creds: creds},
   153  	}
   154  
   155  	cl, err := client.NewClient(ctx, instance, dialParams, Options()...)
   156  	if err != nil {
   157  		logging.Errorf(ctx, "failed to create casclient: %+v", err)
   158  		return nil, errors.Annotate(err, "failed to create client").Err()
   159  	}
   160  	return cl, nil
   161  }
   162  
   163  // Options returns CAS client options.
   164  func Options() []client.Opt {
   165  	casConcurrency := runtime.NumCPU() * 2
   166  	if runtime.GOOS == "windows" {
   167  		// This is for better file write performance on Windows (http://b/171672371#comment6).
   168  		casConcurrency = runtime.NumCPU()
   169  	}
   170  
   171  	rpcTimeouts := make(client.RPCTimeouts)
   172  	for k, v := range client.DefaultRPCTimeouts {
   173  		rpcTimeouts[k] = v
   174  	}
   175  
   176  	// Extend the timeout for write operations beyond the default, as writes can
   177  	// sometimes be quite slow. This timeout only applies to writing a single
   178  	// file chunk, so there isn't a risk of setting a timeout that's to low for
   179  	// large files.
   180  	rpcTimeouts["Write"] = 2 * time.Minute
   181  
   182  	// There's suspicion GetCapabilities sometimes takes longer than default
   183  	// 5 sec because it is the first call ever (and it needs to open the
   184  	// connection and refresh auth tokens). Give it more time.
   185  	rpcTimeouts["GetCapabilities"] = 30 * time.Second
   186  
   187  	return []client.Opt{
   188  		client.CASConcurrency(casConcurrency),
   189  		client.UtilizeLocality(true),
   190  		&client.TreeSymlinkOpts{
   191  			// Symlinks will be uploaded as-is...
   192  			Preserved: true,
   193  			// ... and the target file included in the CAS archive...
   194  			FollowsTarget: true,
   195  			// ... unless the target file is outside the root directory, in
   196  			// which case the target file will be uploaded instead of preserving
   197  			// the symlink.
   198  			MaterializeOutsideExecRoot: true,
   199  		},
   200  		rpcTimeouts,
   201  		// Set restricted permission for written files.
   202  		client.DirMode(0700),
   203  		client.ExecutableMode(0700),
   204  		client.RegularMode(0600),
   205  		client.CompressedBytestreamThreshold(0),
   206  	}
   207  }
   208  
   209  // ContextWithMetadata attaches RBE related metadata with tool name to the
   210  // given context.
   211  func ContextWithMetadata(ctx context.Context, toolName string) (context.Context, error) {
   212  	ctx, err := contextmd.WithMetadata(ctx, &contextmd.Metadata{
   213  		ToolName: toolName,
   214  	})
   215  	if err != nil {
   216  		return nil, errors.Annotate(err, "failed to attach metadata").Err()
   217  	}
   218  
   219  	m, err := contextmd.ExtractMetadata(ctx)
   220  	if err != nil {
   221  		return nil, errors.Annotate(err, "failed to extract metadata").Err()
   222  	}
   223  
   224  	logging.Infof(ctx, "context metadata: %#+v", *m)
   225  
   226  	return ctx, nil
   227  }
   228  
   229  func isLocalAddr(addr string) (bool, error) {
   230  	tcpaddr, err := net.ResolveTCPAddr("tcp", addr)
   231  	if err != nil {
   232  		return false, err
   233  	}
   234  	if tcpaddr.IP == nil {
   235  		return true, nil
   236  	}
   237  	return tcpaddr.IP.IsLoopback(), nil
   238  }