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 }