go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/swarming/server/rpcs/tasks_get_stdout_test.go (about)

     1  // Copyright 2024 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 rpcs
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"strconv"
    21  	"strings"
    22  	"testing"
    23  	"time"
    24  
    25  	"google.golang.org/grpc/codes"
    26  
    27  	"go.chromium.org/luci/gae/impl/memory"
    28  	"go.chromium.org/luci/gae/service/datastore"
    29  
    30  	apipb "go.chromium.org/luci/swarming/proto/api_v2"
    31  	configpb "go.chromium.org/luci/swarming/proto/config"
    32  	"go.chromium.org/luci/swarming/server/acls"
    33  	"go.chromium.org/luci/swarming/server/model"
    34  
    35  	. "github.com/smartystreets/goconvey/convey"
    36  	. "go.chromium.org/luci/common/testing/assertions"
    37  )
    38  
    39  func TestGetStdout(t *testing.T) {
    40  	t.Parallel()
    41  
    42  	Convey("TestGetStdout", t, func() {
    43  		ctx := memory.Use(context.Background())
    44  		state := NewMockedRequestState()
    45  		state.MockPerm("project:visible-realm", acls.PermTasksGet)
    46  		ctx = MockRequestState(ctx, state)
    47  		srv := TasksServer{}
    48  		reqKey, err := model.TaskIDToRequestKey(ctx, "65aba3a3e6b99310")
    49  		So(err, ShouldBeNil)
    50  		var testTime = time.Date(2023, time.January, 1, 2, 3, 4, 0, time.UTC)
    51  		tr := &model.TaskRequest{
    52  			Key:     reqKey,
    53  			TxnUUID: "txn-uuid",
    54  			TaskSlices: []model.TaskSlice{
    55  				model.TaskSlice{
    56  					Properties: model.TaskProperties{
    57  						Idempotent: true,
    58  						Dimensions: model.TaskDimensions{
    59  							"d1":   {"v1", "v2"},
    60  							"d2":   {"v3"},
    61  							"pool": {"pool"},
    62  							"id":   {"bot123"},
    63  						},
    64  						ExecutionTimeoutSecs: 123,
    65  						GracePeriodSecs:      456,
    66  						IOTimeoutSecs:        789,
    67  						Command:              []string{"run"},
    68  						RelativeCwd:          "./rel/cwd",
    69  						Env: model.Env{
    70  							"k1": "v1",
    71  							"k2": "v2",
    72  						},
    73  						EnvPrefixes: model.EnvPrefixes{
    74  							"p1": {"v1", "v2"},
    75  							"p2": {"v2"},
    76  						},
    77  						Caches: []model.CacheEntry{
    78  							{Name: "n1", Path: "p1"},
    79  							{Name: "n2", Path: "p2"},
    80  						},
    81  						CASInputRoot: model.CASReference{
    82  							CASInstance: "cas-inst",
    83  							Digest: model.CASDigest{
    84  								Hash:      "cas-hash",
    85  								SizeBytes: 1234,
    86  							},
    87  						},
    88  						CIPDInput: model.CIPDInput{
    89  							Server: "server",
    90  							ClientPackage: model.CIPDPackage{
    91  								PackageName: "client-package",
    92  								Version:     "client-version",
    93  							},
    94  							Packages: []model.CIPDPackage{
    95  								{
    96  									PackageName: "pkg1",
    97  									Version:     "ver1",
    98  									Path:        "path1",
    99  								},
   100  								{
   101  									PackageName: "pkg2",
   102  									Version:     "ver2",
   103  									Path:        "path2",
   104  								},
   105  							},
   106  						},
   107  						Outputs:        []string{"o1", "o2"},
   108  						HasSecretBytes: true,
   109  						Containment: model.Containment{
   110  							LowerPriority:             true,
   111  							ContainmentType:           123,
   112  							LimitProcesses:            456,
   113  							LimitTotalCommittedMemory: 789,
   114  						},
   115  					},
   116  					ExpirationSecs:  15 * 60,
   117  					WaitForCapacity: true,
   118  				},
   119  			},
   120  			Created:              testTime,
   121  			Expiration:           testTime.Add(20 * time.Minute),
   122  			Name:                 "name",
   123  			ParentTaskID:         datastore.NewIndexedNullable("parent-task-id"),
   124  			Authenticated:        "authenticated-user@example.com",
   125  			User:                 "user@example.com",
   126  			Tags:                 []string{"tag1", "tag2"},
   127  			ManualTags:           []string{"tag1"},
   128  			ServiceAccount:       "service-account",
   129  			Realm:                "project:visible-realm",
   130  			RealmsEnabled:        true,
   131  			SchedulingAlgorithm:  configpb.Pool_SCHEDULING_ALGORITHM_FIFO,
   132  			Priority:             50,
   133  			BotPingToleranceSecs: 456,
   134  			RBEInstance:          "rbe-instance",
   135  			PubSubTopic:          "pubsub-topic",
   136  			PubSubAuthToken:      "pubsub-auth-token",
   137  			PubSubUserData:       "pubsub-user-data",
   138  			ResultDBUpdateToken:  "resultdb-update-token",
   139  			ResultDB:             model.ResultDBConfig{Enable: true},
   140  			HasBuildTask:         true,
   141  		}
   142  		trs := &model.TaskResultSummary{
   143  			TaskResultCommon: model.TaskResultCommon{
   144  				State:               apipb.TaskState_COMPLETED,
   145  				Modified:            testTime,
   146  				BotVersion:          "bot_version_123",
   147  				BotDimensions:       model.BotDimensions{"os": []string{"linux"}, "cpu": []string{"x86_64"}},
   148  				BotIdleSince:        datastore.NewUnindexedOptional(testTime.Add(-30 * time.Minute)),
   149  				BotLogsCloudProject: "example-cloud-project",
   150  				ServerVersions:      []string{"v1.0"},
   151  				CurrentTaskSlice:    1,
   152  				Started:             datastore.NewIndexedNullable(testTime.Add(-1 * time.Hour)),
   153  				Completed:           datastore.NewIndexedNullable(testTime),
   154  				DurationSecs:        datastore.NewUnindexedOptional(3600.0),
   155  				ExitCode:            datastore.NewUnindexedOptional(int64(0)),
   156  				Failure:             false,
   157  				InternalFailure:     false,
   158  				StdoutChunks:        1,
   159  				CASOutputRoot: model.CASReference{
   160  					CASInstance: "cas-instance",
   161  					Digest: model.CASDigest{
   162  						Hash:      "cas-hash",
   163  						SizeBytes: 1024,
   164  					},
   165  				},
   166  				CIPDPins: model.CIPDInput{
   167  					Server: "https://example.cipd.server",
   168  					ClientPackage: model.CIPDPackage{
   169  						PackageName: "client_pkg",
   170  						Version:     "1.0.0",
   171  						Path:        "client",
   172  					},
   173  				},
   174  				ResultDBInfo: model.ResultDBInfo{
   175  					Hostname:   "results.api.example.dev",
   176  					Invocation: "inv123",
   177  				},
   178  			},
   179  			Key:                  model.TaskResultSummaryKey(ctx, reqKey),
   180  			BotID:                datastore.NewUnindexedOptional("bot123"),
   181  			Created:              testTime.Add(-2 * time.Hour),
   182  			Tags:                 []string{"tag1", "tag2"},
   183  			RequestName:          "example-request",
   184  			RequestUser:          "user@example.com",
   185  			RequestPriority:      50,
   186  			RequestAuthenticated: "authenticated-user@example.com",
   187  			RequestRealm:         "project:visible-realm",
   188  			RequestPool:          "pool",
   189  			RequestBotID:         "bot123",
   190  			PropertiesHash:       datastore.NewIndexedOptional([]byte("prop-hash")),
   191  			TryNumber:            datastore.NewIndexedNullable(int64(1)),
   192  			CostUSD:              0.05,
   193  			CostSavedUSD:         0.00,
   194  			DedupedFrom:          "",
   195  			ExpirationDelay:      datastore.NewUnindexedOptional(0.0),
   196  		}
   197  
   198  		Convey("ok; many chunks", func() {
   199  			numChunks := 5
   200  			trs.TaskResultCommon.StdoutChunks = int64(numChunks)
   201  			So(datastore.Put(ctx, tr, trs), ShouldBeNil)
   202  			model.PutMockTaskOutput(ctx, reqKey, numChunks)
   203  			expectedOutputStr := ""
   204  			for i := 0; i < numChunks; i++ {
   205  				expectedOutputStr += strings.Repeat(strconv.Itoa(i), model.ChunkSize)
   206  			}
   207  			resp, err := srv.GetStdout(ctx, &apipb.TaskIdWithOffsetRequest{
   208  				TaskId: "65aba3a3e6b99310",
   209  			})
   210  			So(err, ShouldBeNil)
   211  			So(resp, ShouldResembleProto, &apipb.TaskOutputResponse{
   212  				Output: []byte(expectedOutputStr),
   213  				State:  apipb.TaskState_COMPLETED,
   214  			})
   215  		})
   216  
   217  		Convey("ok; one chunks", func() {
   218  			numChunks := 1
   219  			trs.TaskResultCommon.StdoutChunks = int64(numChunks)
   220  			So(datastore.Put(ctx, tr, trs), ShouldBeNil)
   221  			model.PutMockTaskOutput(ctx, reqKey, numChunks)
   222  			expectedOutputStr := ""
   223  			for i := 0; i < numChunks; i++ {
   224  				expectedOutputStr += strings.Repeat(strconv.Itoa(i), model.ChunkSize)
   225  			}
   226  			resp, err := srv.GetStdout(ctx, &apipb.TaskIdWithOffsetRequest{
   227  				TaskId: "65aba3a3e6b99310",
   228  			})
   229  			So(err, ShouldBeNil)
   230  			So(resp, ShouldResembleProto, &apipb.TaskOutputResponse{
   231  				Output: []byte(expectedOutputStr),
   232  				State:  apipb.TaskState_COMPLETED,
   233  			})
   234  		})
   235  
   236  		Convey("ok; offset and length", func() {
   237  			numChunks := 5
   238  			trs.TaskResultCommon.StdoutChunks = int64(numChunks)
   239  			So(datastore.Put(ctx, tr, trs), ShouldBeNil)
   240  			model.PutMockTaskOutput(ctx, reqKey, numChunks)
   241  			expectedOutputStr := ""
   242  			for i := 0; i < numChunks; i++ {
   243  				expectedOutputStr += strings.Repeat(strconv.Itoa(i), model.ChunkSize)
   244  			}
   245  			resp, err := srv.GetStdout(ctx, &apipb.TaskIdWithOffsetRequest{
   246  				TaskId: "65aba3a3e6b99310",
   247  				Offset: 100,
   248  				Length: 1000,
   249  			})
   250  			So(err, ShouldBeNil)
   251  			So(resp, ShouldResembleProto, &apipb.TaskOutputResponse{
   252  				Output: []byte(expectedOutputStr[100:1100]),
   253  				State:  apipb.TaskState_COMPLETED,
   254  			})
   255  		})
   256  
   257  		Convey("ok; all missing chunks", func() {
   258  			numChunks := 2
   259  			trs.TaskResultCommon.StdoutChunks = int64(numChunks)
   260  			So(datastore.Put(ctx, tr, trs), ShouldBeNil)
   261  			resp, err := srv.GetStdout(ctx, &apipb.TaskIdWithOffsetRequest{
   262  				TaskId: "65aba3a3e6b99310",
   263  			})
   264  			So(err, ShouldBeNil)
   265  			expectedOutput := bytes.Join([][]byte{
   266  				bytes.Repeat([]byte("\x00"), model.ChunkSize),
   267  				bytes.Repeat([]byte("\x00"), model.ChunkSize),
   268  			}, []byte(""))
   269  			So(resp, ShouldResembleProto, &apipb.TaskOutputResponse{
   270  				Output: expectedOutput,
   271  				State:  apipb.TaskState_COMPLETED,
   272  			})
   273  		})
   274  
   275  		Convey("not ok; no task_id", func() {
   276  			resp, err := srv.GetStdout(ctx, &apipb.TaskIdWithOffsetRequest{})
   277  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   278  			So(err, ShouldErrLike, "task_id is required")
   279  			So(resp, ShouldBeNil)
   280  		})
   281  
   282  		Convey("not ok; error with task_id", func() {
   283  			resp, err := srv.GetStdout(ctx, &apipb.TaskIdWithOffsetRequest{
   284  				TaskId: "!",
   285  			})
   286  			So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
   287  			So(err, ShouldErrLike, "task_id !: bad task ID: too small")
   288  			So(resp, ShouldBeNil)
   289  		})
   290  
   291  		Convey("not ok; no such task", func() {
   292  			resp, err := srv.GetStdout(ctx, &apipb.TaskIdWithOffsetRequest{
   293  				TaskId: "65aba3a3e6b99320",
   294  			})
   295  			So(err, ShouldHaveGRPCStatus, codes.NotFound)
   296  			So(err, ShouldErrLike, "no such task")
   297  			So(resp, ShouldBeNil)
   298  		})
   299  
   300  		Convey("not ok; requestor does not have ACLs", func() {
   301  			reqKey, err := model.TaskIDToRequestKey(ctx, "65aba3a3e6b99320")
   302  			So(err, ShouldBeNil)
   303  			trs := model.TaskResultSummary{
   304  				Key:                  model.TaskResultSummaryKey(ctx, reqKey),
   305  				RequestRealm:         "project:no-access-realm",
   306  				RequestPool:          "no-access-pool",
   307  				RequestBotID:         "da bot",
   308  				RequestAuthenticated: "user:someone@notyou.com",
   309  			}
   310  			So(datastore.Put(ctx, &trs), ShouldBeNil)
   311  			resp, err := srv.GetStdout(ctx, &apipb.TaskIdWithOffsetRequest{
   312  				TaskId: "65aba3a3e6b99320",
   313  			})
   314  			So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
   315  			So(err, ShouldErrLike, "the caller \"user:test@example.com\" doesn't have permission \"swarming.tasks.get\" for the task \"65aba3a3e6b99320\"")
   316  			So(resp, ShouldBeNil)
   317  		})
   318  	})
   319  }