code.gitea.io/gitea@v1.22.3/routers/api/actions/artifacts.go (about) 1 // Copyright 2023 The Gitea Authors. All rights reserved. 2 // SPDX-License-Identifier: MIT 3 4 package actions 5 6 // GitHub Actions Artifacts API Simple Description 7 // 8 // 1. Upload artifact 9 // 1.1. Post upload url 10 // Post: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts?api-version=6.0-preview 11 // Request: 12 // { 13 // "Type": "actions_storage", 14 // "Name": "artifact" 15 // } 16 // Response: 17 // { 18 // "fileContainerResourceUrl":"/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload" 19 // } 20 // it acquires an upload url for artifact upload 21 // 1.2. Upload artifact 22 // PUT: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload?itemPath=artifact%2Ffilename 23 // it upload chunk with headers: 24 // x-tfs-filelength: 1024 // total file length 25 // content-length: 1024 // chunk length 26 // x-actions-results-md5: md5sum // md5sum of chunk 27 // content-range: bytes 0-1023/1024 // chunk range 28 // we save all chunks to one storage directory after md5sum check 29 // 1.3. Confirm upload 30 // PATCH: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/upload?itemPath=artifact%2Ffilename 31 // it confirm upload and merge all chunks to one file, save this file to storage 32 // 33 // 2. Download artifact 34 // 2.1 list artifacts 35 // GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts?api-version=6.0-preview 36 // Response: 37 // { 38 // "count": 1, 39 // "value": [ 40 // { 41 // "name": "artifact", 42 // "fileContainerResourceUrl": "/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/path" 43 // } 44 // ] 45 // } 46 // 2.2 download artifact 47 // GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/path?api-version=6.0-preview 48 // Response: 49 // { 50 // "value": [ 51 // { 52 // "contentLocation": "/api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/download", 53 // "path": "artifact/filename", 54 // "itemType": "file" 55 // } 56 // ] 57 // } 58 // 2.3 download artifact file 59 // GET: /api/actions_pipeline/_apis/pipelines/workflows/{run_id}/artifacts/{artifact_id}/download?itemPath=artifact%2Ffilename 60 // Response: 61 // download file 62 // 63 64 import ( 65 "crypto/md5" 66 "errors" 67 "fmt" 68 "net/http" 69 "strconv" 70 "strings" 71 72 "code.gitea.io/gitea/models/actions" 73 "code.gitea.io/gitea/models/db" 74 "code.gitea.io/gitea/modules/httplib" 75 "code.gitea.io/gitea/modules/json" 76 "code.gitea.io/gitea/modules/log" 77 "code.gitea.io/gitea/modules/setting" 78 "code.gitea.io/gitea/modules/storage" 79 "code.gitea.io/gitea/modules/util" 80 "code.gitea.io/gitea/modules/web" 81 web_types "code.gitea.io/gitea/modules/web/types" 82 actions_service "code.gitea.io/gitea/services/actions" 83 "code.gitea.io/gitea/services/context" 84 ) 85 86 const artifactRouteBase = "/_apis/pipelines/workflows/{run_id}/artifacts" 87 88 type artifactContextKeyType struct{} 89 90 var artifactContextKey = artifactContextKeyType{} 91 92 type ArtifactContext struct { 93 *context.Base 94 95 ActionTask *actions.ActionTask 96 } 97 98 func init() { 99 web.RegisterResponseStatusProvider[*ArtifactContext](func(req *http.Request) web_types.ResponseStatusProvider { 100 return req.Context().Value(artifactContextKey).(*ArtifactContext) 101 }) 102 } 103 104 func ArtifactsRoutes(prefix string) *web.Route { 105 m := web.NewRoute() 106 m.Use(ArtifactContexter()) 107 108 r := artifactRoutes{ 109 prefix: prefix, 110 fs: storage.ActionsArtifacts, 111 } 112 113 m.Group(artifactRouteBase, func() { 114 // retrieve, list and confirm artifacts 115 m.Combo("").Get(r.listArtifacts).Post(r.getUploadArtifactURL).Patch(r.comfirmUploadArtifact) 116 // handle container artifacts list and download 117 m.Put("/{artifact_hash}/upload", r.uploadArtifact) 118 // handle artifacts download 119 m.Get("/{artifact_hash}/download_url", r.getDownloadArtifactURL) 120 m.Get("/{artifact_id}/download", r.downloadArtifact) 121 }) 122 123 return m 124 } 125 126 func ArtifactContexter() func(next http.Handler) http.Handler { 127 return func(next http.Handler) http.Handler { 128 return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { 129 base, baseCleanUp := context.NewBaseContext(resp, req) 130 defer baseCleanUp() 131 132 ctx := &ArtifactContext{Base: base} 133 ctx.AppendContextValue(artifactContextKey, ctx) 134 135 // action task call server api with Bearer ACTIONS_RUNTIME_TOKEN 136 // we should verify the ACTIONS_RUNTIME_TOKEN 137 authHeader := req.Header.Get("Authorization") 138 if len(authHeader) == 0 || !strings.HasPrefix(authHeader, "Bearer ") { 139 ctx.Error(http.StatusUnauthorized, "Bad authorization header") 140 return 141 } 142 143 // New act_runner uses jwt to authenticate 144 tID, err := actions_service.ParseAuthorizationToken(req) 145 146 var task *actions.ActionTask 147 if err == nil { 148 task, err = actions.GetTaskByID(req.Context(), tID) 149 if err != nil { 150 log.Error("Error runner api getting task by ID: %v", err) 151 ctx.Error(http.StatusInternalServerError, "Error runner api getting task by ID") 152 return 153 } 154 if task.Status != actions.StatusRunning { 155 log.Error("Error runner api getting task: task is not running") 156 ctx.Error(http.StatusInternalServerError, "Error runner api getting task: task is not running") 157 return 158 } 159 } else { 160 // Old act_runner uses GITEA_TOKEN to authenticate 161 authToken := strings.TrimPrefix(authHeader, "Bearer ") 162 163 task, err = actions.GetRunningTaskByToken(req.Context(), authToken) 164 if err != nil { 165 log.Error("Error runner api getting task: %v", err) 166 ctx.Error(http.StatusInternalServerError, "Error runner api getting task") 167 return 168 } 169 } 170 171 if err := task.LoadJob(req.Context()); err != nil { 172 log.Error("Error runner api getting job: %v", err) 173 ctx.Error(http.StatusInternalServerError, "Error runner api getting job") 174 return 175 } 176 177 ctx.ActionTask = task 178 next.ServeHTTP(ctx.Resp, ctx.Req) 179 }) 180 } 181 } 182 183 type artifactRoutes struct { 184 prefix string 185 fs storage.ObjectStorage 186 } 187 188 func (ar artifactRoutes) buildArtifactURL(ctx *ArtifactContext, runID int64, artifactHash, suffix string) string { 189 uploadURL := strings.TrimSuffix(httplib.GuessCurrentAppURL(ctx), "/") + strings.TrimSuffix(ar.prefix, "/") + 190 strings.ReplaceAll(artifactRouteBase, "{run_id}", strconv.FormatInt(runID, 10)) + 191 "/" + artifactHash + "/" + suffix 192 return uploadURL 193 } 194 195 type getUploadArtifactRequest struct { 196 Type string 197 Name string 198 RetentionDays int64 199 } 200 201 type getUploadArtifactResponse struct { 202 FileContainerResourceURL string `json:"fileContainerResourceUrl"` 203 } 204 205 // getUploadArtifactURL generates a URL for uploading an artifact 206 func (ar artifactRoutes) getUploadArtifactURL(ctx *ArtifactContext) { 207 _, runID, ok := validateRunID(ctx) 208 if !ok { 209 return 210 } 211 212 var req getUploadArtifactRequest 213 if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { 214 log.Error("Error decode request body: %v", err) 215 ctx.Error(http.StatusInternalServerError, "Error decode request body") 216 return 217 } 218 219 // set retention days 220 retentionQuery := "" 221 if req.RetentionDays > 0 { 222 retentionQuery = fmt.Sprintf("?retentionDays=%d", req.RetentionDays) 223 } 224 225 // use md5(artifact_name) to create upload url 226 artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(req.Name))) 227 resp := getUploadArtifactResponse{ 228 FileContainerResourceURL: ar.buildArtifactURL(ctx, runID, artifactHash, "upload"+retentionQuery), 229 } 230 log.Debug("[artifact] get upload url: %s", resp.FileContainerResourceURL) 231 ctx.JSON(http.StatusOK, resp) 232 } 233 234 func (ar artifactRoutes) uploadArtifact(ctx *ArtifactContext) { 235 task, runID, ok := validateRunID(ctx) 236 if !ok { 237 return 238 } 239 artifactName, artifactPath, ok := parseArtifactItemPath(ctx) 240 if !ok { 241 return 242 } 243 244 // get upload file size 245 fileRealTotalSize, contentLength, err := getUploadFileSize(ctx) 246 if err != nil { 247 log.Error("Error get upload file size: %v", err) 248 ctx.Error(http.StatusInternalServerError, "Error get upload file size") 249 return 250 } 251 252 // get artifact retention days 253 expiredDays := setting.Actions.ArtifactRetentionDays 254 if queryRetentionDays := ctx.Req.URL.Query().Get("retentionDays"); queryRetentionDays != "" { 255 expiredDays, err = strconv.ParseInt(queryRetentionDays, 10, 64) 256 if err != nil { 257 log.Error("Error parse retention days: %v", err) 258 ctx.Error(http.StatusBadRequest, "Error parse retention days") 259 return 260 } 261 } 262 log.Debug("[artifact] upload chunk, name: %s, path: %s, size: %d, retention days: %d", 263 artifactName, artifactPath, fileRealTotalSize, expiredDays) 264 265 // create or get artifact with name and path 266 artifact, err := actions.CreateArtifact(ctx, task, artifactName, artifactPath, expiredDays) 267 if err != nil { 268 log.Error("Error create or get artifact: %v", err) 269 ctx.Error(http.StatusInternalServerError, "Error create or get artifact") 270 return 271 } 272 273 // save chunk to storage, if success, return chunk stotal size 274 // if artifact is not gzip when uploading, chunksTotalSize == fileRealTotalSize 275 // if artifact is gzip when uploading, chunksTotalSize < fileRealTotalSize 276 chunksTotalSize, err := saveUploadChunk(ar.fs, ctx, artifact, contentLength, runID) 277 if err != nil { 278 log.Error("Error save upload chunk: %v", err) 279 ctx.Error(http.StatusInternalServerError, "Error save upload chunk") 280 return 281 } 282 283 // update artifact size if zero or not match, over write artifact size 284 if artifact.FileSize == 0 || 285 artifact.FileCompressedSize == 0 || 286 artifact.FileSize != fileRealTotalSize || 287 artifact.FileCompressedSize != chunksTotalSize { 288 artifact.FileSize = fileRealTotalSize 289 artifact.FileCompressedSize = chunksTotalSize 290 artifact.ContentEncoding = ctx.Req.Header.Get("Content-Encoding") 291 if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { 292 log.Error("Error update artifact: %v", err) 293 ctx.Error(http.StatusInternalServerError, "Error update artifact") 294 return 295 } 296 log.Debug("[artifact] update artifact size, artifact_id: %d, size: %d, compressed size: %d", 297 artifact.ID, artifact.FileSize, artifact.FileCompressedSize) 298 } 299 300 ctx.JSON(http.StatusOK, map[string]string{ 301 "message": "success", 302 }) 303 } 304 305 // comfirmUploadArtifact confirm upload artifact. 306 // if all chunks are uploaded, merge them to one file. 307 func (ar artifactRoutes) comfirmUploadArtifact(ctx *ArtifactContext) { 308 _, runID, ok := validateRunID(ctx) 309 if !ok { 310 return 311 } 312 artifactName := ctx.Req.URL.Query().Get("artifactName") 313 if artifactName == "" { 314 log.Error("Error artifact name is empty") 315 ctx.Error(http.StatusBadRequest, "Error artifact name is empty") 316 return 317 } 318 if err := mergeChunksForRun(ctx, ar.fs, runID, artifactName); err != nil { 319 log.Error("Error merge chunks: %v", err) 320 ctx.Error(http.StatusInternalServerError, "Error merge chunks") 321 return 322 } 323 ctx.JSON(http.StatusOK, map[string]string{ 324 "message": "success", 325 }) 326 } 327 328 type ( 329 listArtifactsResponse struct { 330 Count int64 `json:"count"` 331 Value []listArtifactsResponseItem `json:"value"` 332 } 333 listArtifactsResponseItem struct { 334 Name string `json:"name"` 335 FileContainerResourceURL string `json:"fileContainerResourceUrl"` 336 } 337 ) 338 339 func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) { 340 _, runID, ok := validateRunID(ctx) 341 if !ok { 342 return 343 } 344 345 artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{RunID: runID}) 346 if err != nil { 347 log.Error("Error getting artifacts: %v", err) 348 ctx.Error(http.StatusInternalServerError, err.Error()) 349 return 350 } 351 if len(artifacts) == 0 { 352 log.Debug("[artifact] handleListArtifacts, no artifacts") 353 ctx.Error(http.StatusNotFound) 354 return 355 } 356 357 var ( 358 items []listArtifactsResponseItem 359 values = make(map[string]bool) 360 ) 361 362 for _, art := range artifacts { 363 if values[art.ArtifactName] { 364 continue 365 } 366 artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(art.ArtifactName))) 367 item := listArtifactsResponseItem{ 368 Name: art.ArtifactName, 369 FileContainerResourceURL: ar.buildArtifactURL(ctx, runID, artifactHash, "download_url"), 370 } 371 items = append(items, item) 372 values[art.ArtifactName] = true 373 374 log.Debug("[artifact] handleListArtifacts, name: %s, url: %s", item.Name, item.FileContainerResourceURL) 375 } 376 377 respData := listArtifactsResponse{ 378 Count: int64(len(items)), 379 Value: items, 380 } 381 ctx.JSON(http.StatusOK, respData) 382 } 383 384 type ( 385 downloadArtifactResponse struct { 386 Value []downloadArtifactResponseItem `json:"value"` 387 } 388 downloadArtifactResponseItem struct { 389 Path string `json:"path"` 390 ItemType string `json:"itemType"` 391 ContentLocation string `json:"contentLocation"` 392 } 393 ) 394 395 // getDownloadArtifactURL generates download url for each artifact 396 func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) { 397 _, runID, ok := validateRunID(ctx) 398 if !ok { 399 return 400 } 401 402 itemPath := util.PathJoinRel(ctx.Req.URL.Query().Get("itemPath")) 403 if !validateArtifactHash(ctx, itemPath) { 404 return 405 } 406 407 artifacts, err := db.Find[actions.ActionArtifact](ctx, actions.FindArtifactsOptions{ 408 RunID: runID, 409 ArtifactName: itemPath, 410 }) 411 if err != nil { 412 log.Error("Error getting artifacts: %v", err) 413 ctx.Error(http.StatusInternalServerError, err.Error()) 414 return 415 } 416 if len(artifacts) == 0 { 417 log.Debug("[artifact] getDownloadArtifactURL, no artifacts") 418 ctx.Error(http.StatusNotFound) 419 return 420 } 421 422 if itemPath != artifacts[0].ArtifactName { 423 log.Error("Error dismatch artifact name, itemPath: %v, artifact: %v", itemPath, artifacts[0].ArtifactName) 424 ctx.Error(http.StatusBadRequest, "Error dismatch artifact name") 425 return 426 } 427 428 var items []downloadArtifactResponseItem 429 for _, artifact := range artifacts { 430 var downloadURL string 431 if setting.Actions.ArtifactStorage.MinioConfig.ServeDirect { 432 u, err := ar.fs.URL(artifact.StoragePath, artifact.ArtifactName) 433 if err != nil && !errors.Is(err, storage.ErrURLNotSupported) { 434 log.Error("Error getting serve direct url: %v", err) 435 } 436 if u != nil { 437 downloadURL = u.String() 438 } 439 } 440 if downloadURL == "" { 441 downloadURL = ar.buildArtifactURL(ctx, runID, strconv.FormatInt(artifact.ID, 10), "download") 442 } 443 item := downloadArtifactResponseItem{ 444 Path: util.PathJoinRel(itemPath, artifact.ArtifactPath), 445 ItemType: "file", 446 ContentLocation: downloadURL, 447 } 448 log.Debug("[artifact] getDownloadArtifactURL, path: %s, url: %s", item.Path, item.ContentLocation) 449 items = append(items, item) 450 } 451 respData := downloadArtifactResponse{ 452 Value: items, 453 } 454 ctx.JSON(http.StatusOK, respData) 455 } 456 457 // downloadArtifact downloads artifact content 458 func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) { 459 _, runID, ok := validateRunID(ctx) 460 if !ok { 461 return 462 } 463 464 artifactID := ctx.ParamsInt64("artifact_id") 465 artifact, exist, err := db.GetByID[actions.ActionArtifact](ctx, artifactID) 466 if err != nil { 467 log.Error("Error getting artifact: %v", err) 468 ctx.Error(http.StatusInternalServerError, err.Error()) 469 return 470 } 471 if !exist { 472 log.Error("artifact with ID %d does not exist", artifactID) 473 ctx.Error(http.StatusNotFound, fmt.Sprintf("artifact with ID %d does not exist", artifactID)) 474 return 475 } 476 if artifact.RunID != runID { 477 log.Error("Error mismatch runID and artifactID, task: %v, artifact: %v", runID, artifactID) 478 ctx.Error(http.StatusBadRequest) 479 return 480 } 481 482 fd, err := ar.fs.Open(artifact.StoragePath) 483 if err != nil { 484 log.Error("Error opening file: %v", err) 485 ctx.Error(http.StatusInternalServerError, err.Error()) 486 return 487 } 488 defer fd.Close() 489 490 // if artifact is compressed, set content-encoding header to gzip 491 if artifact.ContentEncoding == "gzip" { 492 ctx.Resp.Header().Set("Content-Encoding", "gzip") 493 } 494 log.Debug("[artifact] downloadArtifact, name: %s, path: %s, storage: %s, size: %d", artifact.ArtifactName, artifact.ArtifactPath, artifact.StoragePath, artifact.FileSize) 495 ctx.ServeContent(fd, &context.ServeHeaderOptions{ 496 Filename: artifact.ArtifactName, 497 LastModified: artifact.CreatedUnix.AsLocalTime(), 498 }) 499 }