golang.org/x/build@v0.0.0-20240506185731-218518f32b70/internal/gomote/swarming.go (about) 1 // Copyright 2023 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 //go:build linux || darwin 6 7 package gomote 8 9 import ( 10 "context" 11 "encoding/json" 12 "errors" 13 "fmt" 14 "io" 15 "io/fs" 16 "log" 17 "net/http" 18 "slices" 19 "sort" 20 "strings" 21 "time" 22 23 "cloud.google.com/go/storage" 24 "github.com/google/uuid" 25 buildbucketpb "go.chromium.org/luci/buildbucket/proto" 26 "go.chromium.org/luci/swarming/client/swarming" 27 swarmpb "go.chromium.org/luci/swarming/proto/api_v2" 28 "golang.org/x/build/buildlet" 29 "golang.org/x/build/internal" 30 "golang.org/x/build/internal/access" 31 "golang.org/x/build/internal/coordinator/remote" 32 "golang.org/x/build/internal/gomote/protos" 33 "golang.org/x/build/internal/rendezvous" 34 "golang.org/x/crypto/ssh" 35 "google.golang.org/grpc" 36 "google.golang.org/grpc/codes" 37 "google.golang.org/grpc/status" 38 ) 39 40 // expDisableGolangbuild disables the use of golangbuild during the swarming task gomote bootstrap process. 41 const expDisableGolangbuild = "disable-golang-build" 42 43 type rendezvousClient interface { 44 DeregisterInstance(ctx context.Context, id string) 45 HandleReverse(w http.ResponseWriter, r *http.Request) 46 RegisterInstance(ctx context.Context, id string, wait time.Duration) 47 WaitForInstance(ctx context.Context, id string) (buildlet.Client, error) 48 } 49 50 // BuildersClient is a partial interface of the buildbuicketpb.BuildersClient interface. 51 type BuildersClient interface { 52 GetBuilder(ctx context.Context, in *buildbucketpb.GetBuilderRequest, opts ...grpc.CallOption) (*buildbucketpb.BuilderItem, error) 53 ListBuilders(ctx context.Context, in *buildbucketpb.ListBuildersRequest, opts ...grpc.CallOption) (*buildbucketpb.ListBuildersResponse, error) 54 } 55 56 // SwarmingServer is a gomote server implementation which supports LUCI swarming bots. 57 type SwarmingServer struct { 58 // embed the unimplemented server. 59 protos.UnimplementedGomoteServiceServer 60 61 bucket bucketHandle 62 buildersClient BuildersClient 63 buildlets *remote.SessionPool 64 gceBucketName string 65 rendezvous rendezvousClient 66 sshCertificateAuthority ssh.Signer 67 swarmingClient swarming.Client 68 } 69 70 // NewSwarming creates a gomote server. If the rawCAPriKey is invalid, the program will exit. 71 func NewSwarming(rsp *remote.SessionPool, rawCAPriKey []byte, gomoteGCSBucket string, storageClient *storage.Client, rdv *rendezvous.Rendezvous, swarmClient swarming.Client, buildersClient buildbucketpb.BuildersClient) (*SwarmingServer, error) { 72 signer, err := ssh.ParsePrivateKey(rawCAPriKey) 73 if err != nil { 74 return nil, fmt.Errorf("unable to parse raw certificate authority private key into signer=%w", err) 75 } 76 return &SwarmingServer{ 77 bucket: storageClient.Bucket(gomoteGCSBucket), 78 buildersClient: buildersClient, 79 buildlets: rsp, 80 gceBucketName: gomoteGCSBucket, 81 rendezvous: rdv, 82 sshCertificateAuthority: signer, 83 swarmingClient: swarmClient, 84 }, nil 85 } 86 87 // Authenticate will allow the caller to verify that they are properly authenticated and authorized to interact with the 88 // Service. 89 func (ss *SwarmingServer) Authenticate(ctx context.Context, req *protos.AuthenticateRequest) (*protos.AuthenticateResponse, error) { 90 _, err := access.IAPFromContext(ctx) 91 if err != nil { 92 log.Printf("Authenticate access.IAPFromContext(ctx) = nil, %s", err) 93 return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 94 } 95 return &protos.AuthenticateResponse{}, nil 96 } 97 98 // AddBootstrap adds the bootstrap version of Go to an instance and returns the URL for the bootstrap version. If no 99 // bootstrap version is defined then the returned version URL will be empty. 100 func (ss *SwarmingServer) AddBootstrap(ctx context.Context, req *protos.AddBootstrapRequest) (*protos.AddBootstrapResponse, error) { 101 creds, err := access.IAPFromContext(ctx) 102 if err != nil { 103 log.Printf("AddBootstrap access.IAPFromContext(ctx) = nil, %s", err) 104 return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 105 } 106 ses, bc, err := ss.sessionAndClient(ctx, req.GetGomoteId(), creds.ID) 107 if err != nil { 108 // the helper function returns meaningful GRPC error. 109 return nil, err 110 } 111 bs, err := ss.validBuilders(ctx) 112 if err != nil { 113 return nil, err 114 } 115 builder, ok := bs[ses.BuilderType] 116 if !ok { 117 return nil, status.Errorf(codes.Internal, "unable to determine builder definition") 118 } 119 cp, err := builderProperties(builder) 120 if err != nil { 121 log.Printf("AddBootstrap: bootstrap version not found for %s: %s", builder.GetId().GetBuilder(), err) 122 return &protos.AddBootstrapResponse{}, nil 123 } 124 if cp.BootstrapVersion == "latest" { 125 return &protos.AddBootstrapResponse{}, nil 126 } 127 var cipdPlatform string 128 for _, bd := range builder.GetConfig().GetDimensions() { 129 if !strings.HasPrefix(bd, "cipd_platform:") { 130 continue 131 } 132 var ok bool 133 _, cipdPlatform, ok = strings.Cut(bd, ":") 134 if !ok { 135 return nil, status.Errorf(codes.Internal, "unknown builder type") 136 } 137 break 138 } 139 goos, goarch, err := platformToGoValues(cipdPlatform) 140 if err != nil { 141 return nil, status.Errorf(codes.Internal, "unknown platform type") 142 } 143 url := fmt.Sprintf("https://storage.googleapis.com/go-builder-data/gobootstrap-%s-%s-go%s.tar.gz", goos, goarch, cp.BootstrapVersion) 144 if err = bc.PutTarFromURL(ctx, url, cp.BootstrapVersion); err != nil { 145 return nil, status.Errorf(codes.Internal, "unable to download bootstrap Go") 146 } 147 return &protos.AddBootstrapResponse{BootstrapGoUrl: url}, nil 148 } 149 150 // CreateInstance will create a gomote instance within a swarming task for the authenticated user. 151 func (ss *SwarmingServer) CreateInstance(req *protos.CreateInstanceRequest, stream protos.GomoteService_CreateInstanceServer) error { 152 creds, err := access.IAPFromContext(stream.Context()) 153 if err != nil { 154 log.Printf("CreateInstance access.IAPFromContext(ctx) = nil, %s", err) 155 return status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 156 } 157 if req.GetBuilderType() == "" { 158 return status.Errorf(codes.InvalidArgument, "invalid builder type") 159 } 160 bs, err := ss.validBuilders(stream.Context()) 161 if err != nil { 162 return err 163 } 164 builder, ok := bs[req.GetBuilderType()] 165 if !ok { 166 return status.Errorf(codes.InvalidArgument, "unknown builder type") 167 } 168 userName, err := emailToUser(creds.Email) 169 if err != nil { 170 return status.Errorf(codes.Internal, "invalid user email format") 171 } 172 type result struct { 173 buildletClient buildlet.Client 174 err error 175 } 176 rc := make(chan result, 1) 177 dimensions := make(map[string]string) 178 179 for _, bd := range builder.GetConfig().GetDimensions() { 180 k, v, ok := strings.Cut(bd, ":") 181 if ok { 182 dimensions[k] = v 183 } else { 184 log.Printf("failed dimension cut: %s", bd) 185 } 186 } 187 name := fmt.Sprintf("gomote-%s-%s", userName, uuid.NewString()) 188 cp, err := builderProperties(builder) 189 if err != nil { 190 log.Printf("CreateInstance: builder configuration not found for %s: %s", builder.GetId().GetBuilder(), err) 191 return status.Errorf(codes.Internal, "invalid builder configuration") 192 } 193 useGolangbuild := !slices.Contains(req.GetExperimentOption(), expDisableGolangbuild) 194 go func() { 195 bc, err := ss.startNewSwarmingTask(stream.Context(), name, dimensions, cp, &SwarmOpts{}, useGolangbuild) 196 if err != nil { 197 log.Printf("startNewSwarmingTask() = %s", err) 198 } 199 rc <- result{bc, err} 200 }() 201 ticker := time.NewTicker(5 * time.Second) 202 defer ticker.Stop() 203 for { 204 select { 205 case <-stream.Context().Done(): 206 return status.Errorf(codes.DeadlineExceeded, "timed out waiting for gomote instance to be created") 207 case <-ticker.C: 208 err := stream.Send(&protos.CreateInstanceResponse{ 209 Status: protos.CreateInstanceResponse_WAITING, 210 WaitersAhead: int64(0), // Not convinced querying for pending jobs is useful 211 }) 212 if err != nil { 213 return status.Errorf(codes.Internal, "unable to stream result: %s", err) 214 } 215 case r := <-rc: 216 if r.err != nil { 217 log.Printf("error creating gomote buildlet instance=%s: %s", name, r.err) 218 return status.Errorf(codes.Internal, "gomote creation failed instance=%s", name) 219 } 220 gomoteID := ss.buildlets.AddSession(creds.ID, userName, req.GetBuilderType(), req.GetBuilderType(), r.buildletClient) 221 log.Printf("created buildlet %s for %s (%s)", gomoteID, userName, r.buildletClient.String()) 222 session, err := ss.buildlets.Session(gomoteID) 223 if err != nil { 224 return status.Errorf(codes.Internal, "unable to query for gomote timeout") // this should never happen 225 } 226 wd, err := r.buildletClient.WorkDir(stream.Context()) 227 if err != nil { 228 return status.Errorf(codes.Internal, "could not read working dir: %v", err) 229 } 230 err = stream.Send(&protos.CreateInstanceResponse{ 231 Instance: &protos.Instance{ 232 GomoteId: gomoteID, 233 BuilderType: req.GetBuilderType(), 234 HostType: "swarming task", 235 Expires: session.Expires.Unix(), 236 WorkingDir: wd, 237 }, 238 Status: protos.CreateInstanceResponse_COMPLETE, 239 WaitersAhead: 0, 240 }) 241 if err != nil { 242 return status.Errorf(codes.Internal, "unable to stream result: %s", err) 243 } 244 return nil 245 } 246 } 247 } 248 249 // DestroyInstance will destroy a gomote instance. It will ensure that the caller is authenticated and is the owner of the instance 250 // before it destroys the instance. 251 func (ss *SwarmingServer) DestroyInstance(ctx context.Context, req *protos.DestroyInstanceRequest) (*protos.DestroyInstanceResponse, error) { 252 creds, err := access.IAPFromContext(ctx) 253 if err != nil { 254 log.Printf("DestroyInstance access.IAPFromContext(ctx) = nil, %s", err) 255 return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 256 } 257 if req.GetGomoteId() == "" { 258 return nil, status.Errorf(codes.InvalidArgument, "invalid gomote ID") 259 } 260 _, err = ss.session(req.GetGomoteId(), creds.ID) 261 if err != nil { 262 // the helper function returns a meaningful GRPC error. 263 return nil, err 264 } 265 if err := ss.buildlets.DestroySession(req.GetGomoteId()); err != nil { 266 log.Printf("DestroyInstance remote.DestroySession(%s) = %s", req.GetGomoteId(), err) 267 return nil, status.Errorf(codes.Internal, "unable to destroy gomote instance") 268 } 269 // TODO(go.dev/issue/63819) consider destroying the bot after the task has ended. 270 return &protos.DestroyInstanceResponse{}, nil 271 } 272 273 // ExecuteCommand will execute a command on a gomote instance. The output from the command will be streamed back to the caller if the output is set. 274 func (ss *SwarmingServer) ExecuteCommand(req *protos.ExecuteCommandRequest, stream protos.GomoteService_ExecuteCommandServer) error { 275 creds, err := access.IAPFromContext(stream.Context()) 276 if err != nil { 277 return status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 278 } 279 ses, bc, err := ss.sessionAndClient(stream.Context(), req.GetGomoteId(), creds.ID) 280 if err != nil { 281 // the helper function returns meaningful GRPC error. 282 return err 283 } 284 builderType := req.GetImitateHostType() 285 if builderType == "" { 286 builderType = ses.BuilderType 287 } 288 remoteErr, execErr := bc.Exec(stream.Context(), req.GetCommand(), buildlet.ExecOpts{ 289 Dir: req.GetDirectory(), 290 SystemLevel: req.GetSystemLevel(), 291 Output: &streamWriter{writeFunc: func(p []byte) (int, error) { 292 err := stream.Send(&protos.ExecuteCommandResponse{ 293 Output: p, 294 }) 295 if err != nil { 296 return 0, fmt.Errorf("unable to send data=%w", err) 297 } 298 return len(p), nil 299 }}, 300 Args: req.GetArgs(), 301 ExtraEnv: req.GetAppendEnvironment(), 302 Debug: req.GetDebug(), 303 Path: req.GetPath(), 304 }) 305 if execErr != nil { 306 // there were system errors preventing the command from being started or seen to completion. 307 return status.Errorf(codes.Aborted, "unable to execute command: %s", execErr) 308 } 309 if remoteErr != nil { 310 // the command failed remotely 311 return status.Errorf(codes.Unknown, "command execution failed: %s", remoteErr) 312 } 313 return nil 314 } 315 316 // InstanceAlive will ensure that the gomote instance is still alive and will extend the timeout. The requester must be authenticated. 317 func (ss *SwarmingServer) InstanceAlive(ctx context.Context, req *protos.InstanceAliveRequest) (*protos.InstanceAliveResponse, error) { 318 creds, err := access.IAPFromContext(ctx) 319 if err != nil { 320 log.Printf("InstanceAlive access.IAPFromContext(ctx) = nil, %s", err) 321 return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 322 } 323 if req.GetGomoteId() == "" { 324 return nil, status.Errorf(codes.InvalidArgument, "invalid gomote ID") 325 } 326 _, err = ss.session(req.GetGomoteId(), creds.ID) 327 if err != nil { 328 // the helper function returns meaningful GRPC error. 329 return nil, err 330 } 331 if err := ss.buildlets.RenewTimeout(req.GetGomoteId()); err != nil { 332 return nil, status.Errorf(codes.Internal, "unable to renew timeout") 333 } 334 return &protos.InstanceAliveResponse{}, nil 335 } 336 337 // ListDirectory lists the contents of the directory on a gomote instance. 338 func (ss *SwarmingServer) ListDirectory(ctx context.Context, req *protos.ListDirectoryRequest) (*protos.ListDirectoryResponse, error) { 339 creds, err := access.IAPFromContext(ctx) 340 if err != nil { 341 return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 342 } 343 if req.GetGomoteId() == "" || req.GetDirectory() == "" { 344 return nil, status.Errorf(codes.InvalidArgument, "invalid arguments") 345 } 346 _, bc, err := ss.sessionAndClient(ctx, req.GetGomoteId(), creds.ID) 347 if err != nil { 348 // the helper function returns meaningful GRPC error. 349 return nil, err 350 } 351 opt := buildlet.ListDirOpts{ 352 Recursive: req.GetRecursive(), 353 Digest: req.GetDigest(), 354 Skip: req.GetSkipFiles(), 355 } 356 var entries []string 357 if err = bc.ListDir(context.Background(), req.GetDirectory(), opt, func(bi buildlet.DirEntry) { 358 entries = append(entries, bi.String()) 359 }); err != nil { 360 return nil, status.Errorf(codes.Unimplemented, "method ListDirectory not implemented") 361 } 362 return &protos.ListDirectoryResponse{ 363 Entries: entries, 364 }, nil 365 } 366 367 const ( 368 // golangbuildModeAll is golangbuild's MODE_ALL mode that 369 // builds and tests the project all within the same build. 370 // 371 // See https://source.chromium.org/chromium/infra/infra/+/main:go/src/infra/experimental/golangbuild/golangbuildpb/params.proto;l=148-149;drc=4e874bfb4ff7ff0620940712983ca82e8ea81028. 372 golangbuildModeAll = 0 373 // golangbuildPerfMode is golangbuild's MODE_PERF that 374 // runs performance tests. 375 // 376 // See https://source.chromium.org/chromium/infra/infra/+/main:go/src/infra/experimental/golangbuild/golangbuildpb/params.proto;l=174-177;drc=fdea4abccf8447808d4e702c8d09fdd20fd81acb. 377 golangbuildPerfMode = 4 378 ) 379 380 func (ss *SwarmingServer) validBuilders(ctx context.Context) (map[string]*buildbucketpb.BuilderItem, error) { 381 listBuilders := func(bucket string) ([]*buildbucketpb.BuilderItem, error) { 382 var builders []*buildbucketpb.BuilderItem 383 var nextToken string 384 for { 385 buildersResp, err := ss.buildersClient.ListBuilders(ctx, &buildbucketpb.ListBuildersRequest{ 386 Project: "golang", 387 Bucket: bucket, 388 PageSize: 1000, 389 PageToken: nextToken, 390 }) 391 if err != nil { 392 return nil, err 393 } 394 builders = append(builders, buildersResp.GetBuilders()...) 395 if tok := buildersResp.GetNextPageToken(); tok != "" { 396 nextToken = tok 397 continue 398 } 399 return builders, nil 400 } 401 } 402 // list all the valid builders in ci-workers 403 builderBucket := "ci-workers" 404 builderResponse, err := listBuilders(builderBucket) 405 if err != nil { 406 log.Printf("buildersClient.ListBuilders(ctx, %s) = nil, %s", builderBucket, err) 407 return nil, status.Errorf(codes.Internal, "unable to query for builders") 408 } 409 builders := make(map[string]*buildbucketpb.BuilderItem) 410 for _, builder := range builderResponse { 411 bID := builder.GetId() 412 if bID == nil { 413 continue 414 } 415 name := bID.GetBuilder() 416 if !strings.HasPrefix(name, "go") { 417 continue 418 } 419 if !strings.HasSuffix(name, "-test_only") { 420 continue 421 } 422 builders[strings.TrimSuffix(name, "-test_only")] = builder 423 } 424 // list all the valid builders in ci 425 builderBucket = "ci" 426 builderResponse, err = listBuilders(builderBucket) 427 if err != nil { 428 log.Printf("buildersClient.ListBuilders(ctx, %s) = nil, %s", builderBucket, err) 429 return nil, status.Errorf(codes.Internal, "unable to query for builders") 430 } 431 for _, builder := range builderResponse { 432 bID := builder.GetId() 433 if bID == nil { 434 continue 435 } 436 name := bID.GetBuilder() 437 if !strings.HasPrefix(name, "go") { 438 continue 439 } 440 if _, ok := builders[name]; ok { 441 // should not happen 442 continue 443 } 444 config, err := builderProperties(builder) 445 if err != nil || !slices.Contains([]int{golangbuildModeAll, golangbuildPerfMode}, config.Mode) { 446 continue 447 } 448 builders[name] = builder 449 } 450 return builders, nil 451 } 452 453 // ListSwarmingBuilders lists all of the swarming builders which run for the Go master or release branches. The requester must be authenticated. 454 func (ss *SwarmingServer) ListSwarmingBuilders(ctx context.Context, req *protos.ListSwarmingBuildersRequest) (*protos.ListSwarmingBuildersResponse, error) { 455 _, err := access.IAPFromContext(ctx) 456 if err != nil { 457 log.Printf("ListSwarmingInstances access.IAPFromContext(ctx) = nil, %s", err) 458 return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 459 } 460 bs, err := ss.validBuilders(ctx) 461 if err != nil { 462 return nil, err 463 } 464 var builders []string 465 for builder, _ := range bs { 466 builders = append(builders, builder) 467 } 468 sort.Strings(builders) 469 return &protos.ListSwarmingBuildersResponse{Builders: builders}, nil 470 } 471 472 // ListInstances will list the gomote instances owned by the requester. The requester must be authenticated. 473 func (ss *SwarmingServer) ListInstances(ctx context.Context, req *protos.ListInstancesRequest) (*protos.ListInstancesResponse, error) { 474 creds, err := access.IAPFromContext(ctx) 475 if err != nil { 476 log.Printf("ListInstances access.IAPFromContext(ctx) = nil, %s", err) 477 return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 478 } 479 res := &protos.ListInstancesResponse{} 480 for _, s := range ss.buildlets.List() { 481 if s.OwnerID != creds.ID { 482 continue 483 } 484 res.Instances = append(res.Instances, &protos.Instance{ 485 GomoteId: s.ID, 486 BuilderType: s.BuilderType, 487 HostType: s.HostType, 488 Expires: s.Expires.Unix(), 489 }) 490 } 491 return res, nil 492 } 493 494 // ReadTGZToURL retrieves a directory from the gomote instance and writes the file to GCS. It returns a signed URL which the caller uses 495 // to read the file from GCS. 496 func (ss *SwarmingServer) ReadTGZToURL(ctx context.Context, req *protos.ReadTGZToURLRequest) (*protos.ReadTGZToURLResponse, error) { 497 creds, err := access.IAPFromContext(ctx) 498 if err != nil { 499 return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 500 } 501 _, bc, err := ss.sessionAndClient(ctx, req.GetGomoteId(), creds.ID) 502 if err != nil { 503 // the helper function returns meaningful GRPC error. 504 return nil, err 505 } 506 tgz, err := bc.GetTar(ctx, req.GetDirectory()) 507 if err != nil { 508 return nil, status.Errorf(codes.Aborted, "unable to retrieve tar from gomote instance: %s", err) 509 } 510 defer tgz.Close() 511 objectName := uuid.NewString() 512 objectHandle := ss.bucket.Object(objectName) 513 // A context for writes is used to ensure we can cancel the context if a 514 // problem is encountered while writing to the object store. The API documentation 515 // states that the context should be canceled to stop writing without saving the data. 516 writeCtx, cancel := context.WithCancel(ctx) 517 tgzWriter := objectHandle.NewWriter(writeCtx) 518 defer cancel() 519 if _, err = io.Copy(tgzWriter, tgz); err != nil { 520 return nil, status.Errorf(codes.Aborted, "unable to stream tar.gz: %s", err) 521 } 522 // when close is called, the object is stored in the bucket. 523 if err := tgzWriter.Close(); err != nil { 524 return nil, status.Errorf(codes.Aborted, "unable to store object: %s", err) 525 } 526 url, err := ss.signURLForDownload(objectName) 527 if err != nil { 528 return nil, status.Errorf(codes.Internal, "unable to create signed URL for download: %s", err) 529 } 530 return &protos.ReadTGZToURLResponse{ 531 Url: url, 532 }, nil 533 } 534 535 // RemoveFiles removes files or directories from the gomote instance. 536 func (ss *SwarmingServer) RemoveFiles(ctx context.Context, req *protos.RemoveFilesRequest) (*protos.RemoveFilesResponse, error) { 537 creds, err := access.IAPFromContext(ctx) 538 if err != nil { 539 log.Printf("RemoveFiles access.IAPFromContext(ctx) = nil, %s", err) 540 return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 541 } 542 // TODO(go.dev/issue/48742) consider what additional path validation should be implemented. 543 if req.GetGomoteId() == "" || len(req.GetPaths()) == 0 { 544 return nil, status.Errorf(codes.InvalidArgument, "invalid arguments") 545 } 546 _, bc, err := ss.sessionAndClient(ctx, req.GetGomoteId(), creds.ID) 547 if err != nil { 548 // the helper function returns meaningful GRPC error. 549 return nil, err 550 } 551 if err := bc.RemoveAll(ctx, req.GetPaths()...); err != nil { 552 log.Printf("RemoveFiles buildletClient.RemoveAll(ctx, %q) = %s", req.GetPaths(), err) 553 return nil, status.Errorf(codes.Unknown, "unable to remove files") 554 } 555 return &protos.RemoveFilesResponse{}, nil 556 } 557 558 // SignSSHKey signs the public SSH key with a certificate. The signed public SSH key is intended for use with the gomote service SSH 559 // server. It will be signed by the certificate authority of the server and will restrict access to the gomote instance that it was 560 // signed for. 561 func (ss *SwarmingServer) SignSSHKey(ctx context.Context, req *protos.SignSSHKeyRequest) (*protos.SignSSHKeyResponse, error) { 562 creds, err := access.IAPFromContext(ctx) 563 if err != nil { 564 return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 565 } 566 session, err := ss.session(req.GetGomoteId(), creds.ID) 567 if err != nil { 568 // the helper function returns meaningful GRPC error. 569 return nil, err 570 } 571 signedPublicKey, err := remote.SignPublicSSHKey(ctx, ss.sshCertificateAuthority, req.GetPublicSshKey(), session.ID, session.OwnerID, 5*time.Minute) 572 if err != nil { 573 return nil, status.Errorf(codes.InvalidArgument, "unable to sign ssh key") 574 } 575 return &protos.SignSSHKeyResponse{ 576 SignedPublicSshKey: signedPublicKey, 577 }, nil 578 } 579 580 // UploadFile creates a URL and a set of HTTP post fields which are used to upload a file to a staging GCS bucket. Uploaded files are made available to the 581 // gomote instances via a subsequent call to one of the WriteFromURL endpoints. 582 func (ss *SwarmingServer) UploadFile(ctx context.Context, req *protos.UploadFileRequest) (*protos.UploadFileResponse, error) { 583 _, err := access.IAPFromContext(ctx) 584 if err != nil { 585 return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 586 } 587 objectName := uuid.NewString() 588 url, fields, err := ss.signURLForUpload(objectName) 589 if err != nil { 590 log.Printf("unable to create signed URL: %s", err) 591 return nil, status.Errorf(codes.Internal, "unable to create signed url") 592 } 593 return &protos.UploadFileResponse{ 594 Url: url, 595 Fields: fields, 596 ObjectName: objectName, 597 }, nil 598 } 599 600 // signURLForUpload generates a signed URL and a set of http Post fields to be used to upload an object to GCS without authenticating. 601 func (ss *SwarmingServer) signURLForUpload(object string) (url string, fields map[string]string, err error) { 602 if object == "" { 603 return "", nil, errors.New("invalid object name") 604 } 605 pv4, err := ss.bucket.GenerateSignedPostPolicyV4(object, &storage.PostPolicyV4Options{ 606 Expires: time.Now().Add(10 * time.Minute), 607 Insecure: false, 608 }) 609 if err != nil { 610 return "", nil, fmt.Errorf("unable to generate signed url: %w", err) 611 } 612 return pv4.URL, pv4.Fields, nil 613 } 614 615 // WriteFileFromURL initiates an HTTP request to the passed in URL and streams the contents of the request to the gomote instance. 616 func (ss *SwarmingServer) WriteFileFromURL(ctx context.Context, req *protos.WriteFileFromURLRequest) (*protos.WriteFileFromURLResponse, error) { 617 creds, err := access.IAPFromContext(ctx) 618 if err != nil { 619 log.Printf("WriteTGZFromURL access.IAPFromContext(ctx) = nil, %s", err) 620 return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 621 } 622 _, bc, err := ss.sessionAndClient(ctx, req.GetGomoteId(), creds.ID) 623 if err != nil { 624 // the helper function returns meaningful GRPC error. 625 return nil, err 626 } 627 var rc io.ReadCloser 628 // objects stored in the gomote staging bucket are only accessible when you have been granted explicit permissions. A builder 629 // requires a signed URL in order to access objects stored in the gomote staging bucket. 630 if onObjectStore(ss.gceBucketName, req.GetUrl()) { 631 object, err := objectFromURL(ss.gceBucketName, req.GetUrl()) 632 if err != nil { 633 return nil, status.Errorf(codes.InvalidArgument, "invalid object URL") 634 } 635 rc, err = ss.bucket.Object(object).NewReader(ctx) 636 if err != nil { 637 return nil, status.Errorf(codes.Internal, "unable to create object reader: %s", err) 638 } 639 } else { 640 httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, req.GetUrl(), nil) 641 if err != nil { 642 log.Printf("gomote: unable to create HTTP request: %s", err) 643 return nil, status.Errorf(codes.Internal, "unable to create HTTP request") 644 } 645 // TODO(amedee) find sane client defaults, possibly rely on context timeout in request. 646 client := &http.Client{ 647 Timeout: 30 * time.Second, 648 Transport: &http.Transport{ 649 TLSHandshakeTimeout: 5 * time.Second, 650 }, 651 } 652 resp, err := client.Do(httpRequest) 653 if err != nil { 654 return nil, status.Errorf(codes.Aborted, "failed to get file from URL: %s", err) 655 } 656 if resp.StatusCode != http.StatusOK { 657 return nil, status.Errorf(codes.Aborted, "unable to get file from %q: response code: %d", req.GetUrl(), resp.StatusCode) 658 } 659 rc = resp.Body 660 } 661 defer rc.Close() 662 if err := bc.Put(ctx, rc, req.GetFilename(), fs.FileMode(req.GetMode())); err != nil { 663 return nil, status.Errorf(codes.Aborted, "failed to send the file to the gomote instance: %s", err) 664 } 665 return &protos.WriteFileFromURLResponse{}, nil 666 } 667 668 // WriteTGZFromURL will instruct the gomote instance to download the tar.gz from the provided URL. The tar.gz file will be unpacked in the work directory 669 // relative to the directory provided. 670 func (ss *SwarmingServer) WriteTGZFromURL(ctx context.Context, req *protos.WriteTGZFromURLRequest) (*protos.WriteTGZFromURLResponse, error) { 671 creds, err := access.IAPFromContext(ctx) 672 if err != nil { 673 log.Printf("WriteTGZFromURL access.IAPFromContext(ctx) = nil, %s", err) 674 return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication") 675 } 676 if req.GetGomoteId() == "" { 677 return nil, status.Errorf(codes.InvalidArgument, "invalid gomote ID") 678 } 679 if req.GetUrl() == "" { 680 return nil, status.Errorf(codes.InvalidArgument, "missing URL") 681 } 682 _, bc, err := ss.sessionAndClient(ctx, req.GetGomoteId(), creds.ID) 683 if err != nil { 684 // the helper function returns meaningful GRPC error. 685 return nil, err 686 } 687 url := req.GetUrl() 688 if onObjectStore(ss.gceBucketName, url) { 689 object, err := objectFromURL(ss.gceBucketName, url) 690 if err != nil { 691 return nil, status.Errorf(codes.InvalidArgument, "invalid URL") 692 } 693 url, err = ss.signURLForDownload(object) 694 if err != nil { 695 return nil, status.Errorf(codes.Aborted, "unable to sign url for download: %s", err) 696 } 697 } 698 if err := bc.PutTarFromURL(ctx, url, req.GetDirectory()); err != nil { 699 return nil, status.Errorf(codes.FailedPrecondition, "unable to write tar.gz: %s", err) 700 } 701 return &protos.WriteTGZFromURLResponse{}, nil 702 } 703 704 // session is a helper function that retrieves a session associated with the gomoteID and ownerID. 705 func (ss *SwarmingServer) session(gomoteID, ownerID string) (*remote.Session, error) { 706 session, err := ss.buildlets.Session(gomoteID) 707 if err != nil { 708 return nil, status.Errorf(codes.NotFound, "specified gomote instance does not exist") 709 } 710 if session.OwnerID != ownerID { 711 return nil, status.Errorf(codes.PermissionDenied, "not allowed to modify this gomote session") 712 } 713 return session, nil 714 } 715 716 // sessionAndClient is a helper function that retrieves a session and buildlet client for the 717 // associated gomoteID and ownerID. The gomote instance timeout is renewed if the gomote id and owner id 718 // are valid. 719 func (ss *SwarmingServer) sessionAndClient(ctx context.Context, gomoteID, ownerID string) (*remote.Session, buildlet.Client, error) { 720 session, err := ss.session(gomoteID, ownerID) 721 if err != nil { 722 return nil, nil, err 723 } 724 bc, err := ss.buildlets.BuildletClient(gomoteID) 725 if err != nil { 726 return nil, nil, status.Errorf(codes.NotFound, "specified gomote instance does not exist") 727 } 728 if err := ss.buildlets.KeepAlive(ctx, gomoteID); err != nil { 729 log.Printf("gomote: unable to keep alive %s: %s", gomoteID, err) 730 } 731 return session, bc, nil 732 } 733 734 // signURLForDownload generates a signed URL and fields to be used to upload an object to GCS without authenticating. 735 func (ss *SwarmingServer) signURLForDownload(object string) (url string, err error) { 736 url, err = ss.bucket.SignedURL(object, &storage.SignedURLOptions{ 737 Expires: time.Now().Add(10 * time.Minute), 738 Method: http.MethodGet, 739 Scheme: storage.SigningSchemeV4, 740 }) 741 if err != nil { 742 return "", fmt.Errorf("unable to generate signed url: %w", err) 743 } 744 return url, err 745 } 746 747 // SwarmOpts provides additional options for swarming task creation. 748 type SwarmOpts struct { 749 // OnInstanceRequested optionally specifies a hook to run synchronously 750 // after the computeService.Instances.Insert call, but before 751 // waiting for its operation to proceed. 752 OnInstanceRequested func() 753 754 // OnInstanceCreated optionally specifies a hook to run synchronously 755 // after the instance operation succeeds. 756 OnInstanceCreated func() 757 758 // OnInstanceRegistration optionally specifies a hook to run synchronously 759 // after the instance has been registered in rendezvous. 760 OnInstanceRegistration func() 761 } 762 763 // startNewSwarmingTask starts a new swarming task on a bot with the buildlet 764 // running on it. It returns a buildlet client configured to speak to it. 765 // The request will last as long as the lifetime of the context. The dimensions 766 // are a set of key value pairs used to describe what instance type to create. 767 func (ss *SwarmingServer) startNewSwarmingTask(ctx context.Context, name string, dimensions map[string]string, properties *configProperties, opts *SwarmOpts, useGolangbuild bool) (buildlet.Client, error) { 768 ss.rendezvous.RegisterInstance(ctx, name, 10*time.Minute) 769 condRun(opts.OnInstanceRegistration) 770 771 taskID, err := ss.newSwarmingTask(ctx, name, dimensions, properties, opts, useGolangbuild) 772 if err != nil { 773 ss.rendezvous.DeregisterInstance(ctx, name) 774 return nil, err 775 } 776 log.Printf("gomote: swarming task requested name=%s taskID=%s", name, taskID) 777 condRun(opts.OnInstanceRequested) 778 779 queryCtx, cancel := context.WithTimeout(ctx, 20*time.Minute) 780 defer cancel() 781 var taskUp bool 782 tryThenPeriodicallyDo(queryCtx, 5*time.Second, func(ctx context.Context, _ time.Time) { 783 resp, err := ss.swarmingClient.TaskResult(ctx, taskID, &swarming.TaskResultFields{WithPerf: false}) 784 if err != nil { 785 log.Printf("gomote: unable to query for swarming task state: name=%s taskID=%s %s", name, taskID, err) 786 return 787 } 788 switch taskState := resp.GetState(); taskState { 789 case swarmpb.TaskState_COMPLETED, swarmpb.TaskState_RUNNING: 790 taskUp = true 791 cancel() 792 case swarmpb.TaskState_EXPIRED, swarmpb.TaskState_INVALID, swarmpb.TaskState_BOT_DIED, swarmpb.TaskState_CANCELED, 793 swarmpb.TaskState_CLIENT_ERROR, swarmpb.TaskState_KILLED, swarmpb.TaskState_NO_RESOURCE, swarmpb.TaskState_TIMED_OUT: 794 log.Printf("gomote: swarming task creation failed name=%s state=%s", name, taskState) 795 cancel() 796 case swarmpb.TaskState_PENDING: 797 // continue waiting 798 default: 799 log.Printf("gomote: unexpected swarming task state for %s: %s", name, taskState) 800 } 801 }) 802 if !taskUp { 803 ss.rendezvous.DeregisterInstance(ctx, name) 804 return nil, fmt.Errorf("unable to create swarming task name=%s taskID=%s", name, taskID) 805 } 806 condRun(opts.OnInstanceCreated) 807 808 bc, err := ss.waitForInstanceOrFailure(ctx, taskID, name) 809 if err != nil { 810 ss.rendezvous.DeregisterInstance(ctx, name) 811 return nil, err 812 } 813 return bc, nil 814 } 815 816 // waitForInstanceOrFailure waits for either the swarming task to enter a failed state or the successful connection from 817 // a buildlet client. 818 func (ss *SwarmingServer) waitForInstanceOrFailure(ctx context.Context, taskID, name string) (buildlet.Client, error) { 819 queryCtx, cancel := context.WithTimeout(ctx, 25*time.Minute) 820 821 checkForTaskFailure := func(pollCtx context.Context) <-chan error { 822 errCh := make(chan error, 1) 823 go func() { 824 internal.PeriodicallyDo(pollCtx, 10*time.Second, func(ctx context.Context, _ time.Time) { 825 resp, err := ss.swarmingClient.TaskResult(ctx, taskID, &swarming.TaskResultFields{WithPerf: false}) 826 if err != nil { 827 log.Printf("gomote: unable to query for swarming task state: name=%s taskID=%s %s", name, taskID, err) 828 return 829 } 830 switch taskState := resp.GetState(); taskState { 831 case swarmpb.TaskState_RUNNING: 832 // expected 833 case swarmpb.TaskState_EXPIRED, swarmpb.TaskState_INVALID, swarmpb.TaskState_BOT_DIED, swarmpb.TaskState_CANCELED, 834 swarmpb.TaskState_CLIENT_ERROR, swarmpb.TaskState_KILLED, swarmpb.TaskState_NO_RESOURCE, swarmpb.TaskState_TIMED_OUT, swarmpb.TaskState_COMPLETED: 835 errCh <- fmt.Errorf("swarming task creation failed name=%s state=%s", name, taskState) 836 default: 837 log.Printf("gomote: unexpected swarming task state for %s: %s", name, taskState) 838 } 839 }) 840 }() 841 return errCh 842 } 843 844 type result struct { 845 err error 846 bc buildlet.Client 847 } 848 849 getConn := func(waitCtx context.Context) <-chan *result { 850 ch := make(chan *result, 1) 851 go func() { 852 bc, err := ss.rendezvous.WaitForInstance(waitCtx, name) 853 if err != nil { 854 ss.rendezvous.DeregisterInstance(ctx, name) 855 } 856 ch <- &result{err: err, bc: bc} 857 }() 858 return ch 859 } 860 861 statusChan := checkForTaskFailure(queryCtx) 862 resChan := getConn(queryCtx) 863 864 select { 865 case err := <-statusChan: 866 cancel() 867 ss.rendezvous.DeregisterInstance(ctx, name) 868 log.Printf("gomote: failed waiting for task to run %q: %s", name, err) 869 return nil, err 870 case r := <-resChan: 871 cancel() 872 if r.err != nil { 873 log.Printf("gomote: failed to establish connection %q: %s", name, r.err) 874 return nil, r.err 875 } 876 return r.bc, r.err 877 } 878 } 879 880 func buildletStartup(goos, goarch string) string { 881 cmd := `import urllib.request 882 import sys 883 import platform 884 import subprocess 885 import os 886 import stat 887 888 def add_os_file_ext(filename): 889 if sys.platform == "win32": 890 return filename+".exe" 891 return filename 892 893 def sep(): 894 if sys.platform == "win32": 895 return "\\" 896 else: 897 return "/" 898 899 def delete_if_exists(file_path): 900 if os.path.exists(file_path): 901 os.remove(file_path) 902 903 def make_executable(file_path): 904 if sys.platform != "win32": 905 st = os.stat(file_path) 906 os.chmod(file_path, st.st_mode | stat.S_IEXEC) 907 908 if __name__ == "__main__": 909 buildlet_name = add_os_file_ext("buildlet") 910 delete_if_exists(buildlet_name) 911 urllib.request.urlretrieve("https://storage.googleapis.com/go-builder-data/buildlet.%s-%s", buildlet_name) 912 make_executable(os.getcwd() + sep() + buildlet_name) 913 buildlet_name = "."+sep()+buildlet_name 914 subprocess.run([buildlet_name, "--coordinator=gomotessh.golang.org:443", "--reverse-type=swarming-task", "-swarming-bot", "-halt=false"], shell=False, env=os.environ.copy()) 915 ` 916 return fmt.Sprintf(cmd, goos, goarch) 917 } 918 919 func createStringPairs(m map[string]string) []*swarmpb.StringPair { 920 dims := make([]*swarmpb.StringPair, 0, len(m)) 921 for k, v := range m { 922 dims = append(dims, &swarmpb.StringPair{ 923 Key: k, 924 Value: v, 925 }) 926 } 927 return dims 928 } 929 930 func platformToGoValues(platform string) (goos string, goarch string, err error) { 931 goos, goarch, ok := strings.Cut(platform, "-") 932 if !ok { 933 return "", "", fmt.Errorf("cipd_platform not in proper format=%s", platform) 934 } 935 if goos == "Mac" || goos == "mac" { 936 goos = "darwin" 937 } 938 if goarch == "armv6l" { 939 goarch = "arm" 940 } 941 return goos, goarch, nil 942 } 943 944 func (ss *SwarmingServer) newSwarmingTask(ctx context.Context, name string, dimensions map[string]string, properties *configProperties, opts *SwarmOpts, useGolangbuild bool) (string, error) { 945 if useGolangbuild { 946 947 return ss.newSwarmingTaskWithGolangbuild(ctx, name, dimensions, properties, opts) 948 } 949 cipdPlatform, ok := dimensions["cipd_platform"] 950 if !ok { 951 return "", fmt.Errorf("dimensions require cipd_platform: instance=%s", name) 952 } 953 goos, goarch, err := platformToGoValues(cipdPlatform) 954 if err != nil { 955 return "", err 956 } 957 packages := []*swarmpb.CipdPackage{ 958 {Path: "tools/bin", PackageName: "infra/tools/luci-auth/" + cipdPlatform, Version: "latest"}, 959 {Path: "tools/bootstrap-go", PackageName: "golang/bootstrap-go/" + cipdPlatform, Version: properties.BootstrapVersion}, 960 } 961 pythonBin := "python3" 962 switch goos { 963 case "darwin": 964 pythonBin = `tools/bin/python3` 965 packages = append(packages, 966 &swarmpb.CipdPackage{Path: "tools/bin", PackageName: "infra/tools/mac_toolchain/" + cipdPlatform, Version: "latest"}, 967 &swarmpb.CipdPackage{Path: "tools", PackageName: "infra/3pp/tools/cpython3/" + cipdPlatform, Version: "latest"}) 968 case "windows": 969 pythonBin = `tools\bin\python3.exe` 970 packages = append(packages, &swarmpb.CipdPackage{Path: "tools", PackageName: "infra/3pp/tools/cpython3/" + cipdPlatform, Version: "latest"}) 971 } 972 973 req := &swarmpb.NewTaskRequest{ 974 Name: name, 975 Priority: 20, // 30 is the priority for builds 976 ServiceAccount: "coordinator-builder@golang-ci-luci.iam.gserviceaccount.com", 977 TaskSlices: []*swarmpb.TaskSlice{ 978 &swarmpb.TaskSlice{ 979 Properties: &swarmpb.TaskProperties{ 980 CipdInput: &swarmpb.CipdInput{ 981 Packages: packages, 982 }, 983 EnvPrefixes: []*swarmpb.StringListPair{ 984 {Key: "PATH", Value: []string{"tools/bin", "go/bin"}}, 985 {Key: "GOROOT_BOOTSTRAP", Value: []string{"tools/bootstrap-go"}}, 986 {Key: "GOPATH", Value: []string{"gopath"}}, 987 }, 988 Command: []string{pythonBin, "-c", buildletStartup(goos, goarch)}, 989 Dimensions: createStringPairs(dimensions), 990 Env: []*swarmpb.StringPair{ 991 &swarmpb.StringPair{ 992 Key: "GOMOTEID", 993 Value: name, 994 }, 995 }, 996 ExecutionTimeoutSecs: 86400, 997 }, 998 ExpirationSecs: 86400, 999 WaitForCapacity: false, 1000 }, 1001 }, 1002 Tags: []string{"golang_mode:gomote"}, 1003 Realm: "golang:ci", 1004 } 1005 taskMD, err := ss.swarmingClient.NewTask(ctx, req) 1006 if err != nil { 1007 log.Printf("gomote: swarming task creation failed name=%s: %s", name, err) 1008 return "", fmt.Errorf("unable to start task: %w", err) 1009 } 1010 log.Printf("gomote: task created: id=%s https://chromium-swarm.appspot.com/task?id=%s", taskMD.TaskId, taskMD.TaskId) 1011 return taskMD.TaskId, nil 1012 } 1013 1014 func golangbuildStartup(goos, goarch, golangbuildBin string) string { 1015 cmd := `import urllib.request 1016 import sys 1017 import platform 1018 import subprocess 1019 import os 1020 import stat 1021 1022 def make_executable(file_path): 1023 if sys.platform != "win32": 1024 st = os.stat(file_path) 1025 os.chmod(file_path, st.st_mode | stat.S_IEXEC) 1026 1027 if __name__ == "__main__": 1028 ext = "" 1029 if sys.platform == "win32": 1030 ext = ".exe" 1031 sep = "/" 1032 if sys.platform == "win32": 1033 sep = "\\\\" 1034 buildlet_file = "buildlet" + ext 1035 buildlet_path = "." + sep + buildlet_file 1036 if os.path.exists(buildlet_path): 1037 os.remove(buildlet_path) 1038 urllib.request.urlretrieve("https://storage.googleapis.com/go-builder-data/buildlet.%s-%s", buildlet_path) 1039 make_executable(os.getcwd() + sep + buildlet_file) 1040 subprocess.run(["%s", buildlet_path, "--coordinator=gomotessh.golang.org:443", "--reverse-type=swarming-task", "-swarming-bot", "-halt=false"], shell=False, env=os.environ.copy()) 1041 ` 1042 return fmt.Sprintf(cmd, goos, goarch, golangbuildBin) 1043 } 1044 1045 func (ss *SwarmingServer) newSwarmingTaskWithGolangbuild(ctx context.Context, name string, dimensions map[string]string, properties *configProperties, opts *SwarmOpts) (string, error) { 1046 log.Printf("gomote: swarming task creation using golangbuild name=%s", name) 1047 cipdPlatform, ok := dimensions["cipd_platform"] 1048 if !ok { 1049 return "", fmt.Errorf("dimensions require cipd_platform: instance=%s", name) 1050 } 1051 goos, goarch, err := platformToGoValues(cipdPlatform) 1052 if err != nil { 1053 return "", err 1054 } 1055 packages := []*swarmpb.CipdPackage{ 1056 {Path: "tools/bin", PackageName: "infra/experimental/golangbuild/" + cipdPlatform, Version: "latest"}, 1057 {Path: "tools/bin", PackageName: "infra/tools/cipd/" + cipdPlatform, Version: "latest"}, 1058 {Path: "tools/bin", PackageName: "infra/tools/luci-auth/" + cipdPlatform, Version: "latest"}, 1059 } 1060 pythonBin := "python3" 1061 golangbuildBin := "golangbuild" 1062 switch goos { 1063 case "darwin": 1064 golangbuildBin = `tools/bin/golangbuild` 1065 pythonBin = `tools/bin/python3` 1066 packages = append(packages, 1067 &swarmpb.CipdPackage{Path: "tools", PackageName: "infra/3pp/tools/cpython3/" + cipdPlatform, Version: "latest"}) 1068 case "windows": 1069 golangbuildBin = `tools\\bin\\golangbuild.exe` 1070 pythonBin = `tools\bin\python3.exe` 1071 packages = append(packages, &swarmpb.CipdPackage{Path: "tools", PackageName: "infra/3pp/tools/cpython3/" + cipdPlatform, Version: "latest"}) 1072 } 1073 1074 req := &swarmpb.NewTaskRequest{ 1075 Name: name, 1076 Priority: 20, // 30 is the priority for builds 1077 ServiceAccount: "coordinator-builder@golang-ci-luci.iam.gserviceaccount.com", 1078 TaskSlices: []*swarmpb.TaskSlice{ 1079 &swarmpb.TaskSlice{ 1080 Properties: &swarmpb.TaskProperties{ 1081 CipdInput: &swarmpb.CipdInput{ 1082 Packages: packages, 1083 }, 1084 EnvPrefixes: []*swarmpb.StringListPair{ 1085 {Key: "PATH", Value: []string{"tools/bin"}}, 1086 }, 1087 Command: []string{pythonBin, "-c", golangbuildStartup(goos, goarch, golangbuildBin)}, 1088 Dimensions: createStringPairs(dimensions), 1089 Env: []*swarmpb.StringPair{ 1090 &swarmpb.StringPair{ 1091 Key: "GOMOTEID", 1092 Value: name, 1093 }, 1094 &swarmpb.StringPair{ 1095 Key: "GOMOTE_SETUP", 1096 Value: properties.BuilderId, 1097 }, 1098 }, 1099 ExecutionTimeoutSecs: 86400, 1100 }, 1101 ExpirationSecs: 86400, 1102 WaitForCapacity: false, 1103 }, 1104 }, 1105 Tags: []string{"golang_mode:gomote"}, 1106 Realm: "golang:ci", 1107 } 1108 taskMD, err := ss.swarmingClient.NewTask(ctx, req) 1109 if err != nil { 1110 log.Printf("gomote: swarming task creation failed name=%s: %s", name, err) 1111 return "", fmt.Errorf("unable to start task: %w", err) 1112 } 1113 log.Printf("gomote: task created: id=%s https://chromium-swarm.appspot.com/task?id=%s", taskMD.TaskId, taskMD.TaskId) 1114 return taskMD.TaskId, nil 1115 } 1116 1117 func condRun(fn func()) { 1118 if fn != nil { 1119 fn() 1120 } 1121 } 1122 1123 // tryThenPeriodicallyDo calls f and then calls f every period until the provided context is cancelled. 1124 func tryThenPeriodicallyDo(ctx context.Context, period time.Duration, f func(context.Context, time.Time)) { 1125 f(ctx, time.Now()) 1126 internal.PeriodicallyDo(ctx, period, f) 1127 } 1128 1129 type configProperties struct { 1130 BootstrapVersion string `json:"bootstrap_version"` 1131 Mode int `json:"mode"` 1132 BuilderId string // <project>/<bucket>/<builder> 1133 } 1134 1135 func builderProperties(builder *buildbucketpb.BuilderItem) (*configProperties, error) { 1136 cp := new(configProperties) 1137 if err := json.Unmarshal([]byte(builder.GetConfig().GetProperties()), cp); err != nil { 1138 return nil, fmt.Errorf("builder property unmarshal error: %s", err) 1139 } 1140 if cp.BootstrapVersion == "" { 1141 cp.BootstrapVersion = "latest" 1142 } 1143 id := builder.GetId() 1144 cp.BuilderId = fmt.Sprintf("%s/%s/%s", id.GetProject(), id.GetBucket(), id.GetBuilder()) 1145 return cp, nil 1146 }