go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/resultdb/internal/services/recorder/create_artifact_test.go (about)

     1  // Copyright 2020 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package recorder
    16  
    17  import (
    18  	"context"
    19  	"io"
    20  	"net/http"
    21  	"net/http/httptest"
    22  	"net/url"
    23  	"strconv"
    24  	"strings"
    25  	"testing"
    26  	"time"
    27  
    28  	"github.com/golang/protobuf/proto"
    29  	"google.golang.org/genproto/googleapis/bytestream"
    30  	"google.golang.org/grpc"
    31  	"google.golang.org/grpc/codes"
    32  	"google.golang.org/grpc/status"
    33  
    34  	"go.chromium.org/luci/common/tsmon"
    35  	"go.chromium.org/luci/server/router"
    36  
    37  	"go.chromium.org/luci/resultdb/internal/invocations"
    38  	"go.chromium.org/luci/resultdb/internal/spanutil"
    39  	"go.chromium.org/luci/resultdb/internal/testutil"
    40  	"go.chromium.org/luci/resultdb/internal/testutil/insert"
    41  	pb "go.chromium.org/luci/resultdb/proto/v1"
    42  
    43  	. "github.com/smartystreets/goconvey/convey"
    44  )
    45  
    46  type fakeWriter struct {
    47  	grpc.ClientStream // implements the rest of ByteStream_WriteClient.
    48  
    49  	requestsToAccept int
    50  	requests         []*bytestream.WriteRequest
    51  
    52  	res    *bytestream.WriteResponse
    53  	resErr error
    54  }
    55  
    56  func (*fakeWriter) CloseSend() error {
    57  	return nil
    58  }
    59  
    60  func (w *fakeWriter) CloseAndRecv() (*bytestream.WriteResponse, error) {
    61  	if w.res != nil || w.resErr != nil {
    62  		return w.res, w.resErr
    63  	}
    64  
    65  	res := &bytestream.WriteResponse{}
    66  	for _, req := range w.requests {
    67  		res.CommittedSize += int64(len(req.Data))
    68  	}
    69  	return res, nil
    70  }
    71  
    72  func (w *fakeWriter) Send(req *bytestream.WriteRequest) error {
    73  	w.requests = append(w.requests, proto.Clone(req).(*bytestream.WriteRequest))
    74  	if len(w.requests) >= w.requestsToAccept {
    75  		return io.EOF
    76  	}
    77  	return nil
    78  }
    79  
    80  func TestCreateArtifact(t *testing.T) {
    81  	// metric field values for Artifact table
    82  	artMFVs := []any{string(spanutil.Artifacts), string(spanutil.Inserted), insert.TestRealm}
    83  
    84  	Convey(`CreateArtifact`, t, func() {
    85  		ctx := testutil.SpannerTestContext(t)
    86  		ctx, _ = tsmon.WithDummyInMemory(ctx)
    87  		store := tsmon.Store(ctx)
    88  
    89  		w := &fakeWriter{}
    90  		writerCreated := false
    91  		ach := &artifactCreationHandler{
    92  			RBEInstance: "projects/example/instances/artifacts",
    93  			NewCASWriter: func(context.Context) (bytestream.ByteStream_WriteClient, error) {
    94  				So(writerCreated, ShouldBeFalse)
    95  				writerCreated = true
    96  				return w, nil
    97  			},
    98  			MaxArtifactContentStreamLength: 1024,
    99  		}
   100  
   101  		art := "invocations/inv/artifacts/a"
   102  		tok, err := invocationTokenKind.Generate(ctx, []byte("inv"), nil, time.Hour)
   103  		So(err, ShouldBeNil)
   104  
   105  		send := func(artifact, hash string, size int64, updateToken, content, contentType string) *httptest.ResponseRecorder {
   106  			rec := httptest.NewRecorder()
   107  
   108  			// Create a URL whose EscapePath() returns `artifact`.
   109  			u := &url.URL{RawPath: "/" + artifact}
   110  			var err error
   111  			u.Path, err = url.PathUnescape(artifact)
   112  			So(err, ShouldBeNil)
   113  
   114  			c := &router.Context{
   115  				Request: (&http.Request{
   116  					Header: http.Header{},
   117  					URL:    u,
   118  					Body:   io.NopCloser(strings.NewReader(content)),
   119  				}).WithContext(ctx),
   120  				Writer: rec,
   121  			}
   122  			if hash != "" {
   123  				c.Request.Header.Set(artifactContentHashHeaderKey, hash)
   124  			}
   125  			if size != -1 {
   126  				c.Request.Header.Set(artifactContentSizeHeaderKey, strconv.FormatInt(size, 10))
   127  			}
   128  			if updateToken != "" {
   129  				c.Request.Header.Set(updateTokenHeaderKey, updateToken)
   130  			}
   131  			if contentType != "" {
   132  				c.Request.Header.Set(artifactContentTypeHeaderKey, contentType)
   133  			}
   134  			ach.Handle(c)
   135  			return rec
   136  		}
   137  
   138  		Convey(`Verify request`, func() {
   139  			Convey(`Artifact name is malformed`, func() {
   140  				rec := send("artifact", "", 0, "", "", "")
   141  				So(rec.Code, ShouldEqual, http.StatusBadRequest)
   142  				So(rec.Body.String(), ShouldContainSubstring, "bad artifact name: does not match")
   143  			})
   144  
   145  			Convey(`Hash is missing`, func() {
   146  				rec := send(art, "", 0, "", "", "")
   147  				So(rec.Code, ShouldEqual, http.StatusBadRequest)
   148  				So(rec.Body.String(), ShouldContainSubstring, "Content-Hash header is missing")
   149  			})
   150  
   151  			Convey(`Hash is malformed`, func() {
   152  				rec := send(art, "a", 0, "", "", "")
   153  				So(rec.Code, ShouldEqual, http.StatusBadRequest)
   154  				So(rec.Body.String(), ShouldContainSubstring, "Content-Hash header value does not match")
   155  			})
   156  
   157  			Convey(`Size is missing`, func() {
   158  				rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", -1, "", "", "")
   159  				So(rec.Code, ShouldEqual, http.StatusBadRequest)
   160  				So(rec.Body.String(), ShouldContainSubstring, "Content-Length header is missing")
   161  			})
   162  
   163  			Convey(`Size is negative`, func() {
   164  				rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", -100, "", "", "")
   165  				So(rec.Code, ShouldEqual, http.StatusBadRequest)
   166  				So(rec.Body.String(), ShouldContainSubstring, "Content-Length header must be a value between 0 and 1024")
   167  			})
   168  
   169  			Convey(`Size is too large`, func() {
   170  				rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 1<<30, "", "", "")
   171  				So(rec.Code, ShouldEqual, http.StatusBadRequest)
   172  				So(rec.Body.String(), ShouldContainSubstring, "Content-Length header must be a value between 0 and 1024")
   173  			})
   174  
   175  			Convey(`Update token is missing`, func() {
   176  				rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 0, "", "", "")
   177  				So(rec.Code, ShouldEqual, http.StatusUnauthorized)
   178  				So(rec.Body.String(), ShouldContainSubstring, "Update-Token header is missing")
   179  			})
   180  
   181  			Convey(`Update token is invalid`, func() {
   182  				rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 0, "x", "", "")
   183  				So(rec.Code, ShouldEqual, http.StatusForbidden)
   184  				So(rec.Body.String(), ShouldContainSubstring, "invalid Update-Token header value")
   185  			})
   186  
   187  			// RowCount metric should have no changes from any of the above Convey()s.
   188  			So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldBeNil)
   189  		})
   190  
   191  		Convey(`Verify state`, func() {
   192  			Convey(`Invocation does not exist`, func() {
   193  				rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 0, tok, "", "")
   194  				So(rec.Code, ShouldEqual, http.StatusNotFound)
   195  				So(rec.Body.String(), ShouldContainSubstring, "invocations/inv not found")
   196  			})
   197  
   198  			Convey(`Invocation is finalized`, func() {
   199  				testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_FINALIZED, nil))
   200  				rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 0, tok, "", "")
   201  				So(rec.Code, ShouldEqual, http.StatusBadRequest)
   202  				So(rec.Body.String(), ShouldContainSubstring, "invocations/inv is not active")
   203  			})
   204  
   205  			Convey(`Same artifact exists`, func() {
   206  				testutil.MustApply(ctx,
   207  					insert.Invocation("inv", pb.Invocation_ACTIVE, nil),
   208  					spanutil.InsertMap("Artifacts", map[string]any{
   209  						"InvocationId": invocations.ID("inv"),
   210  						"ParentId":     "",
   211  						"ArtifactId":   "a",
   212  						"RBECASHash":   "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
   213  						"Size":         0,
   214  					}),
   215  				)
   216  				rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 0, tok, "", "")
   217  				So(rec.Code, ShouldEqual, http.StatusNoContent)
   218  			})
   219  
   220  			Convey(`Different artifact exists`, func() {
   221  				testutil.MustApply(ctx,
   222  					insert.Invocation("inv", pb.Invocation_ACTIVE, nil),
   223  					spanutil.InsertMap("Artifacts", map[string]any{
   224  						"InvocationId": invocations.ID("inv"),
   225  						"ParentId":     "",
   226  						"ArtifactId":   "a",
   227  						"RBECASHash":   "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b851",
   228  						"Size":         1,
   229  					}),
   230  				)
   231  				rec := send(art, "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", 0, tok, "", "")
   232  				So(rec.Code, ShouldEqual, http.StatusConflict)
   233  			})
   234  
   235  			// RowCount metric should have no changes from any of the above Convey()s.
   236  			So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldBeNil)
   237  		})
   238  
   239  		Convey(`Write to CAS`, func() {
   240  			testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_ACTIVE, nil))
   241  			w.requestsToAccept = 1
   242  
   243  			casSend := func() *httptest.ResponseRecorder {
   244  				return send(art, "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", 5, tok, "hello", "")
   245  			}
   246  
   247  			// Skip due to https://crbug.com/1110755
   248  			SkipConvey(`Single request`, func() {
   249  				rec := casSend()
   250  				So(rec.Code, ShouldEqual, http.StatusNoContent)
   251  				So(w.requests, ShouldHaveLength, 1)
   252  				So(w.requests[0].ResourceName, ShouldEqual, "projects/example/instances/artifacts/uploads/0194fdc2-fa2f-fcc0-41d3-ff12045b73c8/blobs/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824/5")
   253  				So(string(w.requests[0].Data), ShouldEqual, "hello")
   254  			})
   255  
   256  			Convey(`Chunked`, func() {
   257  				w.requestsToAccept = 2
   258  				ach.bufSize = 3
   259  				rec := casSend()
   260  				So(rec.Code, ShouldEqual, http.StatusNoContent)
   261  				So(w.requests, ShouldHaveLength, 2)
   262  				So(string(w.requests[0].Data), ShouldEqual, "hel")
   263  				So(string(w.requests[1].Data), ShouldEqual, "lo")
   264  				So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldEqual, 1)
   265  			})
   266  
   267  			Convey(`Invalid digest`, func() {
   268  				w.resErr = status.Errorf(codes.InvalidArgument, "wrong hash")
   269  				rec := casSend()
   270  				So(rec.Code, ShouldEqual, http.StatusBadRequest)
   271  				So(rec.Body.String(), ShouldEqual, "Content-Hash and/or Content-Length do not match the request body\n")
   272  				So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldBeNil)
   273  			})
   274  
   275  			Convey(`RBE-CAS stops request early`, func() {
   276  				w.requestsToAccept = 1
   277  				w.res = &bytestream.WriteResponse{CommittedSize: 5} // blob already exists
   278  				ach.bufSize = 1
   279  				rec := casSend()
   280  				So(rec.Code, ShouldEqual, http.StatusNoContent)
   281  				// Must not continue requests after receiving io.EOF in the first
   282  				// request.
   283  				So(w.requests, ShouldHaveLength, 1)
   284  				So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldEqual, 1)
   285  			})
   286  
   287  		})
   288  
   289  		Convey(`Verify digest`, func() {
   290  			testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_ACTIVE, nil))
   291  			w.requestsToAccept = 1
   292  
   293  			Convey(`Hash`, func() {
   294  				rec := send(art, "sha256:baaaaada5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", 5, tok, "hello", "")
   295  				So(rec.Code, ShouldEqual, http.StatusBadRequest)
   296  				So(
   297  					rec.Body.String(),
   298  					ShouldEqual,
   299  					`Content-Hash header value "sha256:baaaaada5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" `+
   300  						`does not match the hash of the request body, "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"`+
   301  						"\n")
   302  			})
   303  
   304  			Convey(`Size`, func() {
   305  				// Satisfy the RBE-level verification.
   306  				w.res = &bytestream.WriteResponse{CommittedSize: 100}
   307  
   308  				rec := send(art, "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", 100, tok, "hello", "")
   309  				So(rec.Code, ShouldEqual, http.StatusBadRequest)
   310  				So(rec.Body.String(), ShouldEqual, "Content-Length header value 100 does not match the length of the request body, 5\n")
   311  			})
   312  
   313  			// RowCount metric should have no changes from any of the above Convey()s.
   314  			So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldBeNil)
   315  		})
   316  
   317  		Convey(`e2e`, func() {
   318  			testutil.MustApply(ctx, insert.Invocation("inv", pb.Invocation_ACTIVE, nil))
   319  
   320  			res := send(art, "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", 5, tok, "hello", "text/plain")
   321  			So(res.Code, ShouldEqual, http.StatusNoContent)
   322  
   323  			var actualSize int64
   324  			var actualHash string
   325  			var actualContentType string
   326  			testutil.MustReadRow(ctx, "Artifacts", invocations.ID("inv").Key("", "a"), map[string]any{
   327  				"Size":        &actualSize,
   328  				"RBECASHash":  &actualHash,
   329  				"ContentType": &actualContentType,
   330  			})
   331  			So(actualSize, ShouldEqual, 5)
   332  			So(actualHash, ShouldEqual, "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824")
   333  			So(actualContentType, ShouldEqual, "text/plain")
   334  			So(store.Get(ctx, spanutil.RowCounter, time.Time{}, artMFVs), ShouldEqual, 1)
   335  		})
   336  	})
   337  }