// Copyright 2015 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package buildlet import ( "bufio" "bytes" "encoding/json" "errors" "flag" "fmt" "io/ioutil" "log" "net/http" "net/url" "os" "path/filepath" "runtime" "strings" "sync" "time" "golang.org/x/build" "golang.org/x/build/buildenv" "golang.org/x/build/types" ) type UserPass struct { Username string // "user-$USER" Password string // buildlet key } // A CoordinatorClient makes calls to the build coordinator. type CoordinatorClient struct { // Auth specifies how to authenticate to the coordinator. Auth UserPass // Instance optionally specifies the build coordinator to connect // to. If zero, the production coordinator is used. Instance build.CoordinatorInstance mu sync.Mutex hc *http.Client } func (cc *CoordinatorClient) instance() build.CoordinatorInstance { if cc.Instance == "" { return build.ProdCoordinator } return cc.Instance } func (cc *CoordinatorClient) client() (*http.Client, error) { cc.mu.Lock() defer cc.mu.Unlock() if cc.hc != nil { return cc.hc, nil } cc.hc = &http.Client{ Transport: &http.Transport{ Dial: defaultDialer(), DialTLS: cc.instance().TLSDialer(), }, } return cc.hc, nil } // CreateBuildlet creates a new buildlet of the given builder type on // cc. // // This takes a builderType (instead of a hostType), but the // returned buildlet can be used as any builder that has the same // underlying buildlet type. For instance, a linux-amd64 buildlet can // act as either linux-amd64 or linux-386-387. // // It may expire at any time. // To release it, call Client.Close. func (cc *CoordinatorClient) CreateBuildlet(builderType string) (*Client, error) { return cc.CreateBuildletWithStatus(builderType, nil) } const ( // GomoteCreateStreamVersion is the gomote protocol version at which JSON streamed responses started. GomoteCreateStreamVersion = "20191119" // GomoteCreateMinVersion is the oldest "gomote create" protocol version that's still supported. GomoteCreateMinVersion = "20160922" ) // CreateBuildletWithStatus is like CreateBuildlet but accepts an optional status callback. func (cc *CoordinatorClient) CreateBuildletWithStatus(builderType string, status func(types.BuildletWaitStatus)) (*Client, error) { hc, err := cc.client() if err != nil { return nil, err } ipPort, _ := cc.instance().TLSHostPort() // must succeed if client did form := url.Values{ "version": {GomoteCreateStreamVersion}, // checked by cmd/coordinator/remote.go "builderType": {builderType}, } req, _ := http.NewRequest("POST", "https://"+ipPort+"/buildlet/create", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.SetBasicAuth(cc.Auth.Username, cc.Auth.Password) // TODO: accept a context for deadline/cancelation res, err := hc.Do(req) if err != nil { return nil, err } defer res.Body.Close() if res.StatusCode != 200 { slurp, _ := ioutil.ReadAll(res.Body) return nil, fmt.Errorf("%s: %s", res.Status, slurp) } // TODO: delete this once the server's been deployed with it. // This code only exists for compatibility for a day or two at most. if res.Header.Get("X-Supported-Version") < GomoteCreateStreamVersion { var rb RemoteBuildlet if err := json.NewDecoder(res.Body).Decode(&rb); err != nil { return nil, err } return cc.NamedBuildlet(rb.Name) } type msg struct { Error string `json:"error"` Buildlet *RemoteBuildlet `json:"buildlet"` Status *types.BuildletWaitStatus `json:"status"` } bs := bufio.NewScanner(res.Body) for bs.Scan() { line := bs.Bytes() var m msg if err := json.Unmarshal(line, &m); err != nil { return nil, err } if m.Error != "" { return nil, errors.New(m.Error) } if m.Buildlet != nil { if m.Buildlet.Name == "" { return nil, fmt.Errorf("buildlet: coordinator's /buildlet/create returned an unnamed buildlet") } return cc.NamedBuildlet(m.Buildlet.Name) } if m.Status != nil { if status != nil { status(*m.Status) } continue } log.Printf("buildlet: unknown message type from coordinator's /buildlet/create endpoint: %q", line) continue } err = bs.Err() if err == nil { err = errors.New("buildlet: coordinator's /buildlet/create ended its response stream without a terminal message") } return nil, err } type RemoteBuildlet struct { HostType string // "host-linux-jessie" BuilderType string // "linux-386-387" Name string // "buildlet-adg-openbsd-386-2" Created time.Time Expires time.Time } func (cc *CoordinatorClient) RemoteBuildlets() ([]RemoteBuildlet, error) { hc, err := cc.client() if err != nil { return nil, err } ipPort, _ := cc.instance().TLSHostPort() // must succeed if client did req, _ := http.NewRequest("GET", "https://"+ipPort+"/buildlet/list", nil) req.SetBasicAuth(cc.Auth.Username, cc.Auth.Password) res, err := hc.Do(req) if err != nil { return nil, err } defer res.Body.Close() if res.StatusCode != 200 { slurp, _ := ioutil.ReadAll(res.Body) return nil, fmt.Errorf("%s: %s", res.Status, slurp) } var ret []RemoteBuildlet if err := json.NewDecoder(res.Body).Decode(&ret); err != nil { return nil, err } return ret, nil } // NamedBuildlet returns a buildlet client for the named remote buildlet. // Names are not validated. Use Client.Status to check whether the client works. func (cc *CoordinatorClient) NamedBuildlet(name string) (*Client, error) { hc, err := cc.client() if err != nil { return nil, err } ipPort, _ := cc.instance().TLSHostPort() // must succeed if client did c := &Client{ baseURL: "https://" + ipPort, remoteBuildlet: name, httpClient: hc, authUser: cc.Auth.Username, password: cc.Auth.Password, } c.setCommon() return c, nil } var ( flagsRegistered bool gomoteUserFlag string ) // RegisterFlags registers "user" and "staging" flags that control the // behavior of NewCoordinatorClientFromFlags. These are used by remote // client commands like gomote. func RegisterFlags() { if !flagsRegistered { buildenv.RegisterFlags() flag.StringVar(&gomoteUserFlag, "user", username(), "gomote server username") flagsRegistered = true } } // username finds the user's username in the environment. func username() string { if runtime.GOOS == "windows" { return os.Getenv("USERNAME") } return os.Getenv("USER") } // configDir finds the OS-dependent config dir. func configDir() string { if runtime.GOOS == "windows" { return filepath.Join(os.Getenv("APPDATA"), "Gomote") } if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { return filepath.Join(xdg, "gomote") } return filepath.Join(os.Getenv("HOME"), ".config", "gomote") } // userToken reads the gomote token from the user's home directory. func userToken() (string, error) { if gomoteUserFlag == "" { panic("userToken called with user flag empty") } keyDir := configDir() userPath := filepath.Join(keyDir, "user-"+gomoteUserFlag+".user") b, err := ioutil.ReadFile(userPath) if err == nil { gomoteUserFlag = string(bytes.TrimSpace(b)) } baseFile := "user-" + gomoteUserFlag + ".token" if buildenv.FromFlags() == buildenv.Staging { baseFile = "staging-" + baseFile } tokenFile := filepath.Join(keyDir, baseFile) slurp, err := ioutil.ReadFile(tokenFile) if os.IsNotExist(err) { return "", fmt.Errorf("Missing file %s for user %q. Change --user or obtain a token and place it there.", tokenFile, gomoteUserFlag) } return strings.TrimSpace(string(slurp)), err } // NewCoordinatorClientFromFlags constructs a CoordinatorClient for the current user. func NewCoordinatorClientFromFlags() (*CoordinatorClient, error) { if !flagsRegistered { return nil, errors.New("RegisterFlags not called") } inst := build.ProdCoordinator env := buildenv.FromFlags() if env == buildenv.Staging { inst = build.StagingCoordinator } else if env == buildenv.Development { inst = "localhost:8119" } if gomoteUserFlag == "" { return nil, errors.New("user flag must be specified") } tok, err := userToken() if err != nil { return nil, err } return &CoordinatorClient{ Auth: UserPass{ Username: "user-" + gomoteUserFlag, Password: tok, }, Instance: inst, }, nil }