code.gitea.io/gitea@v1.22.3/routers/api/actions/artifactsv4.go (about) 1 // Copyright 2024 The Gitea Authors. All rights reserved. 2 // SPDX-License-Identifier: MIT 3 4 package actions 5 6 // GitHub Actions Artifacts V4 API Simple Description 7 // 8 // 1. Upload artifact 9 // 1.1. CreateArtifact 10 // Post: /twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact 11 // Request: 12 // { 13 // "workflow_run_backend_id": "21", 14 // "workflow_job_run_backend_id": "49", 15 // "name": "test", 16 // "version": 4 17 // } 18 // Response: 19 // { 20 // "ok": true, 21 // "signedUploadUrl": "http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75" 22 // } 23 // 1.2. Upload Zip Content to Blobstorage (unauthenticated request) 24 // PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=block 25 // 1.3. Continue Upload Zip Content to Blobstorage (unauthenticated request), repeat until everything is uploaded 26 // PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=appendBlock 27 // 1.4. Unknown xml payload to Blobstorage (unauthenticated request), ignored for now 28 // PUT: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=mO7y35r4GyjN7fwg0DTv3-Fv1NDXD84KLEgLpoPOtDI=&expires=2024-01-23+21%3A48%3A37.20833956+%2B0100+CET&artifactName=test&taskID=75&comp=blockList 29 // 1.5. FinalizeArtifact 30 // Post: /twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact 31 // Request 32 // { 33 // "workflow_run_backend_id": "21", 34 // "workflow_job_run_backend_id": "49", 35 // "name": "test", 36 // "size": "2097", 37 // "hash": "sha256:b6325614d5649338b87215d9536b3c0477729b8638994c74cdefacb020a2cad4" 38 // } 39 // Response 40 // { 41 // "ok": true, 42 // "artifactId": "4" 43 // } 44 // 2. Download artifact 45 // 2.1. ListArtifacts and optionally filter by artifact exact name or id 46 // Post: /twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts 47 // Request 48 // { 49 // "workflow_run_backend_id": "21", 50 // "workflow_job_run_backend_id": "49", 51 // "name_filter": "test" 52 // } 53 // Response 54 // { 55 // "artifacts": [ 56 // { 57 // "workflowRunBackendId": "21", 58 // "workflowJobRunBackendId": "49", 59 // "databaseId": "4", 60 // "name": "test", 61 // "size": "2093", 62 // "createdAt": "2024-01-23T00:13:28Z" 63 // } 64 // ] 65 // } 66 // 2.2. GetSignedArtifactURL get the URL to download the artifact zip file of a specific artifact 67 // Post: /twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL 68 // Request 69 // { 70 // "workflow_run_backend_id": "21", 71 // "workflow_job_run_backend_id": "49", 72 // "name": "test" 73 // } 74 // Response 75 // { 76 // "signedUrl": "http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?sig=wHzFOwpF-6220-5CA0CIRmAX9VbiTC2Mji89UOqo1E8=&expires=2024-01-23+21%3A51%3A56.872846295+%2B0100+CET&artifactName=test&taskID=76" 77 // } 78 // 2.3. Download Zip from Blobstorage (unauthenticated request) 79 // GET: http://localhost:3000/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact?sig=wHzFOwpF-6220-5CA0CIRmAX9VbiTC2Mji89UOqo1E8=&expires=2024-01-23+21%3A51%3A56.872846295+%2B0100+CET&artifactName=test&taskID=76 80 81 import ( 82 "crypto/hmac" 83 "crypto/sha256" 84 "encoding/base64" 85 "fmt" 86 "io" 87 "net/http" 88 "net/url" 89 "strconv" 90 "strings" 91 "time" 92 93 "code.gitea.io/gitea/models/actions" 94 "code.gitea.io/gitea/models/db" 95 "code.gitea.io/gitea/modules/httplib" 96 "code.gitea.io/gitea/modules/log" 97 "code.gitea.io/gitea/modules/setting" 98 "code.gitea.io/gitea/modules/storage" 99 "code.gitea.io/gitea/modules/util" 100 "code.gitea.io/gitea/modules/web" 101 "code.gitea.io/gitea/services/context" 102 103 "google.golang.org/protobuf/encoding/protojson" 104 protoreflect "google.golang.org/protobuf/reflect/protoreflect" 105 "google.golang.org/protobuf/types/known/timestamppb" 106 ) 107 108 const ( 109 ArtifactV4RouteBase = "/twirp/github.actions.results.api.v1.ArtifactService" 110 ArtifactV4ContentEncoding = "application/zip" 111 ) 112 113 type artifactV4Routes struct { 114 prefix string 115 fs storage.ObjectStorage 116 } 117 118 func ArtifactV4Contexter() func(next http.Handler) http.Handler { 119 return func(next http.Handler) http.Handler { 120 return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { 121 base, baseCleanUp := context.NewBaseContext(resp, req) 122 defer baseCleanUp() 123 124 ctx := &ArtifactContext{Base: base} 125 ctx.AppendContextValue(artifactContextKey, ctx) 126 127 next.ServeHTTP(ctx.Resp, ctx.Req) 128 }) 129 } 130 } 131 132 func ArtifactsV4Routes(prefix string) *web.Route { 133 m := web.NewRoute() 134 135 r := artifactV4Routes{ 136 prefix: prefix, 137 fs: storage.ActionsArtifacts, 138 } 139 140 m.Group("", func() { 141 m.Post("CreateArtifact", r.createArtifact) 142 m.Post("FinalizeArtifact", r.finalizeArtifact) 143 m.Post("ListArtifacts", r.listArtifacts) 144 m.Post("GetSignedArtifactURL", r.getSignedArtifactURL) 145 m.Post("DeleteArtifact", r.deleteArtifact) 146 }, ArtifactContexter()) 147 m.Group("", func() { 148 m.Put("UploadArtifact", r.uploadArtifact) 149 m.Get("DownloadArtifact", r.downloadArtifact) 150 }, ArtifactV4Contexter()) 151 152 return m 153 } 154 155 func (r artifactV4Routes) buildSignature(endp, expires, artifactName string, taskID int64) []byte { 156 mac := hmac.New(sha256.New, setting.GetGeneralTokenSigningSecret()) 157 mac.Write([]byte(endp)) 158 mac.Write([]byte(expires)) 159 mac.Write([]byte(artifactName)) 160 mac.Write([]byte(fmt.Sprint(taskID))) 161 return mac.Sum(nil) 162 } 163 164 func (r artifactV4Routes) buildArtifactURL(ctx *ArtifactContext, endp, artifactName string, taskID int64) string { 165 expires := time.Now().Add(60 * time.Minute).Format("2006-01-02 15:04:05.999999999 -0700 MST") 166 uploadURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), "/") + strings.TrimSuffix(r.prefix, "/") + 167 "/" + endp + "?sig=" + base64.URLEncoding.EncodeToString(r.buildSignature(endp, expires, artifactName, taskID)) + "&expires=" + url.QueryEscape(expires) + "&artifactName=" + url.QueryEscape(artifactName) + "&taskID=" + fmt.Sprint(taskID) 168 return uploadURL 169 } 170 171 func (r artifactV4Routes) verifySignature(ctx *ArtifactContext, endp string) (*actions.ActionTask, string, bool) { 172 rawTaskID := ctx.Req.URL.Query().Get("taskID") 173 sig := ctx.Req.URL.Query().Get("sig") 174 expires := ctx.Req.URL.Query().Get("expires") 175 artifactName := ctx.Req.URL.Query().Get("artifactName") 176 dsig, _ := base64.URLEncoding.DecodeString(sig) 177 taskID, _ := strconv.ParseInt(rawTaskID, 10, 64) 178 179 expecedsig := r.buildSignature(endp, expires, artifactName, taskID) 180 if !hmac.Equal(dsig, expecedsig) { 181 log.Error("Error unauthorized") 182 ctx.Error(http.StatusUnauthorized, "Error unauthorized") 183 return nil, "", false 184 } 185 t, err := time.Parse("2006-01-02 15:04:05.999999999 -0700 MST", expires) 186 if err != nil || t.Before(time.Now()) { 187 log.Error("Error link expired") 188 ctx.Error(http.StatusUnauthorized, "Error link expired") 189 return nil, "", false 190 } 191 task, err := actions.GetTaskByID(ctx, taskID) 192 if err != nil { 193 log.Error("Error runner api getting task by ID: %v", err) 194 ctx.Error(http.StatusInternalServerError, "Error runner api getting task by ID") 195 return nil, "", false 196 } 197 if task.Status != actions.StatusRunning { 198 log.Error("Error runner api getting task: task is not running") 199 ctx.Error(http.StatusInternalServerError, "Error runner api getting task: task is not running") 200 return nil, "", false 201 } 202 if err := task.LoadJob(ctx); err != nil { 203 log.Error("Error runner api getting job: %v", err) 204 ctx.Error(http.StatusInternalServerError, "Error runner api getting job") 205 return nil, "", false 206 } 207 return task, artifactName, true 208 } 209 210 func (r *artifactV4Routes) getArtifactByName(ctx *ArtifactContext, runID int64, name string) (*actions.ActionArtifact, error) { 211 var art actions.ActionArtifact 212 has, err := db.GetEngine(ctx).Where("run_id = ? AND artifact_name = ? AND artifact_path = ? AND content_encoding = ?", runID, name, name+".zip", ArtifactV4ContentEncoding).Get(&art) 213 if err != nil { 214 return nil, err 215 } else if !has { 216 return nil, util.ErrNotExist 217 } 218 return &art, nil 219 } 220 221 func (r *artifactV4Routes) parseProtbufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) bool { 222 body, err := io.ReadAll(ctx.Req.Body) 223 if err != nil { 224 log.Error("Error decode request body: %v", err) 225 ctx.Error(http.StatusInternalServerError, "Error decode request body") 226 return false 227 } 228 err = protojson.Unmarshal(body, req) 229 if err != nil { 230 log.Error("Error decode request body: %v", err) 231 ctx.Error(http.StatusInternalServerError, "Error decode request body") 232 return false 233 } 234 return true 235 } 236 237 func (r *artifactV4Routes) sendProtbufBody(ctx *ArtifactContext, req protoreflect.ProtoMessage) { 238 resp, err := protojson.Marshal(req) 239 if err != nil { 240 log.Error("Error encode response body: %v", err) 241 ctx.Error(http.StatusInternalServerError, "Error encode response body") 242 return 243 } 244 ctx.Resp.Header().Set("Content-Type", "application/json;charset=utf-8") 245 ctx.Resp.WriteHeader(http.StatusOK) 246 _, _ = ctx.Resp.Write(resp) 247 } 248 249 func (r *artifactV4Routes) createArtifact(ctx *ArtifactContext) { 250 var req CreateArtifactRequest 251 252 if ok := r.parseProtbufBody(ctx, &req); !ok { 253 return 254 } 255 _, _, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) 256 if !ok { 257 return 258 } 259 260 artifactName := req.Name 261 262 rententionDays := setting.Actions.ArtifactRetentionDays 263 if req.ExpiresAt != nil { 264 rententionDays = int64(time.Until(req.ExpiresAt.AsTime()).Hours() / 24) 265 } 266 // create or get artifact with name and path 267 artifact, err := actions.CreateArtifact(ctx, ctx.ActionTask, artifactName, artifactName+".zip", rententionDays) 268 if err != nil { 269 log.Error("Error create or get artifact: %v", err) 270 ctx.Error(http.StatusInternalServerError, "Error create or get artifact") 271 return 272 } 273 artifact.ContentEncoding = ArtifactV4ContentEncoding 274 if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { 275 log.Error("Error UpdateArtifactByID: %v", err) 276 ctx.Error(http.StatusInternalServerError, "Error UpdateArtifactByID") 277 return 278 } 279 280 respData := CreateArtifactResponse{ 281 Ok: true, 282 SignedUploadUrl: r.buildArtifactURL(ctx, "UploadArtifact", artifactName, ctx.ActionTask.ID), 283 } 284 r.sendProtbufBody(ctx, &respData) 285 } 286 287 func (r *artifactV4Routes) uploadArtifact(ctx *ArtifactContext) { 288 task, artifactName, ok := r.verifySignature(ctx, "UploadArtifact") 289 if !ok { 290 return 291 } 292 293 comp := ctx.Req.URL.Query().Get("comp") 294 switch comp { 295 case "block", "appendBlock": 296 // get artifact by name 297 artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName) 298 if err != nil { 299 log.Error("Error artifact not found: %v", err) 300 ctx.Error(http.StatusNotFound, "Error artifact not found") 301 return 302 } 303 304 if comp == "block" { 305 artifact.FileSize = 0 306 artifact.FileCompressedSize = 0 307 } 308 309 _, err = appendUploadChunk(r.fs, ctx, artifact, artifact.FileSize, ctx.Req.ContentLength, artifact.RunID) 310 if err != nil { 311 log.Error("Error runner api getting task: task is not running") 312 ctx.Error(http.StatusInternalServerError, "Error runner api getting task: task is not running") 313 return 314 } 315 artifact.FileCompressedSize += ctx.Req.ContentLength 316 artifact.FileSize += ctx.Req.ContentLength 317 if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { 318 log.Error("Error UpdateArtifactByID: %v", err) 319 ctx.Error(http.StatusInternalServerError, "Error UpdateArtifactByID") 320 return 321 } 322 ctx.JSON(http.StatusCreated, "appended") 323 case "blocklist": 324 ctx.JSON(http.StatusCreated, "created") 325 } 326 } 327 328 func (r *artifactV4Routes) finalizeArtifact(ctx *ArtifactContext) { 329 var req FinalizeArtifactRequest 330 331 if ok := r.parseProtbufBody(ctx, &req); !ok { 332 return 333 } 334 _, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) 335 if !ok { 336 return 337 } 338 339 // get artifact by name 340 artifact, err := r.getArtifactByName(ctx, runID, req.Name) 341 if err != nil { 342 log.Error("Error artifact not found: %v", err) 343 ctx.Error(http.StatusNotFound, "Error artifact not found") 344 return 345 } 346 chunkMap, err := listChunksByRunID(r.fs, runID) 347 if err != nil { 348 log.Error("Error merge chunks: %v", err) 349 ctx.Error(http.StatusInternalServerError, "Error merge chunks") 350 return 351 } 352 chunks, ok := chunkMap[artifact.ID] 353 if !ok { 354 log.Error("Error merge chunks") 355 ctx.Error(http.StatusInternalServerError, "Error merge chunks") 356 return 357 } 358 checksum := "" 359 if req.Hash != nil { 360 checksum = req.Hash.Value 361 } 362 if err := mergeChunksForArtifact(ctx, chunks, r.fs, artifact, checksum); err != nil { 363 log.Error("Error merge chunks: %v", err) 364 ctx.Error(http.StatusInternalServerError, "Error merge chunks") 365 return 366 } 367 368 respData := FinalizeArtifactResponse{ 369 Ok: true, 370 ArtifactId: artifact.ID, 371 } 372 r.sendProtbufBody(ctx, &respData) 373 } 374 375 func (r *artifactV4Routes) listArtifacts(ctx *ArtifactContext) { 376 var req ListArtifactsRequest 377 378 if ok := r.parseProtbufBody(ctx, &req); !ok { 379 return 380 } 381 _, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) 382 if !ok { 383 return 384 } 385 386 artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{RunID: runID}) 387 if err != nil { 388 log.Error("Error getting artifacts: %v", err) 389 ctx.Error(http.StatusInternalServerError, err.Error()) 390 return 391 } 392 if len(artifacts) == 0 { 393 log.Debug("[artifact] handleListArtifacts, no artifacts") 394 ctx.Error(http.StatusNotFound) 395 return 396 } 397 398 list := []*ListArtifactsResponse_MonolithArtifact{} 399 400 table := map[string]*ListArtifactsResponse_MonolithArtifact{} 401 for _, artifact := range artifacts { 402 if _, ok := table[artifact.ArtifactName]; ok || req.IdFilter != nil && artifact.ID != req.IdFilter.Value || req.NameFilter != nil && artifact.ArtifactName != req.NameFilter.Value || artifact.ArtifactName+".zip" != artifact.ArtifactPath || artifact.ContentEncoding != ArtifactV4ContentEncoding { 403 table[artifact.ArtifactName] = nil 404 continue 405 } 406 407 table[artifact.ArtifactName] = &ListArtifactsResponse_MonolithArtifact{ 408 Name: artifact.ArtifactName, 409 CreatedAt: timestamppb.New(artifact.CreatedUnix.AsTime()), 410 DatabaseId: artifact.ID, 411 WorkflowRunBackendId: req.WorkflowRunBackendId, 412 WorkflowJobRunBackendId: req.WorkflowJobRunBackendId, 413 Size: artifact.FileSize, 414 } 415 } 416 for _, artifact := range table { 417 if artifact != nil { 418 list = append(list, artifact) 419 } 420 } 421 422 respData := ListArtifactsResponse{ 423 Artifacts: list, 424 } 425 r.sendProtbufBody(ctx, &respData) 426 } 427 428 func (r *artifactV4Routes) getSignedArtifactURL(ctx *ArtifactContext) { 429 var req GetSignedArtifactURLRequest 430 431 if ok := r.parseProtbufBody(ctx, &req); !ok { 432 return 433 } 434 _, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) 435 if !ok { 436 return 437 } 438 439 artifactName := req.Name 440 441 // get artifact by name 442 artifact, err := r.getArtifactByName(ctx, runID, artifactName) 443 if err != nil { 444 log.Error("Error artifact not found: %v", err) 445 ctx.Error(http.StatusNotFound, "Error artifact not found") 446 return 447 } 448 449 respData := GetSignedArtifactURLResponse{} 450 451 if setting.Actions.ArtifactStorage.MinioConfig.ServeDirect { 452 u, err := storage.ActionsArtifacts.URL(artifact.StoragePath, artifact.ArtifactPath) 453 if u != nil && err == nil { 454 respData.SignedUrl = u.String() 455 } 456 } 457 if respData.SignedUrl == "" { 458 respData.SignedUrl = r.buildArtifactURL(ctx, "DownloadArtifact", artifactName, ctx.ActionTask.ID) 459 } 460 r.sendProtbufBody(ctx, &respData) 461 } 462 463 func (r *artifactV4Routes) downloadArtifact(ctx *ArtifactContext) { 464 task, artifactName, ok := r.verifySignature(ctx, "DownloadArtifact") 465 if !ok { 466 return 467 } 468 469 // get artifact by name 470 artifact, err := r.getArtifactByName(ctx, task.Job.RunID, artifactName) 471 if err != nil { 472 log.Error("Error artifact not found: %v", err) 473 ctx.Error(http.StatusNotFound, "Error artifact not found") 474 return 475 } 476 477 file, _ := r.fs.Open(artifact.StoragePath) 478 479 _, _ = io.Copy(ctx.Resp, file) 480 } 481 482 func (r *artifactV4Routes) deleteArtifact(ctx *ArtifactContext) { 483 var req DeleteArtifactRequest 484 485 if ok := r.parseProtbufBody(ctx, &req); !ok { 486 return 487 } 488 _, runID, ok := validateRunIDV4(ctx, req.WorkflowRunBackendId) 489 if !ok { 490 return 491 } 492 493 // get artifact by name 494 artifact, err := r.getArtifactByName(ctx, runID, req.Name) 495 if err != nil { 496 log.Error("Error artifact not found: %v", err) 497 ctx.Error(http.StatusNotFound, "Error artifact not found") 498 return 499 } 500 501 err = actions.SetArtifactNeedDelete(ctx, runID, req.Name) 502 if err != nil { 503 log.Error("Error deleting artifacts: %v", err) 504 ctx.Error(http.StatusInternalServerError, err.Error()) 505 return 506 } 507 508 respData := DeleteArtifactResponse{ 509 Ok: true, 510 ArtifactId: artifact.ID, 511 } 512 r.sendProtbufBody(ctx, &respData) 513 }