golang.org/x/build@v0.0.0-20240506185731-218518f32b70/buildlet/remote.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
     6  
     7  import (
     8  	"bufio"
     9  	"bytes"
    10  	"encoding/json"
    11  	"errors"
    12  	"flag"
    13  	"fmt"
    14  	"io"
    15  	"log"
    16  	"net/http"
    17  	"net/url"
    18  	"os"
    19  	"path/filepath"
    20  	"runtime"
    21  	"strings"
    22  	"sync"
    23  	"time"
    24  
    25  	"golang.org/x/build"
    26  	"golang.org/x/build/buildenv"
    27  	"golang.org/x/build/types"
    28  )
    29  
    30  type UserPass struct {
    31  	Username string // "user-$USER"
    32  	Password string // buildlet key
    33  }
    34  
    35  // A CoordinatorClient makes calls to the build coordinator.
    36  type CoordinatorClient struct {
    37  	// Auth specifies how to authenticate to the coordinator.
    38  	Auth UserPass
    39  
    40  	// Instance optionally specifies the build coordinator to connect
    41  	// to. If zero, the production coordinator is used.
    42  	Instance build.CoordinatorInstance
    43  
    44  	mu sync.Mutex
    45  	hc *http.Client
    46  }
    47  
    48  func (cc *CoordinatorClient) instance() build.CoordinatorInstance {
    49  	if cc.Instance == "" {
    50  		return build.ProdCoordinator
    51  	}
    52  	return cc.Instance
    53  }
    54  
    55  func (cc *CoordinatorClient) client() (*http.Client, error) {
    56  	cc.mu.Lock()
    57  	defer cc.mu.Unlock()
    58  	if cc.hc != nil {
    59  		return cc.hc, nil
    60  	}
    61  	cc.hc = &http.Client{
    62  		Transport: &http.Transport{
    63  			Dial:    defaultDialer(),
    64  			DialTLS: cc.instance().TLSDialer(),
    65  		},
    66  	}
    67  	return cc.hc, nil
    68  }
    69  
    70  // CreateBuildlet creates a new buildlet of the given builder type on
    71  // cc.
    72  //
    73  // This takes a builderType (instead of a hostType), but the
    74  // returned buildlet can be used as any builder that has the same
    75  // underlying buildlet type. For instance, a linux-amd64 buildlet can
    76  // act as either linux-amd64 or linux-386-387.
    77  //
    78  // It may expire at any time.
    79  // To release it, call Client.Close.
    80  func (cc *CoordinatorClient) CreateBuildlet(builderType string) (RemoteClient, error) {
    81  	return cc.CreateBuildletWithStatus(builderType, nil)
    82  }
    83  
    84  const (
    85  	// GomoteCreateStreamVersion is the gomote protocol version at which JSON streamed responses started.
    86  	GomoteCreateStreamVersion = "20191119"
    87  
    88  	// GomoteCreateMinVersion is the oldest "gomote create" protocol version that's still supported.
    89  	GomoteCreateMinVersion = "20160922"
    90  )
    91  
    92  // CreateBuildletWithStatus is like CreateBuildlet but accepts an optional status callback.
    93  func (cc *CoordinatorClient) CreateBuildletWithStatus(builderType string, status func(types.BuildletWaitStatus)) (RemoteClient, error) {
    94  	hc, err := cc.client()
    95  	if err != nil {
    96  		return nil, err
    97  	}
    98  	ipPort, _ := cc.instance().TLSHostPort() // must succeed if client did
    99  	form := url.Values{
   100  		"version":     {GomoteCreateStreamVersion}, // checked by cmd/coordinator/remote.go
   101  		"builderType": {builderType},
   102  	}
   103  	req, _ := http.NewRequest("POST",
   104  		"https://"+ipPort+"/buildlet/create",
   105  		strings.NewReader(form.Encode()))
   106  	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   107  	req.SetBasicAuth(cc.Auth.Username, cc.Auth.Password)
   108  	// TODO: accept a context for deadline/cancelation
   109  	res, err := hc.Do(req)
   110  	if err != nil {
   111  		return nil, err
   112  	}
   113  	defer res.Body.Close()
   114  	if res.StatusCode != 200 {
   115  		slurp, _ := io.ReadAll(res.Body)
   116  		return nil, fmt.Errorf("%s: %s", res.Status, slurp)
   117  	}
   118  
   119  	// TODO: delete this once the server's been deployed with it.
   120  	// This code only exists for compatibility for a day or two at most.
   121  	if res.Header.Get("X-Supported-Version") < GomoteCreateStreamVersion {
   122  		var rb RemoteBuildlet
   123  		if err := json.NewDecoder(res.Body).Decode(&rb); err != nil {
   124  			return nil, err
   125  		}
   126  		return cc.NamedBuildlet(rb.Name)
   127  	}
   128  
   129  	type msg struct {
   130  		Error    string                    `json:"error"`
   131  		Buildlet *RemoteBuildlet           `json:"buildlet"`
   132  		Status   *types.BuildletWaitStatus `json:"status"`
   133  	}
   134  	bs := bufio.NewScanner(res.Body)
   135  	for bs.Scan() {
   136  		line := bs.Bytes()
   137  		var m msg
   138  		if err := json.Unmarshal(line, &m); err != nil {
   139  			return nil, err
   140  		}
   141  		if m.Error != "" {
   142  			return nil, errors.New(m.Error)
   143  		}
   144  		if m.Buildlet != nil {
   145  			if m.Buildlet.Name == "" {
   146  				return nil, fmt.Errorf("buildlet: coordinator's /buildlet/create returned an unnamed buildlet")
   147  			}
   148  			return cc.NamedBuildlet(m.Buildlet.Name)
   149  		}
   150  		if m.Status != nil {
   151  			if status != nil {
   152  				status(*m.Status)
   153  			}
   154  			continue
   155  		}
   156  		log.Printf("buildlet: unknown message type from coordinator's /buildlet/create endpoint: %q", line)
   157  		continue
   158  	}
   159  	err = bs.Err()
   160  	if err == nil {
   161  		err = errors.New("buildlet: coordinator's /buildlet/create ended its response stream without a terminal message")
   162  	}
   163  	return nil, err
   164  }
   165  
   166  type RemoteBuildlet struct {
   167  	HostType    string // "host-linux-bullseye"
   168  	BuilderType string // "linux-386-387"
   169  	Name        string // "buildlet-adg-openbsd-386-2"
   170  	Created     time.Time
   171  	Expires     time.Time
   172  }
   173  
   174  func (cc *CoordinatorClient) RemoteBuildlets() ([]RemoteBuildlet, error) {
   175  	hc, err := cc.client()
   176  	if err != nil {
   177  		return nil, err
   178  	}
   179  	ipPort, _ := cc.instance().TLSHostPort() // must succeed if client did
   180  	req, _ := http.NewRequest("GET", "https://"+ipPort+"/buildlet/list", nil)
   181  	req.SetBasicAuth(cc.Auth.Username, cc.Auth.Password)
   182  	res, err := hc.Do(req)
   183  	if err != nil {
   184  		return nil, err
   185  	}
   186  	defer res.Body.Close()
   187  	if res.StatusCode != 200 {
   188  		slurp, _ := io.ReadAll(res.Body)
   189  		return nil, fmt.Errorf("%s: %s", res.Status, slurp)
   190  	}
   191  	var ret []RemoteBuildlet
   192  	if err := json.NewDecoder(res.Body).Decode(&ret); err != nil {
   193  		return nil, err
   194  	}
   195  	return ret, nil
   196  }
   197  
   198  // NamedBuildlet returns a buildlet client for the named remote buildlet.
   199  // Names are not validated. Use Client.Status to check whether the client works.
   200  func (cc *CoordinatorClient) NamedBuildlet(name string) (RemoteClient, error) {
   201  	hc, err := cc.client()
   202  	if err != nil {
   203  		return nil, err
   204  	}
   205  	ipPort, _ := cc.instance().TLSHostPort() // must succeed if client did
   206  	c := &client{
   207  		baseURL:        "https://" + ipPort,
   208  		remoteBuildlet: name,
   209  		httpClient:     hc,
   210  		authUser:       cc.Auth.Username,
   211  		password:       cc.Auth.Password,
   212  	}
   213  	c.setCommon()
   214  	return c, nil
   215  }
   216  
   217  var (
   218  	flagsRegistered bool
   219  	gomoteUserFlag  string
   220  )
   221  
   222  // RegisterFlags registers "user" and "staging" flags that control the
   223  // behavior of NewCoordinatorClientFromFlags. These are used by remote
   224  // client commands like gomote.
   225  func RegisterFlags() {
   226  	if !flagsRegistered {
   227  		buildenv.RegisterFlags()
   228  		flag.StringVar(&gomoteUserFlag, "user", username(), "gomote server username")
   229  		flagsRegistered = true
   230  	}
   231  }
   232  
   233  // username finds the user's username in the environment.
   234  func username() string {
   235  	if runtime.GOOS == "windows" {
   236  		return os.Getenv("USERNAME")
   237  	}
   238  	return os.Getenv("USER")
   239  }
   240  
   241  // configDir finds the OS-dependent config dir.
   242  func configDir() string {
   243  	if runtime.GOOS == "windows" {
   244  		return filepath.Join(os.Getenv("APPDATA"), "Gomote")
   245  	}
   246  	if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
   247  		return filepath.Join(xdg, "gomote")
   248  	}
   249  	return filepath.Join(os.Getenv("HOME"), ".config", "gomote")
   250  }
   251  
   252  // userToken reads the gomote token from the user's home directory.
   253  func userToken() (string, error) {
   254  	if gomoteUserFlag == "" {
   255  		panic("userToken called with user flag empty")
   256  	}
   257  	keyDir := configDir()
   258  	userPath := filepath.Join(keyDir, "user-"+gomoteUserFlag+".user")
   259  	b, err := os.ReadFile(userPath)
   260  	if err == nil {
   261  		gomoteUserFlag = string(bytes.TrimSpace(b))
   262  	}
   263  	baseFile := "user-" + gomoteUserFlag + ".token"
   264  	if buildenv.FromFlags() == buildenv.Staging {
   265  		baseFile = "staging-" + baseFile
   266  	}
   267  	tokenFile := filepath.Join(keyDir, baseFile)
   268  	slurp, err := os.ReadFile(tokenFile)
   269  	if os.IsNotExist(err) {
   270  		return "", fmt.Errorf("Missing file %s for user %q. Change --user or obtain a token and place it there.",
   271  			tokenFile, gomoteUserFlag)
   272  	}
   273  	return strings.TrimSpace(string(slurp)), err
   274  }
   275  
   276  // NewCoordinatorClientFromFlags constructs a CoordinatorClient for the current user.
   277  func NewCoordinatorClientFromFlags() (*CoordinatorClient, error) {
   278  	if !flagsRegistered {
   279  		return nil, errors.New("RegisterFlags not called")
   280  	}
   281  	inst := build.ProdCoordinator
   282  	env := buildenv.FromFlags()
   283  	if env == buildenv.Staging {
   284  		inst = build.StagingCoordinator
   285  	} else if env == buildenv.Development {
   286  		inst = "localhost:8119"
   287  	}
   288  
   289  	if gomoteUserFlag == "" {
   290  		return nil, errors.New("user flag must be specified")
   291  	}
   292  	tok, err := userToken()
   293  	if err != nil {
   294  		return nil, err
   295  	}
   296  	return &CoordinatorClient{
   297  		Auth: UserPass{
   298  			Username: "user-" + gomoteUserFlag,
   299  			Password: tok,
   300  		},
   301  		Instance: inst,
   302  	}, nil
   303  }