golang.org/x/build@v0.0.0-20240506185731-218518f32b70/buildlet/buildletclient.go (about)

     1  // Copyright 2015 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package buildlet // import "golang.org/x/build/buildlet"
     6  
     7  import (
     8  	"bufio"
     9  	"context"
    10  	"encoding/json"
    11  	"errors"
    12  	"fmt"
    13  	"io"
    14  	"log"
    15  	"net"
    16  	"net/http"
    17  	"net/url"
    18  	"os"
    19  	"strings"
    20  	"sync"
    21  	"time"
    22  )
    23  
    24  var _ Client = (*client)(nil)
    25  
    26  // NewClient returns a *client that will manipulate ipPort,
    27  // authenticated using the provided keypair.
    28  //
    29  // This constructor returns immediately without testing the host or auth.
    30  func NewClient(ipPort string, kp KeyPair) Client {
    31  	tr := &http.Transport{
    32  		Dial:            defaultDialer(),
    33  		DialTLS:         kp.tlsDialer(),
    34  		IdleConnTimeout: time.Minute,
    35  	}
    36  	c := &client{
    37  		ipPort:     ipPort,
    38  		tls:        kp,
    39  		password:   kp.Password(),
    40  		httpClient: &http.Client{Transport: tr},
    41  		closeFuncs: []func(){tr.CloseIdleConnections},
    42  	}
    43  	c.setCommon()
    44  	return c
    45  }
    46  
    47  func (c *client) setCommon() {
    48  	c.peerDead = make(chan struct{})
    49  	c.ctx, c.ctxCancel = context.WithCancel(context.Background())
    50  }
    51  
    52  // SetOnHeartbeatFailure sets a function to be called when heartbeats
    53  // against this builder fail, or when the client is destroyed with
    54  // Close. The function fn is never called more than once.
    55  // SetOnHeartbeatFailure must be set before any use of the buildlet.
    56  func (c *client) SetOnHeartbeatFailure(fn func()) {
    57  	c.heartbeatFailure = fn
    58  }
    59  
    60  var ErrClosed = errors.New("buildlet: Client closed")
    61  
    62  // Close destroys and closes down the buildlet, destroying all state
    63  // immediately.
    64  func (c *client) Close() error {
    65  	// TODO(bradfitz): have a Client-wide Done channel we set on
    66  	// all outbound HTTP Requests and close it in the once here?
    67  	// Then if something was in-flight and somebody else Closes,
    68  	// the local http.Transport notices, rather than noticing via
    69  	// the remote machine dying or timing out.
    70  	c.closeOnce.Do(func() {
    71  		// Send a best-effort notification to the server to destroy itself.
    72  		// Don't want too long (since it's likely in a broken state anyway).
    73  		// Ignore the return value, since we're about to forcefully destroy
    74  		// it anyway.
    75  		req, err := http.NewRequest("POST", c.URL()+"/halt", nil)
    76  		if err != nil {
    77  			// ignore.
    78  		} else {
    79  			_, err = c.doHeaderTimeout(req, 2*time.Second)
    80  		}
    81  		if err == nil {
    82  			err = ErrClosed
    83  		}
    84  		for _, fn := range c.closeFuncs {
    85  			fn()
    86  		}
    87  		c.setPeerDead(err) // which will also cause c.heartbeatFailure to run
    88  	})
    89  	return nil
    90  }
    91  
    92  func (c *client) setPeerDead(err error) {
    93  	c.setPeerDeadOnce.Do(func() {
    94  		c.MarkBroken()
    95  		if err == nil {
    96  			err = errors.New("peer dead (no specific error)")
    97  		}
    98  		c.deadErr = err
    99  		close(c.peerDead)
   100  	})
   101  }
   102  
   103  // SetDescription sets a short description of where the buildlet
   104  // connection came from.  This is used by the build coordinator status
   105  // page, mostly for debugging.
   106  func (c *client) SetDescription(v string) {
   107  	c.desc = v
   108  }
   109  
   110  // SetInstanceName sets an instance name for GCE and EC2 buildlets.
   111  // This value differs from the buildlet name used in the CLI and web interface.
   112  func (c *client) SetInstanceName(v string) {
   113  	c.instanceName = v
   114  }
   115  
   116  // InstanceName gets an instance name for GCE and EC2 buildlets.
   117  // This value differs from the buildlet name used in the CLI and web interface.
   118  // For non-GCE or EC2 buildlets, this will return an empty string.
   119  func (c *client) InstanceName() string {
   120  	return c.instanceName
   121  }
   122  
   123  // SetHTTPClient replaces the underlying HTTP client.
   124  // It should only be called before the Client is used.
   125  func (c *client) SetHTTPClient(httpClient *http.Client) {
   126  	c.httpClient = httpClient
   127  }
   128  
   129  // SetDialer sets the function that creates a new connection to the buildlet.
   130  // By default, net.Dialer.DialContext is used. SetDialer has effect only when
   131  // TLS isn't used.
   132  //
   133  // TODO(bradfitz): this is only used for ssh connections to buildlets,
   134  // which previously required the client to do its own net.Dial +
   135  // upgrade request. But now that the net/http client supports
   136  // read/write bodies for protocol upgrades, we could change how ssh
   137  // works and delete this.
   138  func (c *client) SetDialer(dialer func(context.Context) (net.Conn, error)) {
   139  	c.dialer = dialer
   140  }
   141  
   142  // defaultDialer returns the net/http package's default Dial function.
   143  // Notably, this sets TCP keep-alive values, so when we kill VMs
   144  // (whose TCP stacks stop replying, forever), we don't leak file
   145  // descriptors for otherwise forever-stalled TCP connections.
   146  func defaultDialer() func(network, addr string) (net.Conn, error) {
   147  	if fn := http.DefaultTransport.(*http.Transport).Dial; fn != nil {
   148  		return fn
   149  	}
   150  	return net.Dial
   151  }
   152  
   153  // A client interacts with a single buildlet.
   154  type client struct {
   155  	ipPort         string // required, unless remoteBuildlet+baseURL is set
   156  	tls            KeyPair
   157  	httpClient     *http.Client
   158  	dialer         func(context.Context) (net.Conn, error) // nil means to use net.Dialer.DialContext
   159  	baseURL        string                                  // optional baseURL (used by remote buildlets)
   160  	authUser       string                                  // defaults to "gomote", if password is non-empty
   161  	password       string                                  // basic auth password or empty for none
   162  	remoteBuildlet string                                  // non-empty if for remote buildlets (used by client)
   163  	name           string                                  // optional name for debugging, returned by Name
   164  	instanceName   string                                  // instance name for GCE and EC2 VMs
   165  
   166  	closeFuncs []func() // optional extra code to run on close
   167  
   168  	ctx              context.Context
   169  	ctxCancel        context.CancelFunc
   170  	heartbeatFailure func() // optional
   171  	desc             string
   172  
   173  	closeOnce         sync.Once
   174  	initHeartbeatOnce sync.Once
   175  	setPeerDeadOnce   sync.Once
   176  	peerDead          chan struct{} // closed on peer death
   177  	deadErr           error         // guarded by peerDead's close
   178  
   179  	mu     sync.Mutex
   180  	broken bool // client is broken in some way
   181  }
   182  
   183  func (c *client) String() string {
   184  	if c == nil {
   185  		return "(nil buildlet.Client)"
   186  	}
   187  	return strings.TrimSpace(c.URL() + " " + c.desc)
   188  }
   189  
   190  // RemoteName returns the name of this client's buildlet on the
   191  // coordinator. If this buildlet isn't a remote buildlet created via
   192  // gomote, this returns the empty string.
   193  func (c *client) RemoteName() string {
   194  	return c.remoteBuildlet
   195  }
   196  
   197  // URL returns the buildlet's URL prefix, without a trailing slash.
   198  func (c *client) URL() string {
   199  	if c.baseURL != "" {
   200  		return strings.TrimRight(c.baseURL, "/")
   201  	}
   202  	if !c.tls.IsZero() {
   203  		return "https://" + strings.TrimSuffix(c.ipPort, ":443")
   204  	}
   205  	return "http://" + strings.TrimSuffix(c.ipPort, ":80")
   206  }
   207  
   208  func (c *client) IPPort() string { return c.ipPort }
   209  
   210  func (c *client) SetName(name string) { c.name = name }
   211  
   212  // Name returns the name of this buildlet.
   213  // It returns the first non-empty string from the name given to
   214  // SetName, its remote buildlet name, its ip:port, or "(unnamed-buildlet)" in the case where
   215  // ip:port is empty because there's a custom dialer.
   216  func (c *client) Name() string {
   217  	if c.name != "" {
   218  		return c.name
   219  	}
   220  	if c.remoteBuildlet != "" {
   221  		return c.remoteBuildlet
   222  	}
   223  	if c.ipPort != "" {
   224  		return c.ipPort
   225  	}
   226  	return "(unnamed-buildlet)"
   227  }
   228  
   229  // MarkBroken marks this client as broken in some way.
   230  func (c *client) MarkBroken() {
   231  	c.mu.Lock()
   232  	defer c.mu.Unlock()
   233  	c.broken = true
   234  	c.ctxCancel()
   235  }
   236  
   237  // IsBroken reports whether this client is broken in some way.
   238  func (c *client) IsBroken() bool {
   239  	c.mu.Lock()
   240  	defer c.mu.Unlock()
   241  	return c.broken
   242  }
   243  
   244  func (c *client) authUsername() string {
   245  	if c.authUser != "" {
   246  		return c.authUser
   247  	}
   248  	return "gomote"
   249  }
   250  
   251  func (c *client) do(req *http.Request) (*http.Response, error) {
   252  	c.initHeartbeatOnce.Do(c.initHeartbeats)
   253  	if c.password != "" {
   254  		req.SetBasicAuth(c.authUsername(), c.password)
   255  	}
   256  	if c.remoteBuildlet != "" {
   257  		req.Header.Set("X-Buildlet-Proxy", c.remoteBuildlet)
   258  	}
   259  	return c.httpClient.Do(req)
   260  }
   261  
   262  // ProxyTCP connects to the given port on the remote buildlet.
   263  // The buildlet client must currently be a gomote client (RemoteName != "")
   264  // and the target type must be a VM type running on GCE. This was primarily
   265  // created for RDP to Windows machines, but it might get reused for other
   266  // purposes in the future.
   267  func (c *client) ProxyTCP(port int) (io.ReadWriteCloser, error) {
   268  	if c.RemoteName() == "" {
   269  		return nil, errors.New("ProxyTCP currently only supports gomote-created buildlets")
   270  	}
   271  	req, err := http.NewRequest("POST", c.URL()+"/tcpproxy", nil)
   272  	if err != nil {
   273  		return nil, err
   274  	}
   275  	req.Header.Add("X-Target-Port", fmt.Sprint(port))
   276  	res, err := c.do(req)
   277  	if err != nil {
   278  		return nil, err
   279  	}
   280  	if res.StatusCode != http.StatusSwitchingProtocols {
   281  		slurp, _ := io.ReadAll(io.LimitReader(res.Body, 4<<10))
   282  		res.Body.Close()
   283  		return nil, fmt.Errorf("wanted 101 Switching Protocols; unexpected response: %v, %q", res.Status, slurp)
   284  	}
   285  	rwc, ok := res.Body.(io.ReadWriteCloser)
   286  	if !ok {
   287  		res.Body.Close()
   288  		return nil, fmt.Errorf("tcpproxy response was not a Writer")
   289  	}
   290  	return rwc, nil
   291  }
   292  
   293  // ProxyRoundTripper returns a RoundTripper that sends HTTP requests directly
   294  // through to the underlying buildlet, adding auth and X-Buildlet-Proxy headers
   295  // as necessary. This is really only intended for use by the coordinator.
   296  func (c *client) ProxyRoundTripper() http.RoundTripper {
   297  	return proxyRoundTripper{c}
   298  }
   299  
   300  type proxyRoundTripper struct {
   301  	c *client
   302  }
   303  
   304  func (p proxyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
   305  	return p.c.do(req)
   306  }
   307  
   308  func (c *client) initHeartbeats() {
   309  	go c.heartbeatLoop()
   310  }
   311  
   312  func (c *client) heartbeatLoop() {
   313  	failInARow := 0
   314  	for {
   315  		select {
   316  		case <-c.peerDead:
   317  			// Dead for whatever reason (heartbeat, remote
   318  			// side closed, caller Closed
   319  			// normally). Regardless, we call the
   320  			// heartbeatFailure func if set.
   321  			if c.heartbeatFailure != nil {
   322  				c.heartbeatFailure()
   323  			}
   324  			return
   325  		case <-time.After(10 * time.Second):
   326  			t0 := time.Now()
   327  			if _, err := c.Status(context.Background()); err != nil {
   328  				failInARow++
   329  				if failInARow == 3 {
   330  					log.Printf("Buildlet %v failed three heartbeats; final error: %v", c, err)
   331  					c.setPeerDead(fmt.Errorf("Buildlet %v failed heartbeat after %v; marking dead; err=%v", c, time.Since(t0), err))
   332  				}
   333  			} else {
   334  				failInARow = 0
   335  			}
   336  		}
   337  	}
   338  }
   339  
   340  var errHeaderTimeout = errors.New("timeout waiting for headers")
   341  
   342  // doHeaderTimeout calls c.do(req) and returns its results, or
   343  // errHeaderTimeout if max elapses first.
   344  func (c *client) doHeaderTimeout(req *http.Request, max time.Duration) (res *http.Response, err error) {
   345  	type resErr struct {
   346  		res *http.Response
   347  		err error
   348  	}
   349  
   350  	ctx, cancel := context.WithCancel(req.Context())
   351  
   352  	req = req.WithContext(ctx)
   353  
   354  	timer := time.NewTimer(max)
   355  	defer timer.Stop()
   356  
   357  	resErrc := make(chan resErr, 1)
   358  	go func() {
   359  		res, err := c.do(req)
   360  		resErrc <- resErr{res, err}
   361  	}()
   362  
   363  	cleanup := func() {
   364  		cancel()
   365  		if re := <-resErrc; re.res != nil {
   366  			re.res.Body.Close()
   367  		}
   368  	}
   369  
   370  	select {
   371  	case re := <-resErrc:
   372  		if re.err != nil {
   373  			cancel()
   374  			return nil, re.err
   375  		}
   376  		// Clean up our cancel context above when the caller
   377  		// reads to the end of the response body or closes.
   378  		re.res.Body = onEOFReadCloser{re.res.Body, cancel}
   379  		return re.res, nil
   380  	case <-c.peerDead:
   381  		log.Printf("%s: peer dead with %v, waiting for headers for %v", c.Name(), c.deadErr, req.URL.Path)
   382  		go cleanup()
   383  		return nil, c.deadErr
   384  	case <-timer.C:
   385  		log.Printf("%s: timeout after %v waiting for headers for %v", c.Name(), max, req.URL.Path)
   386  		go cleanup()
   387  		return nil, errHeaderTimeout
   388  	}
   389  }
   390  
   391  // doOK sends the request and expects a 200 OK response.
   392  func (c *client) doOK(req *http.Request) error {
   393  	res, err := c.do(req)
   394  	if err != nil {
   395  		return err
   396  	}
   397  	defer res.Body.Close()
   398  	if res.StatusCode != http.StatusOK {
   399  		slurp, _ := io.ReadAll(io.LimitReader(res.Body, 4<<10))
   400  		return fmt.Errorf("%v; body: %s", res.Status, slurp)
   401  	}
   402  	return nil
   403  }
   404  
   405  // PutTar writes files to the remote buildlet, rooted at the relative
   406  // directory dir.
   407  // If dir is empty, they're placed at the root of the buildlet's work directory.
   408  // The dir is created if necessary.
   409  // The Reader must be of a tar.gz file.
   410  func (c *client) PutTar(ctx context.Context, r io.Reader, dir string) error {
   411  	req, err := http.NewRequest("PUT", c.URL()+"/writetgz?dir="+url.QueryEscape(dir), r)
   412  	if err != nil {
   413  		return err
   414  	}
   415  	return c.doOK(req.WithContext(ctx))
   416  }
   417  
   418  // PutTarFromURL tells the buildlet to download the tar.gz file from tarURL
   419  // and write it to dir, a relative directory from the workdir.
   420  // If dir is empty, they're placed at the root of the buildlet's work directory.
   421  // The dir is created if necessary.
   422  // The url must be of a tar.gz file.
   423  func (c *client) PutTarFromURL(ctx context.Context, tarURL, dir string) error {
   424  	form := url.Values{
   425  		"url": {tarURL},
   426  	}
   427  	req, err := http.NewRequest("POST", c.URL()+"/writetgz?dir="+url.QueryEscape(dir), strings.NewReader(form.Encode()))
   428  	if err != nil {
   429  		return err
   430  	}
   431  	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   432  	return c.doOK(req.WithContext(ctx))
   433  }
   434  
   435  // Put writes the provided file to path (relative to workdir) and sets mode.
   436  // It creates any missing parent directories with 0755 permission.
   437  func (c *client) Put(ctx context.Context, r io.Reader, path string, mode os.FileMode) error {
   438  	param := url.Values{
   439  		"path": {path},
   440  		"mode": {fmt.Sprint(int64(mode))},
   441  	}
   442  	req, err := http.NewRequest("PUT", c.URL()+"/write?"+param.Encode(), r)
   443  	if err != nil {
   444  		return err
   445  	}
   446  	return c.doOK(req.WithContext(ctx))
   447  }
   448  
   449  // GetTar returns a .tar.gz stream of the given directory, relative to the buildlet's work dir.
   450  // The provided dir may be empty to get everything.
   451  func (c *client) GetTar(ctx context.Context, dir string) (io.ReadCloser, error) {
   452  	req, err := http.NewRequest("GET", c.URL()+"/tgz?dir="+url.QueryEscape(dir), nil)
   453  	if err != nil {
   454  		return nil, err
   455  	}
   456  	res, err := c.do(req.WithContext(ctx))
   457  	if err != nil {
   458  		return nil, err
   459  	}
   460  	if res.StatusCode != http.StatusOK {
   461  		slurp, _ := io.ReadAll(io.LimitReader(res.Body, 4<<10))
   462  		res.Body.Close()
   463  		return nil, fmt.Errorf("%v; body: %s", res.Status, slurp)
   464  	}
   465  	return res.Body, nil
   466  }
   467  
   468  // ExecOpts are options for a remote command invocation.
   469  type ExecOpts struct {
   470  	// Output is the output of stdout and stderr.
   471  	// If nil, the output is discarded.
   472  	Output io.Writer
   473  
   474  	// Dir is the directory from which to execute the command,
   475  	// as an absolute or relative path using the buildlet's native
   476  	// path separator, or a slash-separated relative path.
   477  	// If relative, it is relative to the buildlet's work directory.
   478  	//
   479  	// Dir is optional. If not specified, it defaults to the directory of
   480  	// the command, or the work directory if SystemLevel is set.
   481  	Dir string
   482  
   483  	// Args are the arguments to pass to the cmd given to Client.Exec.
   484  	Args []string
   485  
   486  	// ExtraEnv are KEY=VALUE pairs to append to the buildlet
   487  	// process's environment.
   488  	ExtraEnv []string
   489  
   490  	// Path, if non-nil, specifies the PATH variable of the executed process's
   491  	// environment. Each path in the list should use separators native to the
   492  	// buildlet's platform, and a non-nil empty list clears the path.
   493  	//
   494  	// The following expansions apply:
   495  	//   - the string "$PATH" expands to any existing PATH element(s)
   496  	//   - the substring "$WORKDIR" expands to buildlet's temp workdir
   497  	//
   498  	// After expansion, the list is joined with an OS-specific list
   499  	// separator and supplied to the executed process as its PATH
   500  	// environment variable.
   501  	Path []string
   502  
   503  	// SystemLevel controls whether the command is expected to be found outside of
   504  	// the buildlet's environment.
   505  	SystemLevel bool
   506  
   507  	// Debug, if true, instructs to the buildlet to print extra debug
   508  	// info to the output before the command begins executing.
   509  	Debug bool
   510  
   511  	// OnStartExec is an optional hook that runs after the 200 OK
   512  	// response from the buildlet, but before the output begins
   513  	// writing to Output.
   514  	OnStartExec func()
   515  }
   516  
   517  // ErrTimeout is a sentinel error that represents that waiting
   518  // for a command to complete has exceeded the given timeout.
   519  var ErrTimeout = errors.New("buildlet: timeout waiting for command to complete")
   520  
   521  // Exec runs cmd on the buildlet.
   522  //
   523  // cmd may be an absolute or relative path using the buildlet's native path
   524  // separator, or a slash-separated relative path. If relative, it is
   525  // relative to the buildlet's work directory (not opts.Dir).
   526  //
   527  // Two errors are returned: one is whether the command succeeded
   528  // remotely (remoteErr), and the second (execErr) is whether there
   529  // were system errors preventing the command from being started or
   530  // seen to completition. If execErr is non-nil, the remoteErr is
   531  // meaningless.
   532  //
   533  // If the context's deadline is exceeded while waiting for the command
   534  // to complete, the returned execErr is ErrTimeout.
   535  func (c *client) Exec(ctx context.Context, cmd string, opts ExecOpts) (remoteErr, execErr error) {
   536  	var mode string
   537  	if opts.SystemLevel {
   538  		mode = "sys"
   539  	}
   540  	path := opts.Path
   541  	if len(path) == 0 && path != nil {
   542  		// url.Values doesn't distinguish between a nil slice and
   543  		// a non-nil zero-length slice, so use this sentinel value.
   544  		path = []string{"$EMPTY"}
   545  	}
   546  	form := url.Values{
   547  		"cmd":    {cmd},
   548  		"mode":   {mode},
   549  		"dir":    {opts.Dir},
   550  		"cmdArg": opts.Args,
   551  		"env":    opts.ExtraEnv,
   552  		"path":   path,
   553  		"debug":  {fmt.Sprint(opts.Debug)},
   554  	}
   555  	req, err := http.NewRequest("POST", c.URL()+"/exec", strings.NewReader(form.Encode()))
   556  	if err != nil {
   557  		return nil, err
   558  	}
   559  	req = req.WithContext(ctx)
   560  	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   561  
   562  	// The first thing the buildlet's exec handler does is flush the headers, so
   563  	// 20 seconds should be plenty of time, regardless of where on the planet
   564  	// (Atlanta, Paris, Sydney, etc.) the reverse buildlet is:
   565  	res, err := c.doHeaderTimeout(req, 20*time.Second)
   566  	if err == errHeaderTimeout {
   567  		// If we don't see headers after all that time,
   568  		// consider the buildlet to be unhealthy.
   569  		c.MarkBroken()
   570  		return nil, errors.New("buildlet: timeout waiting for exec header response")
   571  	} else if err != nil {
   572  		return nil, err
   573  	}
   574  	defer res.Body.Close()
   575  	if res.StatusCode != http.StatusOK {
   576  		slurp, _ := io.ReadAll(io.LimitReader(res.Body, 4<<10))
   577  		return nil, fmt.Errorf("buildlet: HTTP status %v: %s", res.Status, slurp)
   578  	}
   579  	condRun(opts.OnStartExec)
   580  
   581  	type errs struct {
   582  		remoteErr, execErr error
   583  	}
   584  	resc := make(chan errs, 1)
   585  	go func() {
   586  		// Stream the output:
   587  		out := opts.Output
   588  		if out == nil {
   589  			out = io.Discard
   590  		}
   591  		if _, err := io.Copy(out, res.Body); err != nil {
   592  			resc <- errs{execErr: fmt.Errorf("error copying response: %w", err)}
   593  			return
   594  		}
   595  
   596  		// Don't record to the dashboard unless we heard the trailer from
   597  		// the buildlet, otherwise it was probably some unrelated error
   598  		// (like the VM being killed, or the buildlet crashing due to
   599  		// e.g. https://golang.org/issue/9309, since we require a tip
   600  		// build of the buildlet to get Trailers support)
   601  		state := res.Trailer.Get("Process-State")
   602  		if state == "" {
   603  			resc <- errs{execErr: errors.New("missing Process-State trailer from HTTP response; buildlet built with old (<= 1.4) Go?")}
   604  			return
   605  		}
   606  		if state != "ok" {
   607  			resc <- errs{remoteErr: errors.New(state)}
   608  		} else {
   609  			resc <- errs{} // success
   610  		}
   611  	}()
   612  	select {
   613  	case res := <-resc:
   614  		if res.execErr != nil {
   615  			// Note: We've historically marked the buildlet as unhealthy after
   616  			// reaching any kind of execution error, even when it's a remote command
   617  			// execution timeout (see use of ErrTimeout below).
   618  			// This is certainly on the safer side of avoiding false positive signal,
   619  			// but maybe someday we'll want to start to rely on the buildlet to report
   620  			// such a condition and not mark it as unhealthy.
   621  
   622  			c.MarkBroken()
   623  			if errors.Is(res.execErr, context.DeadlineExceeded) {
   624  				res.execErr = ErrTimeout
   625  			}
   626  		}
   627  		return res.remoteErr, res.execErr
   628  	case <-c.peerDead:
   629  		return nil, c.deadErr
   630  	}
   631  }
   632  
   633  // RemoveAll deletes the provided paths, relative to the work directory.
   634  func (c *client) RemoveAll(ctx context.Context, paths ...string) error {
   635  	if len(paths) == 0 {
   636  		return nil
   637  	}
   638  	form := url.Values{"path": paths}
   639  	req, err := http.NewRequest("POST", c.URL()+"/removeall", strings.NewReader(form.Encode()))
   640  	if err != nil {
   641  		return err
   642  	}
   643  	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   644  	return c.doOK(req.WithContext(ctx))
   645  }
   646  
   647  // Status provides status information about the buildlet.
   648  //
   649  // A coordinator can use the provided information to decide what, if anything,
   650  // to do with a buildlet.
   651  type Status struct {
   652  	Version int // buildlet version, coordinator rejects value that is too old (see minBuildletVersion).
   653  }
   654  
   655  // Status returns an Status value describing this buildlet.
   656  func (c *client) Status(ctx context.Context) (Status, error) {
   657  	select {
   658  	case <-c.peerDead:
   659  		return Status{}, c.deadErr
   660  	default:
   661  		// Continue below.
   662  	}
   663  	req, err := http.NewRequest("GET", c.URL()+"/status", nil)
   664  	if err != nil {
   665  		return Status{}, err
   666  	}
   667  	req = req.WithContext(ctx)
   668  	resp, err := c.doHeaderTimeout(req, 20*time.Second) // plenty of time
   669  	if err != nil {
   670  		return Status{}, err
   671  	}
   672  	if resp.StatusCode != http.StatusOK {
   673  		return Status{}, errors.New(resp.Status)
   674  	}
   675  	b, err := io.ReadAll(resp.Body)
   676  	resp.Body.Close()
   677  	if err != nil {
   678  		return Status{}, err
   679  	}
   680  	var status Status
   681  	if err := json.Unmarshal(b, &status); err != nil {
   682  		return Status{}, err
   683  	}
   684  	return status, nil
   685  }
   686  
   687  // WorkDir returns the absolute path to the buildlet work directory.
   688  func (c *client) WorkDir(ctx context.Context) (string, error) {
   689  	req, err := http.NewRequest("GET", c.URL()+"/workdir", nil)
   690  	if err != nil {
   691  		return "", err
   692  	}
   693  	req = req.WithContext(ctx)
   694  	resp, err := c.doHeaderTimeout(req, 20*time.Second) // plenty of time
   695  	if err != nil {
   696  		return "", err
   697  	}
   698  	if resp.StatusCode != http.StatusOK {
   699  		return "", errors.New(resp.Status)
   700  	}
   701  	b, err := io.ReadAll(resp.Body)
   702  	resp.Body.Close()
   703  	if err != nil {
   704  		return "", err
   705  	}
   706  	return string(b), nil
   707  }
   708  
   709  // DirEntry is the information about a file on a buildlet.
   710  type DirEntry struct {
   711  	// Line is of the form "drw-rw-rw\t<name>" and then if a regular file,
   712  	// also "\t<size>\t<modtime>". in either case, without trailing newline.
   713  	// TODO: break into parsed fields?
   714  	Line string
   715  }
   716  
   717  func (de DirEntry) String() string {
   718  	return de.Line
   719  }
   720  
   721  // Name returns the relative path to the file, such as "src/net/http/" or "src/net/http/jar.go".
   722  func (de DirEntry) Name() string {
   723  	f := strings.Split(de.Line, "\t")
   724  	if len(f) < 2 {
   725  		return ""
   726  	}
   727  	return f[1]
   728  }
   729  
   730  // Perm returns the permission bits in string form, such as "-rw-r--r--" or "drwxr-xr-x".
   731  func (de DirEntry) Perm() string {
   732  	i := strings.IndexByte(de.Line, '\t')
   733  	if i == -1 {
   734  		return ""
   735  	}
   736  	return de.Line[:i]
   737  }
   738  
   739  // IsDir reports whether de describes a directory. That is,
   740  // it tests for the os.ModeDir bit being set in de.Perm().
   741  func (de DirEntry) IsDir() bool {
   742  	if len(de.Line) == 0 {
   743  		return false
   744  	}
   745  	return de.Line[0] == 'd'
   746  }
   747  
   748  // Digest returns the SHA-1 digest of the file, such as "da39a3ee5e6b4b0d3255bfef95601890afd80709".
   749  // It returns the empty string if the digest isn't included.
   750  func (de DirEntry) Digest() string {
   751  	f := strings.Split(de.Line, "\t")
   752  	if len(f) < 5 {
   753  		return ""
   754  	}
   755  	return f[4]
   756  }
   757  
   758  // ListDirOpts are options for Client.ListDir.
   759  type ListDirOpts struct {
   760  	// Recursive controls whether the directory is listed
   761  	// recursively.
   762  	Recursive bool
   763  
   764  	// Skip are the directories to skip, relative to the directory
   765  	// passed to ListDir. Each item should contain only forward
   766  	// slashes and not start or end in slashes.
   767  	Skip []string
   768  
   769  	// Digest controls whether the SHA-1 digests of regular files
   770  	// are returned.
   771  	Digest bool
   772  }
   773  
   774  // ListDir lists the contents of a directory.
   775  // The fn callback is run for each entry.
   776  // The directory dir itself is not included.
   777  func (c *client) ListDir(ctx context.Context, dir string, opts ListDirOpts, fn func(DirEntry)) error {
   778  	param := url.Values{
   779  		"dir":       {dir},
   780  		"recursive": {fmt.Sprint(opts.Recursive)},
   781  		"skip":      opts.Skip,
   782  		"digest":    {fmt.Sprint(opts.Digest)},
   783  	}
   784  	req, err := http.NewRequest("GET", c.URL()+"/ls?"+param.Encode(), nil)
   785  	if err != nil {
   786  		return err
   787  	}
   788  	resp, err := c.do(req.WithContext(ctx))
   789  	if err != nil {
   790  		return err
   791  	}
   792  	defer resp.Body.Close()
   793  	if resp.StatusCode != http.StatusOK {
   794  		slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
   795  		return fmt.Errorf("%s: %s", resp.Status, slurp)
   796  	}
   797  	sc := bufio.NewScanner(resp.Body)
   798  	for sc.Scan() {
   799  		line := strings.TrimSpace(sc.Text())
   800  		fn(DirEntry{Line: line})
   801  	}
   802  	return sc.Err()
   803  }
   804  
   805  func (c *client) getDialer() func(context.Context) (net.Conn, error) {
   806  	if !c.tls.IsZero() {
   807  		return func(_ context.Context) (net.Conn, error) {
   808  			return c.tls.tlsDialer()("tcp", c.ipPort)
   809  		}
   810  	}
   811  	if c.dialer != nil {
   812  		return c.dialer
   813  	}
   814  	return c.dialWithNetDial
   815  }
   816  
   817  func (c *client) dialWithNetDial(ctx context.Context) (net.Conn, error) {
   818  	var d net.Dialer
   819  	return d.DialContext(ctx, "tcp", c.ipPort)
   820  }
   821  
   822  // ConnectSSH opens an SSH connection to the buildlet for the given username.
   823  // The authorizedPubKey must be a line from an ~/.ssh/authorized_keys file
   824  // and correspond to the private key to be used to communicate over the net.Conn.
   825  func (c *client) ConnectSSH(user, authorizedPubKey string) (net.Conn, error) {
   826  	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
   827  	defer cancel()
   828  	conn, err := c.getDialer()(ctx)
   829  	if err != nil {
   830  		return nil, fmt.Errorf("error dialing HTTP connection before SSH upgrade: %v", err)
   831  	}
   832  	deadline, _ := ctx.Deadline()
   833  	conn.SetDeadline(deadline)
   834  	req, err := http.NewRequest("POST", "/connect-ssh", nil)
   835  	if err != nil {
   836  		conn.Close()
   837  		return nil, err
   838  	}
   839  	req.Header.Add("X-Go-Ssh-User", user)
   840  	req.Header.Add("X-Go-Authorized-Key", authorizedPubKey)
   841  	if !c.tls.IsZero() {
   842  		req.SetBasicAuth(c.authUsername(), c.password)
   843  	}
   844  	if err := req.Write(conn); err != nil {
   845  		conn.Close()
   846  		return nil, fmt.Errorf("writing /connect-ssh HTTP request failed: %v", err)
   847  	}
   848  	bufr := bufio.NewReader(conn)
   849  	res, err := http.ReadResponse(bufr, req)
   850  	if err != nil {
   851  		conn.Close()
   852  		return nil, fmt.Errorf("reading /connect-ssh response: %v", err)
   853  	}
   854  	if res.StatusCode != http.StatusSwitchingProtocols {
   855  		slurp, _ := io.ReadAll(res.Body)
   856  		conn.Close()
   857  		return nil, fmt.Errorf("unexpected /connect-ssh response: %v, %s", res.Status, slurp)
   858  	}
   859  	conn.SetDeadline(time.Time{})
   860  	return conn, nil
   861  }
   862  
   863  func condRun(fn func()) {
   864  	if fn != nil {
   865  		fn()
   866  	}
   867  }
   868  
   869  type onEOFReadCloser struct {
   870  	rc io.ReadCloser
   871  	fn func()
   872  }
   873  
   874  func (o onEOFReadCloser) Read(p []byte) (n int, err error) {
   875  	n, err = o.rc.Read(p)
   876  	if err == io.EOF {
   877  		o.fn()
   878  	}
   879  	return
   880  }
   881  
   882  func (o onEOFReadCloser) Close() error {
   883  	o.fn()
   884  	return o.rc.Close()
   885  }