Skip to content

Commit 39aa693

Browse files
committed
internal/gomote, internal/gomote/protos: implement write files from URL
This change adds the implementation for the WriteFileFromURL endpoint. The caller will be able to add the contents of an HTTP get call to a file on the gomote instance. They must set the permissions on the file. Files located on the gomote transfer GCS bucket will be retrieved using the GCS storage package and authentication vs a vanilla HTTP call. For golang/go#47521 Updates golang/go#48742 Change-Id: If9ac24654352433c7a073de08017213223cf9020 Reviewed-on: https://go-review.googlesource.com/c/build/+/397596 Reviewed-by: Heschi Kreinick <[email protected]> Trust: Carlos Amedee <[email protected]> Run-TryBot: Carlos Amedee <[email protected]> TryBot-Result: Gopher Robot <[email protected]>
1 parent 6444502 commit 39aa693

File tree

6 files changed

+519
-101
lines changed

6 files changed

+519
-101
lines changed

buildlet/fakebuildletclient.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,10 @@ func (fc *FakeClient) ProxyTCP(port int) (io.ReadWriteCloser, error) { return ni
147147
// Put places a file on a fake buildlet.
148148
func (fc *FakeClient) Put(ctx context.Context, r io.Reader, path string, mode os.FileMode) error {
149149
// TODO(go.dev/issue/48742) add a file system implementation which would enable proper testing.
150-
return errUnimplemented
150+
if path == "" {
151+
errors.New("invalid argument")
152+
}
153+
return nil
151154
}
152155

153156
// PutTar fakes putting a tar zipped file on a buildldet.

internal/gomote/gomote.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import (
1111
"context"
1212
"errors"
1313
"fmt"
14+
"io"
15+
"io/fs"
1416
"log"
17+
"net/http"
1518
"regexp"
1619
"strings"
1720
"time"
@@ -39,6 +42,8 @@ type scheduler interface {
3942
// bucketHandle interface used to enable testing of the storage.bucketHandle.
4043
type bucketHandle interface {
4144
GenerateSignedPostPolicyV4(object string, opts *storage.PostPolicyV4Options) (*storage.PostPolicyV4, error)
45+
SignedURL(object string, opts *storage.SignedURLOptions) (string, error)
46+
Object(name string) *storage.ObjectHandle
4247
}
4348

4449
// Server is a gomote server implementation.
@@ -380,6 +385,68 @@ func (s *Server) signURLForUpload(object string) (url string, fields map[string]
380385
return pv4.URL, pv4.Fields, nil
381386
}
382387

388+
// signURLForDownload generates a signed URL and fields to be used to upload an object to GCS without authenticating.
389+
func (s *Server) signURLForDownload(object string) (url string, err error) {
390+
url, err = s.bucket.SignedURL(object, &storage.SignedURLOptions{
391+
Expires: time.Now().Add(10 * time.Minute),
392+
Method: http.MethodGet,
393+
Scheme: storage.SigningSchemeV4,
394+
})
395+
if err != nil {
396+
return "", fmt.Errorf("unable to generate signed url: %w", err)
397+
}
398+
return url, err
399+
}
400+
401+
// WriteFileFromURL initiates an HTTP request to the passed in URL and streams the contents of the request to the gomote instance.
402+
func (s *Server) WriteFileFromURL(ctx context.Context, req *protos.WriteFileFromURLRequest) (*protos.WriteFileFromURLResponse, error) {
403+
creds, err := access.IAPFromContext(ctx)
404+
if err != nil {
405+
log.Printf("WriteTGZFromURL access.IAPFromContext(ctx) = nil, %s", err)
406+
return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication")
407+
}
408+
_, bc, err := s.sessionAndClient(req.GetGomoteId(), creds.ID)
409+
if err != nil {
410+
// the helper function returns meaningful GRPC error.
411+
return nil, err
412+
}
413+
var rc io.ReadCloser
414+
// objects stored in the gomote staging bucket are only accessible when you have been granted explicit permissions. A builder
415+
// requires a signed URL in order to access objects stored in the the gomote staging bucket.
416+
if onObjectStore(s.gceBucketName, req.GetUrl()) {
417+
object, err := objectFromURL(s.gceBucketName, req.GetUrl())
418+
if err != nil {
419+
return nil, status.Errorf(codes.InvalidArgument, "invalid object URL")
420+
}
421+
rc, err = s.bucket.Object(object).NewReader(ctx)
422+
if err != nil {
423+
return nil, status.Errorf(codes.Internal, "unable to create object reader: %s", err)
424+
}
425+
} else {
426+
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, req.GetUrl(), nil)
427+
// TODO(amedee) find sane client defaults, possibly rely on context timeout in request.
428+
client := &http.Client{
429+
Timeout: 30 * time.Second,
430+
Transport: &http.Transport{
431+
TLSHandshakeTimeout: 5 * time.Second,
432+
},
433+
}
434+
resp, err := client.Do(httpRequest)
435+
if err != nil {
436+
return nil, status.Errorf(codes.Aborted, "failed to get file from URL: %s", err)
437+
}
438+
if resp.StatusCode != http.StatusOK {
439+
return nil, status.Errorf(codes.Aborted, "unable to get file from URL: response code: %d", resp.StatusCode)
440+
}
441+
rc = resp.Body
442+
}
443+
defer rc.Close()
444+
if err := bc.Put(ctx, rc, req.GetFilename(), fs.FileMode(req.GetMode())); err != nil {
445+
return nil, status.Errorf(codes.Aborted, "failed to send the file to the gomote instance: %s", err)
446+
}
447+
return &protos.WriteFileFromURLResponse{}, nil
448+
}
449+
383450
// 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
384451
// relative to the directory provided.
385452
func (s *Server) WriteTGZFromURL(ctx context.Context, req *protos.WriteTGZFromURLRequest) (*protos.WriteTGZFromURLResponse, error) {
@@ -452,3 +519,21 @@ func emailToUser(email string) (string, error) {
452519
}
453520
return email[strings.Index(email, ":")+1 : strings.LastIndex(email, "@")], nil
454521
}
522+
523+
// onObjectStore returns true if the the url is for an object on GCS.
524+
func onObjectStore(bucketName, url string) bool {
525+
return strings.HasPrefix(url, fmt.Sprintf("https://storage.googleapis.com/%s/", bucketName))
526+
}
527+
528+
// objectFromURL returns the object name for an object on GCS.
529+
func objectFromURL(bucketName, url string) (string, error) {
530+
if !onObjectStore(bucketName, url) {
531+
return "", errors.New("URL not for gomote transfer bucket")
532+
}
533+
url = strings.TrimPrefix(url, fmt.Sprintf("https://storage.googleapis.com/%s/", bucketName))
534+
pos := strings.Index(url, "?")
535+
if pos == -1 {
536+
return "", errors.New("invalid object store URL")
537+
}
538+
return url[:pos], nil
539+
}

internal/gomote/gomote_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,87 @@ func TestWriteTGZFromURL(t *testing.T) {
736736
}
737737
}
738738

739+
// TODO(go.dev/issue/48737) add test for files on GCS
740+
func TestWriteFileFromURL(t *testing.T) {
741+
ctx := access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAP())
742+
client := setupGomoteTest(t, context.Background())
743+
gomoteID := mustCreateInstance(t, client, fakeIAP())
744+
if _, err := client.WriteFileFromURL(ctx, &protos.WriteFileFromURLRequest{
745+
GomoteId: gomoteID,
746+
Url: `https://go.dev/dl/go1.17.6.linux-amd64.tar.gz`,
747+
Filename: "foo",
748+
Mode: 0777,
749+
}); err != nil {
750+
t.Fatalf("client.WriteFileFromURL(ctx, req) = response, %s; want no error", err)
751+
}
752+
}
753+
754+
func TestWriteFileFromURLError(t *testing.T) {
755+
// This test will create a gomote instance and attempt to call TestWriteFileFromURL.
756+
// If overrideID is set to true, the test will use a different gomoteID than the
757+
// the one created for the test.
758+
testCases := []struct {
759+
desc string
760+
ctx context.Context
761+
overrideID bool
762+
gomoteID string // Used iff overrideID is true.
763+
url string
764+
filename string
765+
mode uint32
766+
wantCode codes.Code
767+
}{
768+
{
769+
desc: "unauthenticated request",
770+
ctx: context.Background(),
771+
wantCode: codes.Unauthenticated,
772+
},
773+
{
774+
desc: "missing gomote id",
775+
ctx: access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAP()),
776+
overrideID: true,
777+
gomoteID: "",
778+
wantCode: codes.NotFound,
779+
},
780+
{
781+
desc: "gomote does not exist",
782+
ctx: access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAPWithUser("foo", "bar")),
783+
overrideID: true,
784+
gomoteID: "chucky",
785+
url: "go.dev/dl/1_14.tar.gz",
786+
wantCode: codes.NotFound,
787+
},
788+
{
789+
desc: "wrong gomote id",
790+
ctx: access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAPWithUser("foo", "bar")),
791+
overrideID: false,
792+
url: "go.dev/dl/1_14.tar.gz",
793+
wantCode: codes.PermissionDenied,
794+
},
795+
}
796+
for _, tc := range testCases {
797+
t.Run(tc.desc, func(t *testing.T) {
798+
client := setupGomoteTest(t, context.Background())
799+
gomoteID := mustCreateInstance(t, client, fakeIAP())
800+
if tc.overrideID {
801+
gomoteID = tc.gomoteID
802+
}
803+
req := &protos.WriteFileFromURLRequest{
804+
GomoteId: gomoteID,
805+
Url: tc.url,
806+
Filename: tc.filename,
807+
Mode: 0,
808+
}
809+
got, err := client.WriteFileFromURL(tc.ctx, req)
810+
if err != nil && status.Code(err) != tc.wantCode {
811+
t.Fatalf("unexpected error: %s; want %s", err, tc.wantCode)
812+
}
813+
if err == nil {
814+
t.Fatalf("client.WriteFileFromURL(ctx, %v) = %v, nil; want error", req, got)
815+
}
816+
})
817+
}
818+
}
819+
739820
func TestWriteTGZFromURLError(t *testing.T) {
740821
// This test will create a gomote instance and attempt to call TestWriteTGZFromURL.
741822
// If overrideID is set to true, the test will use a different gomoteID than the
@@ -812,6 +893,29 @@ func TestIsPrivilegedUser(t *testing.T) {
812893
}
813894
}
814895

896+
func TestObjectFromURL(t *testing.T) {
897+
url := `https://storage.googleapis.com/example-bucket/cat.jpeg?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=example...`
898+
bucket := "example-bucket"
899+
wantObject := "cat.jpeg"
900+
object, err := objectFromURL(bucket, url)
901+
if err != nil {
902+
t.Fatalf("urlToBucketObject(url) = %q, %s; want %q, no error", object, err, wantObject)
903+
}
904+
if object != wantObject {
905+
t.Fatalf("urlToBucketObject(url) = %q; want %q", object, wantObject)
906+
}
907+
}
908+
909+
func TestObjectFromURLError(t *testing.T) {
910+
bucket := "example-bucket"
911+
object := "cat.jpeg"
912+
url := fmt.Sprintf("https://storage.googleapis.com/%s/%s", bucket, object)
913+
got, err := objectFromURL(bucket, url)
914+
if err == nil {
915+
t.Fatalf("urlToBucketObject(url) = %q, nil; want \"\", error", got)
916+
}
917+
}
918+
815919
func TestEmailToUser(t *testing.T) {
816920
testCases := []struct {
817921
desc string
@@ -933,3 +1037,14 @@ func (fbc *fakeBucketHandler) GenerateSignedPostPolicyV4(object string, opts *st
9331037
},
9341038
}, nil
9351039
}
1040+
1041+
func (fbc *fakeBucketHandler) SignedURL(object string, opts *storage.SignedURLOptions) (string, error) {
1042+
if object == "" || opts == nil {
1043+
return "", errors.New("invalid arguments")
1044+
}
1045+
return fmt.Sprintf("https://localhost/%s?X-Goog-Algorithm=GOOG4-yyy", object), nil
1046+
}
1047+
1048+
func (fbc *fakeBucketHandler) Object(name string) *storage.ObjectHandle {
1049+
return &storage.ObjectHandle{}
1050+
}

0 commit comments

Comments
 (0)