| // Copyright 2025 Google LLC |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package rsproxy |
| |
| import ( |
| "flag" |
| "fmt" |
| "strings" |
| "time" |
| |
| "github.com/bazelbuild/remote-apis-sdks/go/pkg/credshelper" |
| "github.com/bazelbuild/remote-apis-sdks/go/pkg/moreflag" |
| "github.com/bazelbuild/rsclient/internal/pkg/auth" |
| "github.com/bazelbuild/rsclient/internal/pkg/rsmsg" |
| "github.com/bazelbuild/rsclient/internal/pkg/wrapper" |
| "google.golang.org/grpc" |
| "google.golang.org/grpc/credentials" |
| "google.golang.org/grpc/keepalive" |
| |
| rspb "google.golang.org/genproto/googleapis/devtools/resultstore/v2" |
| ) |
| |
| // StringSlice is a type that implements the flag.Value interface for comma-separated strings. |
| type StringSlice []string |
| |
| func (s *StringSlice) String() string { |
| return strings.Join(*s, ",") |
| } |
| |
| func (s *StringSlice) Set(value string) error { |
| // append comma-separated values, making this flag repeatable-cumulative. |
| existing := make(map[string]struct{}) |
| for _, v := range *s { |
| existing[v] = struct{}{} |
| } |
| |
| for _, v := range strings.Split(value, ",") { |
| if _, found := existing[v]; !found { |
| *s = append(*s, v) |
| existing[v] = struct{}{} |
| } |
| } |
| return nil |
| } |
| |
| // CLIConfig holds all the configurable parameters for the rsproxy application. |
| type CLIConfig struct { |
| // ProxyConfig contains the majority of the library-level configuration. |
| ProxyConfig |
| |
| // auth.DialParams contains shared connection settings (auth, TLS, headers, etc.). |
| auth.DialParams |
| |
| // CredentialsHelper is the path to a binary that provides credentials. |
| CredentialsHelper string |
| // CredentialsHelperArgs are arguments for the credentials helper. |
| CredentialsHelperArgs string |
| // UseRPCCredentials, if false, disables per-RPC credentials. |
| UseRPCCredentials bool |
| // MaxConcurrentRequests is the max concurrent RPCs on a single connection. |
| MaxConcurrentRequests uint |
| // ResultStoreTLSServerName overrides the TLS server name for ResultStore. |
| ResultStoreTLSServerName string |
| // CASTLSServerName overrides the TLS server name for CAS. |
| CASTLSServerName string |
| // RPCTimeoutsStrings are comma-separated key=value pairs for RPC timeouts. |
| RPCTimeoutsStrings map[string]string |
| // KeepAliveTime is the duration between keepalive pings. |
| KeepAliveTime time.Duration |
| // KeepAliveTimeout is the duration to wait for a keepalive response. |
| KeepAliveTimeout time.Duration |
| // KeepAlivePermitWithoutStream, if true, sends pings even with no active RPCs. |
| KeepAlivePermitWithoutStream bool |
| // DebugUploadReqs, if true, sends upload requests in batches of 1. |
| DebugUploadReqs bool |
| |
| // ReadyFile is a file created after the proxy's ingress is successfully opened. |
| ReadyFile string |
| // ProxyDoneFile is a file created to signal internal processing is complete. |
| ProxyDoneFile string |
| // ProjectId is the name of the project being built. |
| ProjectId string |
| // FIFOVarName is the env var name for the path to the upload requests FIFO. |
| FIFOVarName string |
| // SignalPolicy defines how signals are handled and forwarded in integrated mode. |
| SignalPolicy wrapper.SignalPolicy |
| |
| // PreBuildUploads is a list of files to upload before the build. |
| PreBuildUploads StringSlice |
| // PostBuildUploads is a list of files to upload after the build. |
| PostBuildUploads StringSlice |
| } |
| |
| // RegisterFlags registers all proxy-related flags with the given FlagSet. |
| func (c *CLIConfig) RegisterFlags(fs *flag.FlagSet) { |
| // Egress Service Flags |
| fs.StringVar(&c.RSService, "rs_service", "", "The ResultStore service to dial via gRPC, including port, such as 'localhost:8790' or 'resultstore.googleapis.com:443'") |
| fs.StringVar(&c.CASService, "cas_service", "", "The CAS service to dial via gRPC, including port, such as 'localhost:8790' or 'remotebuildexecution.googleapis.com:443'") |
| fs.StringVar(&c.RSInstanceName, "rs_instance", "", "The instance ID to target when uploading to ResultStore via gRPC (e.g., projects/$PROJECT/instances/default_instance for Google ResultStore).") |
| fs.StringVar(&c.CASInstanceName, "cas_instance", "", "The instance ID to target when uploading content to CAS via gRPC (e.g., projects/$PROJECT/instances/default_instance for Google RBE CAS).") |
| fs.StringVar(&c.JobStatusAddr, "jobstatus_addr", "", "If set, ResultStore events are translated into JobStatus events and streamed to this gRPC address.") |
| |
| // Authentication and RPC Flags |
| fs.StringVar(&c.CredFile, "credential_file", "", "The name of a file that contains service account credentials to use when streaming build events. Used only if --use_application_default_credentials and --use_gce_credentials are false.") |
| fs.BoolVar(&c.UseApplicationDefault, "use_application_default_credentials", false, "If true, use application default credentials to connect to build event service. See https://cloud.google.com/sdk/gcloud/reference/auth/application-default/login") |
| fs.BoolVar(&c.UseComputeEngine, "use_gce_credentials", false, "If true (and --use_application_default_credentials is false), use the default GCE credentials to authenticate with build event service.") |
| fs.StringVar(&c.CredentialsHelper, credshelper.CredshelperPathFlag, "", "Path to the credentials helper binary. If given execrel://, looks for the `credshelper` binary in the same folder as the current executable.") |
| fs.StringVar(&c.CredentialsHelperArgs, credshelper.CredshelperArgsFlag, "", "Arguments for the credentials helper, separated by space.") |
| fs.BoolVar(&c.UseRPCCredentials, "use_rpc_credentials", true, "If false, no per-RPC credentials will be used (disables --credential_file, --use_application_default_credentials, and --use_gce_credentials.") |
| fs.BoolVar(&c.UseExternalAuthToken, "use_external_auth_token", false, "If true, se an externally provided auth token, given via PerRPCCreds when the SDK is initialized.") |
| fs.BoolVar(&c.NoSecurity, "service_no_security", false, "If true, do not use TLS or authentication when connecting to the gRPC service.") |
| fs.BoolVar(&c.NoAuth, "service_no_auth", false, "If true, do not authenticate with the service (implied by --service_no_security).") |
| fs.Var((*moreflag.StringListMapValue)(&c.RemoteHeaders), "remote_headers", "Comma-separated headers to pass with each RPC in the form key=value.") |
| fs.UintVar(&c.MaxConcurrentRequests, "max_concurrent_requests_per_conn", DefaultMaxConcurrentRequests, "Maximum number of concurrent RPCs on a single gRPC connection.") |
| |
| // TLS Options |
| fs.StringVar(&c.TLSServerName, "tls_server_name", "", "Override the TLS server name for both ResultStore and CAS") |
| fs.StringVar(&c.ResultStoreTLSServerName, "rs_tls_server_name", "", "Override the TLS server name for ResultStore (this defaults to --tls_server_name)") |
| fs.StringVar(&c.CASTLSServerName, "cas_tls_server_name", "", "Override the TLS server name for CAS (this defaults to --tls_server_name)") |
| fs.StringVar(&c.TLSCACertFile, "tls_ca_cert", "", "Load TLS CA certificates from this file") |
| fs.StringVar(&c.TLSClientAuthCert, "tls_client_auth_cert", "", "Certificate to use when using mTLS to connect to the RBE service.") |
| fs.StringVar(&c.TLSClientAuthKey, "tls_client_auth_key", "", "Key to use when using mTLS to connect to the RBE service.") |
| |
| // Keepalive and Timeouts |
| fs.Var((*moreflag.StringMapValue)(&c.RPCTimeoutsStrings), "rpc_timeouts", "Comma-separated key value pairs in the form rpc_name=timeout. The key for default RPC is named default. 0 indicates no timeout. Example: GetActionResult=500ms,Execute=0,default=10s.") |
| fs.DurationVar(&c.KeepAliveTime, "grpc_keepalive_time", 0*time.Second, "After a duration of this time if the client doesn't see any activity it pings the server to see if the transport is still alive. If zero or not set, the mechanism is off.") |
| fs.DurationVar(&c.KeepAliveTimeout, "grpc_keepalive_timeout", 20*time.Second, "After having pinged for keepalive check, the client waits for a duration of Timeout and if no activity is seen even after that the connection is closed. Default is 20s.") |
| fs.BoolVar(&c.KeepAlivePermitWithoutStream, "grpc_keepalive_permit_without_stream", false, "If true, client sends keepalive pings even with no active RPCs; otherwise, doesn't send pings even if time and timeout are set. Default is false.") |
| |
| // Debug and Ingress Control |
| fs.BoolVar(&c.DebugUploadReqs, "debug_upload_reqs", false, "If true, send upload requests in batches of 1 for easier debugging.") |
| fs.StringVar(&c.ReadyFile, "ready_file", "", "If provided, a file will be created at this path after the proxy's ingress is successfully opened.") |
| fs.StringVar(&c.ProxyDoneFile, "proxy_done_file", "", "If set, a file will be created at this path to signal that the server has finished internal data processing and is ready for shutdown.") |
| fs.BoolVar(&c.Passthrough, "passthrough", false, "If true, skip all internal translation.") |
| fs.BoolVar(&c.MangleResourceIDs, "mangle_resource_ids", true, "If true, transform IDs to conform to ResultStore API (only 'a-z, A-Z, 0-9, _, and -' are allowed). (default: true)") |
| fs.StringVar(&c.PrototextWriterOptions.Path, "prototext_dump_path", "", "If set, append incoming UploadRequests to this file in prototext format.") |
| |
| // Ingress Flags |
| fs.StringVar(&c.StreamIngressOptions.InputFile, "input_file", "", "Path to a regular file containing ResultStore UploadRequests.") |
| fs.StringVar(&c.StreamIngressOptions.InputFIFO, "input_fifo", "", "Path to a named FIFO of ResultStore UploadRequests.") |
| fs.StringVar(&c.GRPCIngressOptions.Addr, "grpc_ingress_addr", "", "If set, start a gRPC ingress server on this address to receive upload requests.") |
| fs.StringVar(&c.PrototextReaderOptions.Path, "prototext_input_file", "", "If set, read human-readable UploadRequests from this file.") |
| |
| // Application metadata |
| fs.StringVar(&c.ProjectId, "project_id", "unknownproject", "Name of project being built.") |
| fs.StringVar(&c.ResultsURL, "results_url", "https://btx.cloud.google.com/invocations/", "URL for viewing ResultStore invocations") |
| fs.BoolVar(&c.QuietMode, "quiet", false, "If true, run silently without showing invocation URLs") |
| fs.Var(&c.PreBuildUploads, "pre_build_uploads", "Comma-separated list of files to upload before the build (repeatable, cumulative)") |
| fs.Var(&c.PostBuildUploads, "post_build_uploads", "Comma-separated list of files to upload after the build (repeatable, cumulative)") |
| fs.StringVar(&c.FIFOVarName, "fifo_var_name", "RSPROXY_FIFO", "The name of the environment variable to set with the path to the upload requests fifo.") |
| fs.Var(&c.SignalPolicy, "signal_policy", "Signal forwarding policy for integrated mode: relay (default), passive, or relay-group.") |
| } |
| |
| // ToProxyOpts converts the CLI configuration into a set of Proxy options. |
| func (c *CLIConfig) ToProxyOpts() ([]ProxyOpt, error) { |
| if c.SignalPolicy == "" { |
| c.SignalPolicy = wrapper.PolicyRelay |
| } |
| var opts []ProxyOpt |
| |
| if len(c.RPCTimeoutsStrings) > 0 { |
| timeouts := make(map[string]time.Duration) |
| for rpc, d := range DefaultRPCTimeouts { |
| timeouts[rpc] = d |
| } |
| for rpc, s := range c.RPCTimeoutsStrings { |
| d, err := time.ParseDuration(s) |
| if err != nil { |
| return nil, err |
| } |
| timeouts[rpc] = d |
| } |
| opts = append(opts, RPCTimeouts(timeouts)) |
| } |
| |
| var perRPCCreds *auth.PerRPCCreds |
| if c.CredentialsHelper != "" { |
| creds, err := credshelper.NewExternalCredentials(c.CredentialsHelper, strings.Fields(c.CredentialsHelperArgs)) |
| if err != nil { |
| return nil, fmt.Errorf("credentials helper failed. Please try again or use another method of authentication:%v", err) |
| } |
| perRPCCreds = &auth.PerRPCCreds{Creds: creds.PerRPCCreds()} |
| } |
| if perRPCCreds != nil { |
| opts = append(opts, &ProxyPerRPCCredsOpt{Creds: perRPCCreds.Creds}) |
| } |
| |
| dialOpts := make([]grpc.DialOption, 0) |
| if c.KeepAliveTime > 0*time.Second { |
| params := keepalive.ClientParameters{ |
| Time: c.KeepAliveTime, |
| Timeout: c.KeepAliveTimeout, |
| PermitWithoutStream: c.KeepAlivePermitWithoutStream, |
| } |
| dialOpts = append(dialOpts, grpc.WithKeepaliveParams(params)) |
| } |
| |
| c.DialOpts = dialOpts |
| c.ExternalPerRPCCreds = perRPCCreds |
| c.TransportCredsOnly = !c.UseRPCCredentials |
| c.DialParams.MaxConcurrentRequests = uint32(c.MaxConcurrentRequests) |
| |
| makeDialParams := func(service string, tlsServerName string) auth.DialParams { |
| p := c.DialParams |
| p.Service = service |
| if tlsServerName != "" { |
| p.TLSServerName = tlsServerName |
| } |
| return p |
| } |
| |
| rsTLSServerName := c.ResultStoreTLSServerName |
| casTLSServerName := c.CASTLSServerName |
| |
| c.PreBuildArtifacts = c.PreBuildUploads |
| c.PostBuildArtifacts = c.PostBuildUploads |
| |
| if c.DebugUploadReqs { |
| c.BundleCountThreshold = 1 |
| } |
| |
| opts = append(opts, &c.ProxyConfig) |
| opts = append(opts, &DialParamsOpt{ |
| RSDialParams: makeDialParams(c.RSService, rsTLSServerName), |
| CASDialParams: makeDialParams(c.CASService, casTLSServerName), |
| JSDialParams: auth.DialParams{ |
| Service: c.JobStatusAddr, |
| NoSecurity: true, |
| DialOpts: dialOpts, |
| }, |
| }) |
| |
| return opts, nil |
| } |
| |
| // BuildPartialInvocation creates a partial Invocation protobuf based on the CLI config. |
| // This partial invocation will be merged with the first Invocation message (Create) |
| // coming from the build tool. |
| // |
| // Expectations: |
| // 1. The build tool is expected to populate all timing information. |
| // 2. The build tool is expected to report its command line. |
| // 3. Since the description often bears the buildId, it is the responsibility |
| // of the build tool to set both the buildId and the description. |
| func (c *CLIConfig) BuildPartialInvocation(version string, lookupUser func() string, lookupHostname func() string, getWorkingDir func() string) *rspb.Invocation { |
| |
| return &rspb.Invocation{ |
| InvocationAttributes: &rspb.InvocationAttributes{ |
| Labels: []string{c.ProjectId}, |
| ProjectId: rsmsg.ParseProjectID(c.RSInstanceName), |
| Users: []string{lookupUser()}, |
| }, |
| WorkspaceInfo: &rspb.WorkspaceInfo{ |
| Hostname: lookupHostname(), |
| WorkingDirectory: getWorkingDir(), |
| }, |
| Properties: []*rspb.Property{ |
| { |
| Key: "rsproxy_version", |
| Value: version, |
| }, |
| }, |
| } |
| } |
| |
| // ConfigureRPCCreds prepares and returns PerRPCCredentials based on the CLIConfig. |
| func (c *CLIConfig) ConfigureRPCCreds() (credentials.PerRPCCredentials, error) { |
| var pCreds credentials.PerRPCCredentials |
| if c.CredentialsHelper != "" { |
| creds, err := credshelper.NewExternalCredentials(c.CredentialsHelper, strings.Fields(c.CredentialsHelperArgs)) |
| if err != nil { |
| return nil, err |
| } |
| pCreds = creds.PerRPCCreds() |
| } |
| return pCreds, nil |
| } |