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 }