internal/gomote: add add bootstrap endpoint

This change adds the bootstrap endpoint for the swarming
implementation of the gomote GRPC server.

Fixes golang/go#64212

Change-Id: Ib7c488bdf5ab8f6d993b6e95a6237cf12d19ed92
Reviewed-on: https://go-review.googlesource.com/c/build/+/543095
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
Auto-Submit: Carlos Amedee <carlos@golang.org>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/internal/gomote/gomote_test.go b/internal/gomote/gomote_test.go
index ec63003..d84e720 100644
--- a/internal/gomote/gomote_test.go
+++ b/internal/gomote/gomote_test.go
@@ -94,7 +94,7 @@
 	}
 }
 
-func TestAddBootstrapAlive(t *testing.T) {
+func TestAddBootstrap(t *testing.T) {
 	client := setupGomoteTest(t, context.Background())
 	gomoteID := mustCreateInstance(t, client, fakeIAP())
 	req := &protos.AddBootstrapRequest{
diff --git a/internal/gomote/swarming.go b/internal/gomote/swarming.go
index 11f435f..fe27418 100644
--- a/internal/gomote/swarming.go
+++ b/internal/gomote/swarming.go
@@ -8,6 +8,7 @@
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -89,6 +90,58 @@
 	return &protos.AuthenticateResponse{}, nil
 }
 
+// AddBootstrap adds the bootstrap version of Go to an instance and returns the URL for the bootstrap version. If no
+// bootstrap version is defined then the returned version URL will be empty.
+func (ss *SwarmingServer) AddBootstrap(ctx context.Context, req *protos.AddBootstrapRequest) (*protos.AddBootstrapResponse, error) {
+	creds, err := access.IAPFromContext(ctx)
+	if err != nil {
+		log.Printf("AddBootstrap access.IAPFromContext(ctx) = nil, %s", err)
+		return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication")
+	}
+	ses, bc, err := ss.sessionAndClient(ctx, req.GetGomoteId(), creds.ID)
+	if err != nil {
+		// the helper function returns meaningful GRPC error.
+		return nil, err
+	}
+	builder, err := ss.buildersClient.GetBuilder(ctx, &buildbucketpb.GetBuilderRequest{
+		Id: &buildbucketpb.BuilderID{
+			Project: "golang",
+			Bucket:  "ci-workers",
+			Builder: ses.BuilderType + "-test_only",
+		},
+	})
+	if err != nil {
+		return nil, status.Errorf(codes.InvalidArgument, "unknown builder type")
+	}
+	type ConfigProperties struct {
+		BootstrapVersion string `json:"bootstrap_version, omitempty"`
+	}
+	var cp ConfigProperties
+	if err := json.Unmarshal([]byte(builder.GetConfig().GetProperties()), &cp); err != nil {
+		return &protos.AddBootstrapResponse{}, nil
+	}
+	if cp.BootstrapVersion == "" {
+		return &protos.AddBootstrapResponse{}, nil
+	}
+	var cipdPlatform string
+	for _, bd := range builder.GetConfig().GetDimensions() {
+		if !strings.HasPrefix(bd, "cipd_platform:") {
+			continue
+		}
+		var ok bool
+		_, cipdPlatform, ok = strings.Cut(bd, ":")
+		if !ok {
+			return nil, status.Errorf(codes.Internal, "unknown builder type")
+		}
+		break
+	}
+	url := fmt.Sprintf("https://storage.googleapis.com/go-builder-data/gobootstrap-%s-go%s.tar.gz", cipdPlatform, cp.BootstrapVersion)
+	if err = bc.PutTarFromURL(ctx, url, cp.BootstrapVersion); err != nil {
+		return nil, status.Errorf(codes.Internal, "unable to download bootstrap Go")
+	}
+	return &protos.AddBootstrapResponse{BootstrapGoUrl: url}, nil
+}
+
 // CreateInstance will create a gomote instance within a swarming task for the authenticated user.
 func (ss *SwarmingServer) CreateInstance(req *protos.CreateInstanceRequest, stream protos.GomoteService_CreateInstanceServer) error {
 	creds, err := access.IAPFromContext(stream.Context())
diff --git a/internal/gomote/swarming_test.go b/internal/gomote/swarming_test.go
index a493350..5302af6 100644
--- a/internal/gomote/swarming_test.go
+++ b/internal/gomote/swarming_test.go
@@ -104,6 +104,75 @@
 	}
 }
 
+func TestSwarmingAddBootstrap(t *testing.T) {
+	ctx := access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAP())
+	client := setupGomoteSwarmingTest(t, context.Background(), mockSwarmClientSimple())
+	gomoteID := mustCreateSwarmingInstance(t, client, fakeIAP())
+	req := &protos.AddBootstrapRequest{
+		GomoteId: gomoteID,
+	}
+	got, err := client.AddBootstrap(ctx, req)
+	if err != nil {
+		t.Fatalf("client.AddBootstrap(ctx, %v) = %v, %s; want no error", req, got, err)
+	}
+}
+
+func TestSwarmingAddBootstrapError(t *testing.T) {
+	// This test will create a gomote instance and attempt to call AddBootstrap.
+	// If overrideID is set to true, the test will use a different gomoteID than
+	// the one created for the test.
+	testCases := []struct {
+		desc       string
+		ctx        context.Context
+		overrideID bool
+		gomoteID   string // Used iff overrideID is true.
+		wantCode   codes.Code
+	}{
+		{
+			desc:     "unauthenticated request",
+			ctx:      context.Background(),
+			wantCode: codes.Unauthenticated,
+		},
+		{
+			desc:       "missing gomote id",
+			ctx:        access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAP()),
+			overrideID: true,
+			wantCode:   codes.NotFound,
+		},
+		{
+			desc:       "gomote does not exist",
+			ctx:        access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAP()),
+			overrideID: true,
+			gomoteID:   "xyz",
+			wantCode:   codes.NotFound,
+		},
+		{
+			desc:     "gomote is not owned by caller",
+			ctx:      access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAPWithUser("user-x", "email-y")),
+			wantCode: codes.PermissionDenied,
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.desc, func(t *testing.T) {
+			client := setupGomoteSwarmingTest(t, context.Background(), mockSwarmClientSimple())
+			gomoteID := mustCreateSwarmingInstance(t, client, fakeIAP())
+			if tc.overrideID {
+				gomoteID = tc.gomoteID
+			}
+			req := &protos.AddBootstrapRequest{
+				GomoteId: gomoteID,
+			}
+			got, err := client.AddBootstrap(tc.ctx, req)
+			if err != nil && status.Code(err) != tc.wantCode {
+				t.Fatalf("unexpected error: %s; want %s", err, tc.wantCode)
+			}
+			if err == nil {
+				t.Fatalf("client.AddBootstrap(ctx, %v) = %v, nil; want error", req, got)
+			}
+		})
+	}
+}
+
 func TestSwarmingListSwarmingBuilders(t *testing.T) {
 	log.SetOutput(io.Discard)
 	defer log.SetOutput(os.Stdout)