blob: 03e66bdb2f532f8d3413b6c373d604554c7e4b9a [file]
// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Package reapi provides remote execution API.
package reapi
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"flag"
"fmt"
"io"
"os"
"path"
"slices"
"strings"
"sync"
"time"
"github.com/klauspost/compress/zstd"
oteltrace "go.opentelemetry.io/otel/trace"
"google.golang.org/api/option"
gtransport "google.golang.org/api/transport/grpc"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/encoding/gzip"
grpcexpotel "google.golang.org/grpc/experimental/opentelemetry"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/stats"
grpcotel "google.golang.org/grpc/stats/opentelemetry"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"go.chromium.org/build/hashigo/digest"
rpb "go.chromium.org/build/remote-apis/build/bazel/remote/execution/v2"
semverpb "go.chromium.org/build/remote-apis/build/bazel/semver"
"go.chromium.org/build/siso/auth/cred"
"go.chromium.org/build/siso/o11y/clog"
"go.chromium.org/build/siso/o11y/iometrics"
"go.chromium.org/build/siso/o11y/monitoring"
"go.chromium.org/build/siso/o11y/trace"
"go.chromium.org/build/siso/reapi/firstbyte"
"go.chromium.org/build/siso/reapi/retry"
"go.chromium.org/build/siso/version"
_ "embed"
)
// Option contains options of remote exec API.
type Option struct {
// Prefix is used to distinguish client.
// If empty, "reapi" is used as prefix.
// Usually it uses Address as endpoint,
// and optionally CASAddress as endpoint for cas operation,
// if it is explicitly specified by the flag.
// If Prefix contains "cas", CASAddress will not be used.
Prefix string
Address string
CASAddress string
Instance string
// Insecure mode for RE API.
Insecure bool
// mTLS
TLSClientAuthCert string
TLSClientAuthKey string
TLSCACert string
// ExecutionPriority sets the priority value to use when sending actions to the REAPI backend.
//
// This can be used, e.g., to prioritize interactive builds from developers over builds from CI.
ExecutionPriority int
// use compressed blobs if server supports compressed blobs and size is bigger than this.
CompressedBlob int64
// use compressed blobs in BatchUpdateBlobs if server supports it and size is bigger than this.
// 0 means disabled.
BatchCompressedBlob int64
// compressor for ByteStream Read/Write APIs.
compressor rpb.Compressor_Value
// compressor for BatchUpdateBlobs API.
compressorForBatchUpdateBlobs rpb.Compressor_Value
// Threshold that decides whether to use ByteStream API (compression-aware)
// instead of BatchReadBlobs if blob size is bigger than this.
ByteStreamReadThreshold int64
// Enables GRPC compression. If enabled, blob-level compression will be
// forcibly disabled.
EnableGRPCCompression bool
// Keep Execute stream open as lone as possible.
// If false, siso closes Execute stream every 1 minute and retries
// with WaitExecution to mitigate grpc/network issue.
KeepExecStream bool
ConnPool int
KeepAliveParams keepalive.ClientParameters
// StatsHandler, if non-nil, is wired into every gRPC ClientConn via
// grpc.WithStatsHandler. Used by the resource package to sample TTFB
// for the adaptive flush gate.
StatsHandler stats.Handler
// RE API version to use by siso, in format of v<major>.<minor>
// e.g. "v2.0".
// default to use high api version advertised by the server
// capabilities.
REAPIVersion string
// DigestFunction selects the content digest function by name
// (e.g. "sha256", "sha1", "blake3"). Empty means "sha256".
DigestFunction string
// UploadConcurrency caps in-flight upload RPCs per UploadAll call.
// Zero (default) means serial; set to max(32, GOMAXPROCS*4) or similar
// for callers that benefit from parallel upload (e.g. `siso isolate`).
UploadConcurrency int
// MaxRetries is the maximum number of retries for retriable errors.
MaxRetries int
// TracerProvider, when non-nil, enables grpc-native OpenTelemetry tracing:
// each RPC propagates its context via grpc-trace-bin for server-side
// recording (e.g. Dapper). Nil leaves the connection untraced.
TracerProvider oteltrace.TracerProvider
// TraceCookie, when non-empty, is attached as the `cookie` gRPC metadata
// header on every RPC to this backend (to force server-side trace
// sampling, e.g. Dapper). Scoped to this connection so it never reaches a
// non-RBE gRPC service.
TraceCookie string
}
// Envs returns environment flags for reapi.
func Envs(t string) map[string]string {
envs := map[string]string{}
if v, ok := os.LookupEnv(fmt.Sprintf("SISO_%s_INSTANCE", t)); ok {
envs["SISO_REAPI_INSTANCE"] = v
}
if v, ok := os.LookupEnv(fmt.Sprintf("SISO_%s_ADDRESS", t)); ok {
envs["SISO_REAPI_ADDRESS"] = v
}
if v, ok := os.LookupEnv(fmt.Sprintf("SISO_%s_CAS_ADDRESS", t)); ok {
envs["SISO_REAPI_CAS_ADDRESS"] = v
}
return envs
}
// RegisterFlags registers flags on the option.
// Note: if Prefix contains "cas", it would only be used for cas,
// so not register additional cas address flags.
func (o *Option) RegisterFlags(fs *flag.FlagSet, envs map[string]string) {
var purpose string
if o.Prefix == "" {
o.Prefix = "reapi"
} else {
purpose = fmt.Sprintf(" (for %s)", o.Prefix)
}
addr := envs["SISO_REAPI_ADDRESS"]
if addr == "" {
addr = "remotebuildexecution.googleapis.com:443"
}
fs.StringVar(&o.Address, o.Prefix+"_address", addr, "reapi address"+purpose)
if !strings.Contains(o.Prefix, "cas") {
casAddr := envs["SISO_REAPI_CAS_ADDRESS"]
fs.StringVar(&o.CASAddress, o.Prefix+"_cas_address", casAddr, "reapi cas address"+purpose+" (if empty, share conn with "+o.Prefix+"_address)")
}
instance, ok := envs["SISO_REAPI_INSTANCE"]
if !ok {
instance = "default_instance"
}
usage := "reapi instance name" + purpose
if o.Prefix == "reapi" {
usage += ". (for Google RBE: if instance is fully qualified (starts with projects/), project ID is inferred from it. Otherwise, project ID from -project (or $SISO_PROJECT) is used to construct the instance name. If both are provided, -project is used for other cloud services)"
}
fs.StringVar(&o.Instance, o.Prefix+"_instance", instance, usage)
fs.BoolVar(&o.Insecure, o.Prefix+"_insecure", os.Getenv("RBE_service_no_security") == "true", "reapi insecure mode. default can be set by $RBE_service_no_security")
fs.StringVar(&o.TLSClientAuthCert, o.Prefix+"_tls_client_auth_cert", os.Getenv("RBE_tls_client_auth_cert"), "Certificate to use when using mTLS to connect to the RE api service. default can be set by $RBE_tls_client_auth_cert")
fs.StringVar(&o.TLSClientAuthKey, o.Prefix+"_tls_client_auth_key", os.Getenv("RBE_tls_client_auth_key"), "Key to use when using mTLS to connect to the RE api service. default can be set by $RBE_tls_client_auth_key")
fs.StringVar(&o.TLSCACert, o.Prefix+"_tls_ca_cert", os.Getenv("RBE_tls_ca_cert"), "Load TLS CA certificates from this file to connect to the RE api service. default can be set by $RBE_tls_ca_cert")
fs.Int64Var(&o.CompressedBlob, o.Prefix+"_compress_blob", 1024, "use compressed blobs if server supports compressed blobs and size is bigger than this. specify 0 to disable blob-level compression."+purpose)
fs.Int64Var(&o.BatchCompressedBlob, o.Prefix+"_batch_compress_blob", 0, "use compressed blobs in BatchUpdateBlobs if server supports it and size is bigger than this. specify 0 to disable."+purpose)
fs.Int64Var(&o.ByteStreamReadThreshold, o.Prefix+"_byte_stream_read_threshold", 2*1024*1024, "if blob size >= threshold, use ByteStream API (compression-aware)"+purpose)
fs.BoolVar(&o.EnableGRPCCompression, o.Prefix+"_enable_grpc_compression", false, "enable grpc compression. if enabled, blob-level compression will be forcibly disabled."+purpose)
fs.BoolVar(&o.KeepExecStream, o.Prefix+"_keep_exec_stream", false, "keep Execute stream open as long as possible")
fs.IntVar(&o.ConnPool, o.Prefix+"_grpc_conn_pool", 25, "grpc connection pool")
// https://grpc.io/docs/guides/keepalive/#keepalive-configuration-specification
// b/286237547 - RBE suggests 30s
fs.DurationVar(&o.KeepAliveParams.Time, o.Prefix+"_grpc_keepalive_time", 30*time.Second, "grpc keepalive time"+purpose)
fs.DurationVar(&o.KeepAliveParams.Timeout, o.Prefix+"_grpc_keepalive_timeout", 20*time.Second, "grpc keepalive timeout"+purpose)
fs.BoolVar(&o.KeepAliveParams.PermitWithoutStream, o.Prefix+"_grpc_keepalive_permit_without_stream", false, "grpc keepalive permit without stream"+purpose)
fs.StringVar(&o.REAPIVersion, o.Prefix+"_version_to_use", "", "specify re api version to use, in format of v<major>.<minor>. e.g. v2.0")
fs.StringVar(&o.DigestFunction, o.Prefix+"_digest_function", "sha256", "content digest function: sha256, sha1, gitsha1, blake3, md5, sha384, sha512, murmur3, vso, or sha256tree"+purpose)
fs.StringVar(&o.TraceCookie, o.Prefix+"_trace_cookie", "", "if set, sent as the `cookie` gRPC metadata header on every RPC to this backend, to force server-side trace sampling (e.g. Dapper)"+purpose)
// Flags only supported for "execution".
if o.Prefix == "reapi" {
fs.IntVar(&o.ExecutionPriority, o.Prefix+"_priority", 0, "reapi priority for action executions"+purpose+". The semantics and supported values depend on the backend")
}
fs.IntVar(&o.MaxRetries, o.Prefix+"_max_retries", 10, "max retries for remote execution")
}
func isGoogleRBE(address string) bool {
return strings.HasSuffix(address, "remotebuildexecution.googleapis.com:443") || strings.HasSuffix(address, "remotebuildexecution.sandbox.googleapis.com:443")
}
func (o *Option) String() string {
if o == nil || o.Address == "" {
return "no reapi backend"
}
addr := fmt.Sprintf("reapi %q", o.Address)
if isGoogleRBE(o.Address) {
addr = "RBE"
switch {
case strings.HasSuffix(o.Address, "-remotebuildexecution.googleapis.com:443"):
addr = fmt.Sprintf("RBE(%s)", strings.TrimSuffix(o.Address, "-remotebuildexecution.googleapis.com:443"))
case strings.HasSuffix(o.Address, "-remotebuildexecution.sandbox.googleapis.com:443"):
addr = fmt.Sprintf("RBE(%s sandbox)", strings.TrimSuffix(o.Address, "-remotebuildexecution.sandbox.googleapis.com:443"))
}
}
return fmt.Sprintf("%s instance %q", addr, o.Instance)
}
// UpdateProjectID updates the Option for projID and returns cloud project ID to use.
// Just returns empty string if backend is not RBE.
func (o *Option) UpdateProjectID(projID string) string {
if !isGoogleRBE(o.Address) {
return ""
}
if projID != "" && !strings.HasPrefix(o.Instance, "projects/") {
o.Instance = path.Join("projects", projID, "instances", o.Instance)
}
if projID == "" && strings.HasPrefix(o.Instance, "projects/") {
projID = strings.Split(o.Instance, "/")[1]
}
if projID == "" && !strings.HasPrefix(o.Instance, "projects/") {
// make Option invalid.
o.Instance = ""
}
return projID
}
// CheckValid checks whether option is valid or not.
func (o Option) CheckValid() error {
if o.Address == "" {
return errors.New("no reapi address")
}
if isGoogleRBE(o.Address) && o.Instance == "" {
return errors.New("no reapi instance for Google RBE")
}
return nil
}
// NeedCred returns whether credential is needed or not.
func (o Option) NeedCred() bool {
if o.Address == "" {
return false
}
if o.CheckValid() != nil {
return false
}
if o.Insecure {
return false
}
if o.TLSClientAuthCert != "" || o.TLSClientAuthKey != "" {
return false
}
return true
}
// ServiceURI returns service uri (capabilities) for PerRPCCredentials
// to check auth in cred.New
func (o Option) ServiceURI() string {
uri := o.Address
if uri == "" {
return ""
}
if !strings.HasPrefix(uri, "http") {
method := "http"
if strings.HasSuffix(uri, ":443") {
method = "https"
uri = strings.TrimSuffix(uri, ":443")
}
uri = fmt.Sprintf("%s://%s", method, uri)
}
uri += rpb.Capabilities_GetCapabilities_FullMethodName
return uri
}
type grpcClientConn interface {
grpc.ClientConnInterface
io.Closer
}
// Client is a remote exec API client.
type Client struct {
opt Option
digestFn digest.Function
cred cred.Cred
conn grpcClientConn
casConn grpcClientConn
mu sync.Mutex
capabilities *rpb.ServerCapabilities
apiVersion *semverpb.SemVer
knownDigests sync.Map // key:digest.Digest, value: *uploadOp or true
zstdDecoderPool *sync.Pool
m *iometrics.IOMetrics
}
// serviceConfig is gRPC service config for RE API.
// https://github.com/bazelbuild/bazel/blob/7.1.1/src/main/java/com/google/devtools/build/lib/remote/RemoteRetrier.java#L47
//
//go:embed service_config.json
var serviceConfig string
func DialOptions(keepAliveParams keepalive.ClientParameters) []grpc.DialOption {
// TODO(b/273639326): handle auth failures gracefully.
// https://github.com/grpc/grpc/blob/c16338581dba2b054bf52484266b79e6934bbc1c/doc/service_config.md
// https://github.com/grpc/proposal/blob/9f993b522267ed297fe54c9ee32cfc13699166c7/A6-client-retries.md
// timeout=300s may cause deadline exceeded to fetch large *.so file?
dopts := append([]grpc.DialOption(nil),
grpc.WithKeepaliveParams(keepAliveParams),
grpc.WithDisableServiceConfig(),
grpc.WithDefaultServiceConfig(serviceConfig),
// active streams and per-RPC stages into bytestream.* histograms
grpc.WithStatsHandler(monitoring.BytestreamStatsHandler()),
// closes firstbyte.Signal on first InPayload (used by hashfs's
// pre-first-byte watchdog)
grpc.WithStatsHandler(firstbyte.Handler),
)
return dopts
}
// tracingDialOption installs grpc-native OpenTelemetry tracing that propagates
// each RPC's context to the server via the grpc-trace-bin header using tp.
func tracingDialOption(tp oteltrace.TracerProvider) grpc.DialOption {
return grpcotel.DialOption(grpcotel.Options{
TraceOptions: grpcexpotel.TraceOptions{
TracerProvider: tp,
TextMapPropagator: grpcotel.GRPCTraceBinPropagator{},
},
})
}
// cookieInterceptors return unary and stream client interceptors that attach
// cookie as the `cookie` gRPC metadata header to every RPC on the connection.
// Scoping it here keeps the (debug) cookie off any non-RBE gRPC service.
func cookieInterceptors(cookie string) (grpc.UnaryClientInterceptor, grpc.StreamClientInterceptor) {
unary := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
return invoker(metadata.AppendToOutgoingContext(ctx, "cookie", cookie), method, req, reply, cc, opts...)
}
stream := func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
return streamer(metadata.AppendToOutgoingContext(ctx, "cookie", cookie), desc, cc, method, opts...)
}
return unary, stream
}
// DialError is reapi dial error.
type DialError struct {
Err error
}
func (e DialError) Error() string {
return fmt.Sprintf("%v", e.Err)
}
func (e DialError) Unwrap() error {
return e.Err
}
// New creates new remote exec API client.
func New(ctx context.Context, cred cred.Cred, opt Option) (*Client, error) {
defer trace.Begin(ctx, "reapi.New").End()
if opt.Address == "" {
return nil, DialError{Err: errors.New("no reapi address")}
}
if isGoogleRBE(opt.Address) && opt.Instance == "" {
return nil, DialError{Err: errors.New("no reapi instance")}
}
if opt.EnableGRPCCompression && (opt.CompressedBlob != 0 || opt.BatchCompressedBlob != 0) {
opt.CompressedBlob = 0
opt.BatchCompressedBlob = 0
clog.Warningf(ctx, "disabling blob compression because grpc compression is enabled")
}
clog.Infof(ctx, "address: %q instance: %q", opt.Address, opt.Instance)
conn, err := newConn(ctx, opt.Address, cred, opt)
if err != nil {
return nil, DialError{Err: err}
}
casConn := conn
if opt.CASAddress != "" {
clog.Infof(ctx, "cas address: %q", opt.CASAddress)
casConn, err = newConn(ctx, opt.CASAddress, cred, opt)
if err != nil {
conn.Close()
return nil, DialError{Err: err}
}
}
return NewFromConn(ctx, opt, cred, conn, casConn)
}
func newConn(ctx context.Context, addr string, cred cred.Cred, opt Option) (grpcClientConn, error) {
// Force the gRPC DNS resolver by prefixing "dns:///" when the caller
// did not supply a scheme. gtransport.DialPool rides the deprecated
// grpc.DialContext path whose default resolver is "passthrough",
// which treats the target as a single opaque address. Under
// passthrough, the round_robin LB policy in serviceConfig only ever
// gets one subchannel per ClientConn, even though DNS returns many
// GFE VIPs. "dns:///" forces the DNS resolver regardless of which
// dial API sits underneath, so round_robin can fan out one
// subchannel per resolved address.
endpoint := addr
if !strings.Contains(addr, "://") {
endpoint = "dns:///" + addr
}
copts := []option.ClientOption{
option.WithEndpoint(endpoint),
option.WithGRPCConnectionPool(opt.ConnPool),
}
if !isGoogleRBE(addr) {
// disable Google Application Default for non RBE backend.
// user should specify credential helper for the backend.
copts = append(copts, option.WithoutAuthentication())
}
dopts := DialOptions(opt.KeepAliveParams)
if opt.TracerProvider != nil {
// Disable the default google.golang.org/api telemetry handler so this
// is the sole writer of grpc-trace-bin.
copts = append(copts, option.WithTelemetryDisabled())
dopts = append(dopts, tracingDialOption(opt.TracerProvider))
}
if opt.TraceCookie != "" {
unary, stream := cookieInterceptors(opt.TraceCookie)
dopts = append(dopts, grpc.WithChainUnaryInterceptor(unary), grpc.WithChainStreamInterceptor(stream))
}
if opt.StatsHandler != nil {
dopts = append(dopts, grpc.WithStatsHandler(opt.StatsHandler))
}
if opt.EnableGRPCCompression {
dopts = append(dopts, grpc.WithDefaultCallOptions(grpc.UseCompressor(gzip.Name)))
}
var conn grpcClientConn
var err error
var tlsConfig *tls.Config
if opt.Insecure {
// Insecure mode for non-RBE remote execution API.
if strings.HasSuffix(addr, ".googleapis.com:443") {
return nil, errors.New("insecure mode is not supported for RBE")
}
clog.Warningf(ctx, "insecure mode")
copts = append(copts, option.WithoutAuthentication())
dopts = append(dopts, grpc.WithTransportCredentials(insecure.NewCredentials()))
for _, dopt := range dopts {
copts = append(copts, option.WithGRPCDialOption(dopt))
}
conn, err = gtransport.DialInsecure(ctx, copts...)
if err != nil {
return nil, fmt.Errorf("failed to dial %s: %w", addr, err)
}
return conn, nil
}
copts = append(copts, cred.ClientOptions()...)
if opt.TLSCACert != "" {
clog.Infof(ctx, "using TLS CA certificates=%q", opt.TLSCACert)
certPool := x509.NewCertPool()
ca, err := os.ReadFile(opt.TLSCACert)
if err != nil {
return nil, fmt.Errorf("failed to read TLS CA certificates %q: %w", opt.TLSCACert, err)
}
if ok := certPool.AppendCertsFromPEM(ca); !ok {
return nil, fmt.Errorf("failed to load TLS CA certificates from %s", opt.TLSCACert)
}
if tlsConfig == nil {
tlsConfig = &tls.Config{}
}
tlsConfig.RootCAs = certPool
}
if opt.TLSClientAuthCert != "" && opt.TLSClientAuthKey != "" {
// use mTLS certificates for authentication.
clog.Infof(ctx, "using mTLS: cert=%q key=%q", opt.TLSClientAuthCert, opt.TLSClientAuthKey)
cert, err := tls.LoadX509KeyPair(opt.TLSClientAuthCert, opt.TLSClientAuthKey)
if err != nil {
return nil, fmt.Errorf("failed to read mTLS cert pair (%q, %q): %w", opt.TLSClientAuthCert, opt.TLSClientAuthKey, err)
}
if tlsConfig == nil {
tlsConfig = &tls.Config{}
}
tlsConfig.Certificates = []tls.Certificate{cert}
} else if opt.TLSClientAuthCert != "" {
return nil, errors.New("tls_client_auth_cert is set, but tls_client_auth_key is not set")
} else if opt.TLSClientAuthKey != "" {
return nil, errors.New("tls_client_auth_key is set, but tls_client_auth_cert is not set")
}
if tlsConfig != nil {
copts = append(copts, option.WithGRPCDialOption(grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))))
}
for _, dopt := range dopts {
copts = append(copts, option.WithGRPCDialOption(dopt))
}
conn, err = gtransport.DialPool(ctx, copts...)
if err != nil {
return nil, fmt.Errorf("failed to dial %s: %w", addr, err)
}
return conn, nil
}
// NewFromConn creates new remote exec API client from conn and casConn.
func NewFromConn(ctx context.Context, opt Option, cred cred.Cred, conn, casConn grpcClientConn) (*Client, error) {
zstdDecoderPool := &sync.Pool{}
zstdDecoderPool.New = func() any {
d, err := zstd.NewReader(nil, zstdDecoderOpts...)
if err != nil {
clog.Fatalf(ctx, "failed to create zstd.Decoder: %v", err)
}
pd := &pooledDecoder{
Decoder: d,
pool: zstdDecoderPool,
}
return pd
}
fn, err := ParseDigestFunction(opt.DigestFunction)
if err != nil {
conn.Close()
if casConn != nil && casConn != conn {
casConn.Close()
}
return nil, err
}
c := &Client{
opt: opt,
digestFn: fn,
cred: cred,
conn: conn,
casConn: casConn,
zstdDecoderPool: zstdDecoderPool,
m: iometrics.New("reapi"),
}
c.knownDigests.Store(c.digestFn.Empty(), true)
return c, nil
}
// ParseDigestFunction resolves the content digest function from the
// -..._digest_function flag value. An empty name means sha256.
func ParseDigestFunction(name string) (digest.Function, error) {
if name == "" {
name = "sha256"
}
return digest.ParseFunction(name)
}
// DigestFunction returns the content digest function used with this backend.
func (c *Client) DigestFunction() digest.Function {
return c.digestFn
}
// Init initializes the client by fetching capabilities and negotiating compression.
// This requires an active connection to the remote execution backend.
func (c *Client) Init(ctx context.Context) error {
defer trace.Begin(ctx, "reapi.Init").End()
err := func() error {
defer trace.Begin(ctx, "reapi cred.Wait").End()
return c.cred.Wait()
}()
if err != nil {
return fmt.Errorf("failed to initialize credentials: %w", err)
}
cc := rpb.NewCapabilitiesClient(c.conn)
var capa *rpb.ServerCapabilities
// TODO(b/328332495): grpc should retry by service config?
err = retry.Do(ctx, func() error {
defer trace.Begin(ctx, "reapi GetCapabilities").End()
var err error
capa, err = cc.GetCapabilities(ctx, &rpb.GetCapabilitiesRequest{
InstanceName: c.opt.Instance,
})
return err
})
if err != nil {
c.Close()
return fmt.Errorf("failed to get capabilities: %w", err)
}
clog.Infof(ctx, "capabilities of %s: %s", c.opt.Instance, capa)
if err := validateDigestFunction(c.digestFn, capa); err != nil {
c.Close()
return err
}
if c.opt.CompressedBlob > 0 {
c.opt.compressor = selectCompressor(capa.GetCacheCapabilities().GetSupportedCompressors())
if c.opt.compressor != rpb.Compressor_IDENTITY {
clog.Infof(ctx, "compressed-blobs/%s for > %d", strings.ToLower(c.opt.compressor.String()), c.opt.CompressedBlob)
} else {
clog.Infof(ctx, "compressed-blobs is not supported")
}
}
if c.opt.BatchCompressedBlob > 0 {
c.opt.compressorForBatchUpdateBlobs = selectCompressor(capa.GetCacheCapabilities().GetSupportedBatchUpdateCompressors())
if c.opt.compressorForBatchUpdateBlobs != rpb.Compressor_IDENTITY {
clog.Infof(ctx, "batch-update-blobs/%s for > %d", strings.ToLower(c.opt.compressorForBatchUpdateBlobs.String()), c.opt.BatchCompressedBlob)
} else {
clog.Infof(ctx, "batch-update-blobs compression is not supported")
}
}
clog.Infof(ctx, "byte stream read threshold: %d", c.opt.ByteStreamReadThreshold)
var apiVersion *semverpb.SemVer
if c.opt.REAPIVersion != "" {
var major, minor int32
_, err := fmt.Sscanf(c.opt.REAPIVersion, "v%d.%d", &major, &minor)
if err != nil {
clog.Warningf(ctx, "failed to parse reapi version %q: %v", c.opt.REAPIVersion, err)
} else {
apiVersion = &semverpb.SemVer{
Major: major,
Minor: minor,
}
highVer := capa.GetHighApiVersion()
if highVer.GetMajor() < major || (highVer.GetMajor() == major && highVer.GetMinor() < minor) {
clog.Errorf(ctx, "higher api version is specified than server capabilities: %v > %v", apiVersion, highVer)
}
lowVer := capa.GetLowApiVersion()
if lowVer.GetMajor() > major || (lowVer.GetMajor() == major && lowVer.GetMinor() > minor) {
clog.Errorf(ctx, "lower api version is specified than server capabilities: %v < %v", apiVersion, lowVer)
}
}
}
c.mu.Lock()
defer c.mu.Unlock()
c.capabilities = capa
c.apiVersion = apiVersion
return nil
}
// validateDigestFunction checks that the configured digest function is
// advertised by the server's cache capabilities and, when the server offers
// remote execution, by its execution capabilities too (otherwise CAS uploads
// would succeed but Execute would fail later). An empty advertised list means
// an old server that only speaks sha256: clients infer sha256 in that case,
// but any other function is rejected.
func validateDigestFunction(fn digest.Function, capa *rpb.ServerCapabilities) error {
if err := checkAdvertisedDigestFunctions(fn, capa.GetCacheCapabilities().GetDigestFunctions(), "cache"); err != nil {
return err
}
ec := capa.GetExecutionCapabilities()
if !ec.GetExecEnabled() {
return nil
}
advertised := ec.GetDigestFunctions()
if len(advertised) == 0 && ec.GetDigestFunction() != rpb.DigestFunction_UNKNOWN {
// Per the spec, the repeated digest_functions field takes precedence,
// falling back to the legacy singular field if it is unset.
advertised = []rpb.DigestFunction_Value{ec.GetDigestFunction()}
}
return checkAdvertisedDigestFunctions(fn, advertised, "execution")
}
// checkAdvertisedDigestFunctions checks fn against one capability's advertised
// digest functions.
func checkAdvertisedDigestFunctions(fn digest.Function, advertised []rpb.DigestFunction_Value, capability string) error {
if len(advertised) == 0 {
if fn == digest.SHA256 {
return nil
}
return fmt.Errorf("digest function %s not supported; server advertised no %s digest functions", fn, capability)
}
if slices.Contains(advertised, fn.Value()) {
return nil
}
return fmt.Errorf("digest function %s is not supported by the server's %s capabilities; advertised: %v", fn, capability, advertised)
}
// Close closes the client's connections, including the separate CAS
// connection when one was dialed.
func (c *Client) Close() error {
err := c.conn.Close()
if c.casConn != nil && c.casConn != c.conn {
cerr := c.casConn.Close()
if err == nil {
err = cerr
}
}
return err
}
// IOMetrics returns an IOMetrics of the client.
func (c *Client) IOMetrics() *iometrics.IOMetrics {
if c == nil {
return nil
}
return c.m
}
// Proto fetches contents of digest into proto message.
func (c *Client) Proto(ctx context.Context, d digest.Digest, p proto.Message) error {
b, err := c.Get(ctx, d, fmt.Sprintf("%s -> %T", d, p))
if err != nil {
return err
}
return proto.Unmarshal(b, p)
}
// GetActionResultTimeout caps a single GetActionResult attempt.
// Picked at the observed p95 of OK responses (~1.2s on chrome
// cache-warm); past p95 is pathological, cancel-and-retry on a
// fresh stream beats waiting toward the 10s service-config deadline.
// Applied to attempt 0 only; retries run to the natural 10s deadline.
// Set to 0 to disable.
var GetActionResultTimeout = 1200 * time.Millisecond
// keepFirstAttempt reports whether to return the first GetActionResult
// attempt or fall back to a retry. The deadline can fire in the gap
// between the call returning and this check, so a completed attempt
// (success or final error) is kept; we fall back only when our own
// timeout actually cut the call off with DeadlineExceeded.
func keepFirstAttempt(callCtx context.Context, timeoutCause, callErr error) bool {
cutByOurTimeout := errors.Is(context.Cause(callCtx), timeoutCause) &&
status.Code(callErr) == codes.DeadlineExceeded
return !cutByOurTimeout
}
// GetActionResult gets the action result by the digest.
func (c *Client) GetActionResult(ctx context.Context, d digest.Digest) (*rpb.ActionResult, error) {
client := rpb.NewActionCacheClient(c.casConn)
req := &rpb.GetActionResultRequest{
InstanceName: c.opt.Instance,
ActionDigest: d.Proto(),
DigestFunction: c.digestFn.Value(),
}
// Attempt 0 runs under a short deadline so a stalled lookup
// doesn't sit the full 10s service-config deadline. Only our
// synthetic Aborted cause triggers the fallback attempt; any
// other error is returned as-is, since gRPC method-config retry
// (Aborted/Internal/ResourceExhausted/Unavailable/Unknown, up to
// 5 attempts) already covered the transport-level retries.
retried := false
if GetActionResultTimeout > 0 {
cause := status.Error(codes.Aborted, "GetActionResult first-byte timeout")
callCtx, cancel := context.WithTimeoutCause(ctx, GetActionResultTimeout, cause)
result, err := client.GetActionResult(callCtx, req)
// Decide before cancel() overwrites the deadline cause.
keep := keepFirstAttempt(callCtx, cause, err)
cancel()
if keep {
c.m.OpsDone(err)
return result, err
}
monitoring.RecordCancellation(ctx, "cache-check", "pre_first_byte")
retried = true
}
start := time.Now()
result, err := client.GetActionResult(ctx, req)
if retried {
monitoring.RecordRetryDuration(ctx, "cache-check", time.Since(start), err)
}
c.m.OpsDone(err)
return result, err
}
// UpdateActionResultEnabled reports whether UpdateActionResult is supported or not.
func (c *Client) UpdateActionResultEnabled() bool {
if c == nil {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
return c.capabilities.GetCacheCapabilities().GetActionCacheUpdateCapabilities().GetUpdateEnabled()
}
// UpdateActionResult updates the action result by the digest.
func (c *Client) UpdateActionResult(ctx context.Context, d digest.Digest, result *rpb.ActionResult) error {
if !useOutputSymlinks(c.APIVersion()) {
// backend may not support output_symlinks.
// use output_file_symlinks instead.
result = proto.CloneOf(result)
result.OutputFileSymlinks = result.OutputSymlinks //nolint:staticcheck // existing deprecation
result.OutputSymlinks = nil
}
client := rpb.NewActionCacheClient(c.casConn)
_, err := client.UpdateActionResult(ctx, &rpb.UpdateActionResultRequest{
InstanceName: c.opt.Instance,
ActionDigest: d.Proto(),
ActionResult: result,
DigestFunction: c.digestFn.Value(),
})
c.m.OpsDone(err)
return err
}
// APIVersion returns api version to use.
func (c *Client) APIVersion() *semverpb.SemVer {
if c == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if c.apiVersion != nil {
return c.apiVersion
}
return c.capabilities.GetHighApiVersion()
}
// Instance returns the instance name.
func (c *Client) Instance() string {
if c == nil {
return ""
}
return c.opt.Instance
}
// UseActionForPlatformProperties returns true
// when set Platform properties in Action message, as well as Command.
//
// message Action
// // New in version 2.2: clients SHOULD set these platform properties
// // as well as those in the Command. Servers SHOULD prefer those set here.
// Platform platform
//
// message Command
// // DEPRECATED as of v2.2: platform properties are now specified directly
// // in the action.
// Platform platform
func UseActionForPlatformProperties(apiVer *semverpb.SemVer) bool {
return apiVer.GetMajor() >= 2 && apiVer.GetMinor() >= 2
}
// UseOutputPaths returns true
// when use output_paths instead of output_files, output_directories
// in Command.
//
// message Command
// // DEPRECATED since v2.1: Use `output_paths` instead.
// repeated string output_files
//
// // DEPRECATED since v2.1: Use `output_paths` instead.
// repeated string output_directories
//
// // New in v2.1: this fields supersedes the DEPRECATED `output_files`
// // and `output_directories` fields. If `output_paths` is used,
// // `output_files` and `output_directories` will be ignored!
// repeated string output_paths
func UseOutputPaths(apiVer *semverpb.SemVer) bool {
return apiVer.GetMajor() >= 2 && apiVer.GetMinor() >= 1
}
// useOutputSymlinks returns true
// when output_symlinks instead of output_file_symlinks or
// output_directory_symlinks in ActionResult.
//
// message ActionResult
// // DEPRECATED as of v2.1. Servers that wish to be compatible with
// // v2.0 API should still populate this field in addition to
// // `output_symlinks`.
// repeated string output_file_symlinks
//
// // DEPRECATED as of v2.1. Servers that wish to be compatible with
// // v2.0 API should still populate this field in addition to
// // `output_symlinks`.
// repeated string output_directory_symlinks
//
// // New in v2.1: this field will only be populated if the command
// // `output_paths` field was used, and not the pre v2.1
// // `output_files` or `output_directories` fields.
// repeated string output_symlinks
func useOutputSymlinks(apiVer *semverpb.SemVer) bool {
return apiVer.GetMajor() >= 2 && apiVer.GetMinor() >= 1
}
// NewContext returns new context with request metadata.
func NewContext(ctx context.Context, rmd *rpb.RequestMetadata) context.Context {
// Carry the current trace span (if any) as an OpenTelemetry span context so
// RBE RPCs join that span's trace server-side (e.g. Dapper). Inert without a
// configured TracerProvider; see Option.TracerProvider.
if sp := trace.CurSpan(ctx); sp != nil {
if tid, sid, ok := sp.RawIDs(); ok {
ctx = oteltrace.ContextWithSpanContext(ctx, oteltrace.NewSpanContext(oteltrace.SpanContextConfig{
TraceID: tid,
SpanID: sid,
TraceFlags: oteltrace.FlagsSampled,
}))
}
}
if rmd == nil {
rmd = &rpb.RequestMetadata{}
}
ver, err := version.Current()
if err == nil {
rmd.ToolDetails = &rpb.ToolDetails{
ToolName: ver.ToolName(),
ToolVersion: ver.ToolVersion(),
}
}
// Set metadata on the context, replacing any existing value so that nested
// NewContext calls don't accumulate multiple entries (servers such as
// Kajiya reject requests with more than one requestmetadata-bin).
// See the document for the specification.
// https://github.com/bazelbuild/remote-apis/blob/8f539af4b407a4f649707f9632fc2b715c9aa065/build/bazel/remote/execution/v2/remote_execution.proto#L2034-L2045
b, err := proto.Marshal(rmd)
if err != nil {
clog.Warningf(ctx, "marshal %v: %v", rmd, err)
return ctx
}
md, ok := metadata.FromOutgoingContext(ctx)
if !ok {
md = metadata.MD{}
} else {
md = md.Copy()
}
md.Set(requestMetadataKey, string(b))
return metadata.NewOutgoingContext(ctx, md)
}
// MetadataFromOutgoingContext returns request metadata in outgoing context.
func MetadataFromOutgoingContext(ctx context.Context) (*rpb.RequestMetadata, bool) {
md, ok := metadata.FromOutgoingContext(ctx)
if !ok {
return nil, false
}
v, ok := md[requestMetadataKey]
if !ok {
return nil, false
}
if len(v) == 0 {
return nil, false
}
rmd := &rpb.RequestMetadata{}
err := proto.Unmarshal([]byte(v[0]), rmd)
if err != nil {
return nil, false
}
return rmd, true
}
// MaxRetries returns the configured maximum number of retries.
func (c *Client) MaxRetries() int {
return c.opt.MaxRetries
}