code.gitea.io/gitea@v1.21.7/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/modules/context" 74 "code.gitea.io/gitea/modules/json" 75 "code.gitea.io/gitea/modules/log" 76 "code.gitea.io/gitea/modules/setting" 77 "code.gitea.io/gitea/modules/storage" 78 "code.gitea.io/gitea/modules/util" 79 "code.gitea.io/gitea/modules/web" 80 web_types "code.gitea.io/gitea/modules/web/types" 81 ) 82 83 const artifactRouteBase = "/_apis/pipelines/workflows/{run_id}/artifacts" 84 85 type artifactContextKeyType struct{} 86 87 var artifactContextKey = artifactContextKeyType{} 88 89 type ArtifactContext struct { 90 *context.Base 91 92 ActionTask *actions.ActionTask 93 } 94 95 func init() { 96 web.RegisterResponseStatusProvider[*ArtifactContext](func(req *http.Request) web_types.ResponseStatusProvider { 97 return req.Context().Value(artifactContextKey).(*ArtifactContext) 98 }) 99 } 100 101 func ArtifactsRoutes(prefix string) *web.Route { 102 m := web.NewRoute() 103 m.Use(ArtifactContexter()) 104 105 r := artifactRoutes{ 106 prefix: prefix, 107 fs: storage.ActionsArtifacts, 108 } 109 110 m.Group(artifactRouteBase, func() { 111 // retrieve, list and confirm artifacts 112 m.Combo("").Get(r.listArtifacts).Post(r.getUploadArtifactURL).Patch(r.comfirmUploadArtifact) 113 // handle container artifacts list and download 114 m.Put("/{artifact_hash}/upload", r.uploadArtifact) 115 // handle artifacts download 116 m.Get("/{artifact_hash}/download_url", r.getDownloadArtifactURL) 117 m.Get("/{artifact_id}/download", r.downloadArtifact) 118 }) 119 120 return m 121 } 122 123 func ArtifactContexter() func(next http.Handler) http.Handler { 124 return func(next http.Handler) http.Handler { 125 return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { 126 base, baseCleanUp := context.NewBaseContext(resp, req) 127 defer baseCleanUp() 128 129 ctx := &ArtifactContext{Base: base} 130 ctx.AppendContextValue(artifactContextKey, ctx) 131 132 // action task call server api with Bearer ACTIONS_RUNTIME_TOKEN 133 // we should verify the ACTIONS_RUNTIME_TOKEN 134 authHeader := req.Header.Get("Authorization") 135 if len(authHeader) == 0 || !strings.HasPrefix(authHeader, "Bearer ") { 136 ctx.Error(http.StatusUnauthorized, "Bad authorization header") 137 return 138 } 139 140 authToken := strings.TrimPrefix(authHeader, "Bearer ") 141 task, err := actions.GetRunningTaskByToken(req.Context(), authToken) 142 if err != nil { 143 log.Error("Error runner api getting task: %v", err) 144 ctx.Error(http.StatusInternalServerError, "Error runner api getting task") 145 return 146 } 147 148 if err := task.LoadJob(req.Context()); err != nil { 149 log.Error("Error runner api getting job: %v", err) 150 ctx.Error(http.StatusInternalServerError, "Error runner api getting job") 151 return 152 } 153 154 ctx.ActionTask = task 155 next.ServeHTTP(ctx.Resp, ctx.Req) 156 }) 157 } 158 } 159 160 type artifactRoutes struct { 161 prefix string 162 fs storage.ObjectStorage 163 } 164 165 func (ar artifactRoutes) buildArtifactURL(runID int64, artifactHash, suffix string) string { 166 uploadURL := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ar.prefix, "/") + 167 strings.ReplaceAll(artifactRouteBase, "{run_id}", strconv.FormatInt(runID, 10)) + 168 "/" + artifactHash + "/" + suffix 169 return uploadURL 170 } 171 172 type getUploadArtifactRequest struct { 173 Type string 174 Name string 175 RetentionDays int64 176 } 177 178 type getUploadArtifactResponse struct { 179 FileContainerResourceURL string `json:"fileContainerResourceUrl"` 180 } 181 182 // getUploadArtifactURL generates a URL for uploading an artifact 183 func (ar artifactRoutes) getUploadArtifactURL(ctx *ArtifactContext) { 184 _, runID, ok := validateRunID(ctx) 185 if !ok { 186 return 187 } 188 189 var req getUploadArtifactRequest 190 if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil { 191 log.Error("Error decode request body: %v", err) 192 ctx.Error(http.StatusInternalServerError, "Error decode request body") 193 return 194 } 195 196 // set retention days 197 retentionQuery := "" 198 if req.RetentionDays > 0 { 199 retentionQuery = fmt.Sprintf("?retentionDays=%d", req.RetentionDays) 200 } 201 202 // use md5(artifact_name) to create upload url 203 artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(req.Name))) 204 resp := getUploadArtifactResponse{ 205 FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "upload"+retentionQuery), 206 } 207 log.Debug("[artifact] get upload url: %s", resp.FileContainerResourceURL) 208 ctx.JSON(http.StatusOK, resp) 209 } 210 211 func (ar artifactRoutes) uploadArtifact(ctx *ArtifactContext) { 212 task, runID, ok := validateRunID(ctx) 213 if !ok { 214 return 215 } 216 artifactName, artifactPath, ok := parseArtifactItemPath(ctx) 217 if !ok { 218 return 219 } 220 221 // get upload file size 222 fileRealTotalSize, contentLength, err := getUploadFileSize(ctx) 223 if err != nil { 224 log.Error("Error get upload file size: %v", err) 225 ctx.Error(http.StatusInternalServerError, "Error get upload file size") 226 return 227 } 228 229 // get artifact retention days 230 expiredDays := setting.Actions.ArtifactRetentionDays 231 if queryRetentionDays := ctx.Req.URL.Query().Get("retentionDays"); queryRetentionDays != "" { 232 expiredDays, err = strconv.ParseInt(queryRetentionDays, 10, 64) 233 if err != nil { 234 log.Error("Error parse retention days: %v", err) 235 ctx.Error(http.StatusBadRequest, "Error parse retention days") 236 return 237 } 238 } 239 log.Debug("[artifact] upload chunk, name: %s, path: %s, size: %d, retention days: %d", 240 artifactName, artifactPath, fileRealTotalSize, expiredDays) 241 242 // create or get artifact with name and path 243 artifact, err := actions.CreateArtifact(ctx, task, artifactName, artifactPath, expiredDays) 244 if err != nil { 245 log.Error("Error create or get artifact: %v", err) 246 ctx.Error(http.StatusInternalServerError, "Error create or get artifact") 247 return 248 } 249 250 // save chunk to storage, if success, return chunk stotal size 251 // if artifact is not gzip when uploading, chunksTotalSize == fileRealTotalSize 252 // if artifact is gzip when uploading, chunksTotalSize < fileRealTotalSize 253 chunksTotalSize, err := saveUploadChunk(ar.fs, ctx, artifact, contentLength, runID) 254 if err != nil { 255 log.Error("Error save upload chunk: %v", err) 256 ctx.Error(http.StatusInternalServerError, "Error save upload chunk") 257 return 258 } 259 260 // update artifact size if zero or not match, over write artifact size 261 if artifact.FileSize == 0 || 262 artifact.FileCompressedSize == 0 || 263 artifact.FileSize != fileRealTotalSize || 264 artifact.FileCompressedSize != chunksTotalSize { 265 artifact.FileSize = fileRealTotalSize 266 artifact.FileCompressedSize = chunksTotalSize 267 artifact.ContentEncoding = ctx.Req.Header.Get("Content-Encoding") 268 if err := actions.UpdateArtifactByID(ctx, artifact.ID, artifact); err != nil { 269 log.Error("Error update artifact: %v", err) 270 ctx.Error(http.StatusInternalServerError, "Error update artifact") 271 return 272 } 273 log.Debug("[artifact] update artifact size, artifact_id: %d, size: %d, compressed size: %d", 274 artifact.ID, artifact.FileSize, artifact.FileCompressedSize) 275 } 276 277 ctx.JSON(http.StatusOK, map[string]string{ 278 "message": "success", 279 }) 280 } 281 282 // comfirmUploadArtifact comfirm upload artifact. 283 // if all chunks are uploaded, merge them to one file. 284 func (ar artifactRoutes) comfirmUploadArtifact(ctx *ArtifactContext) { 285 _, runID, ok := validateRunID(ctx) 286 if !ok { 287 return 288 } 289 artifactName := ctx.Req.URL.Query().Get("artifactName") 290 if artifactName == "" { 291 log.Error("Error artifact name is empty") 292 ctx.Error(http.StatusBadRequest, "Error artifact name is empty") 293 return 294 } 295 if err := mergeChunksForRun(ctx, ar.fs, runID, artifactName); err != nil { 296 log.Error("Error merge chunks: %v", err) 297 ctx.Error(http.StatusInternalServerError, "Error merge chunks") 298 return 299 } 300 ctx.JSON(http.StatusOK, map[string]string{ 301 "message": "success", 302 }) 303 } 304 305 type ( 306 listArtifactsResponse struct { 307 Count int64 `json:"count"` 308 Value []listArtifactsResponseItem `json:"value"` 309 } 310 listArtifactsResponseItem struct { 311 Name string `json:"name"` 312 FileContainerResourceURL string `json:"fileContainerResourceUrl"` 313 } 314 ) 315 316 func (ar artifactRoutes) listArtifacts(ctx *ArtifactContext) { 317 _, runID, ok := validateRunID(ctx) 318 if !ok { 319 return 320 } 321 322 artifacts, err := actions.ListArtifactsByRunID(ctx, runID) 323 if err != nil { 324 log.Error("Error getting artifacts: %v", err) 325 ctx.Error(http.StatusInternalServerError, err.Error()) 326 return 327 } 328 if len(artifacts) == 0 { 329 log.Debug("[artifact] handleListArtifacts, no artifacts") 330 ctx.Error(http.StatusNotFound) 331 return 332 } 333 334 var ( 335 items []listArtifactsResponseItem 336 values = make(map[string]bool) 337 ) 338 339 for _, art := range artifacts { 340 if values[art.ArtifactName] { 341 continue 342 } 343 artifactHash := fmt.Sprintf("%x", md5.Sum([]byte(art.ArtifactName))) 344 item := listArtifactsResponseItem{ 345 Name: art.ArtifactName, 346 FileContainerResourceURL: ar.buildArtifactURL(runID, artifactHash, "download_url"), 347 } 348 items = append(items, item) 349 values[art.ArtifactName] = true 350 351 log.Debug("[artifact] handleListArtifacts, name: %s, url: %s", item.Name, item.FileContainerResourceURL) 352 } 353 354 respData := listArtifactsResponse{ 355 Count: int64(len(items)), 356 Value: items, 357 } 358 ctx.JSON(http.StatusOK, respData) 359 } 360 361 type ( 362 downloadArtifactResponse struct { 363 Value []downloadArtifactResponseItem `json:"value"` 364 } 365 downloadArtifactResponseItem struct { 366 Path string `json:"path"` 367 ItemType string `json:"itemType"` 368 ContentLocation string `json:"contentLocation"` 369 } 370 ) 371 372 // getDownloadArtifactURL generates download url for each artifact 373 func (ar artifactRoutes) getDownloadArtifactURL(ctx *ArtifactContext) { 374 _, runID, ok := validateRunID(ctx) 375 if !ok { 376 return 377 } 378 379 itemPath := util.PathJoinRel(ctx.Req.URL.Query().Get("itemPath")) 380 if !validateArtifactHash(ctx, itemPath) { 381 return 382 } 383 384 artifacts, err := actions.ListArtifactsByRunIDAndArtifactName(ctx, runID, itemPath) 385 if err != nil { 386 log.Error("Error getting artifacts: %v", err) 387 ctx.Error(http.StatusInternalServerError, err.Error()) 388 return 389 } 390 if len(artifacts) == 0 { 391 log.Debug("[artifact] getDownloadArtifactURL, no artifacts") 392 ctx.Error(http.StatusNotFound) 393 return 394 } 395 396 if itemPath != artifacts[0].ArtifactName { 397 log.Error("Error dismatch artifact name, itemPath: %v, artifact: %v", itemPath, artifacts[0].ArtifactName) 398 ctx.Error(http.StatusBadRequest, "Error dismatch artifact name") 399 return 400 } 401 402 var items []downloadArtifactResponseItem 403 for _, artifact := range artifacts { 404 downloadURL := ar.buildArtifactURL(runID, strconv.FormatInt(artifact.ID, 10), "download") 405 item := downloadArtifactResponseItem{ 406 Path: util.PathJoinRel(itemPath, artifact.ArtifactPath), 407 ItemType: "file", 408 ContentLocation: downloadURL, 409 } 410 log.Debug("[artifact] getDownloadArtifactURL, path: %s, url: %s", item.Path, item.ContentLocation) 411 items = append(items, item) 412 } 413 respData := downloadArtifactResponse{ 414 Value: items, 415 } 416 ctx.JSON(http.StatusOK, respData) 417 } 418 419 // downloadArtifact downloads artifact content 420 func (ar artifactRoutes) downloadArtifact(ctx *ArtifactContext) { 421 _, runID, ok := validateRunID(ctx) 422 if !ok { 423 return 424 } 425 426 artifactID := ctx.ParamsInt64("artifact_id") 427 artifact, err := actions.GetArtifactByID(ctx, artifactID) 428 if errors.Is(err, util.ErrNotExist) { 429 log.Error("Error getting artifact: %v", err) 430 ctx.Error(http.StatusNotFound, err.Error()) 431 return 432 } else if err != nil { 433 log.Error("Error getting artifact: %v", err) 434 ctx.Error(http.StatusInternalServerError, err.Error()) 435 return 436 } 437 if artifact.RunID != runID { 438 log.Error("Error dismatch runID and artifactID, task: %v, artifact: %v", runID, artifactID) 439 ctx.Error(http.StatusBadRequest, err.Error()) 440 return 441 } 442 443 fd, err := ar.fs.Open(artifact.StoragePath) 444 if err != nil { 445 log.Error("Error opening file: %v", err) 446 ctx.Error(http.StatusInternalServerError, err.Error()) 447 return 448 } 449 defer fd.Close() 450 451 // if artifact is compressed, set content-encoding header to gzip 452 if artifact.ContentEncoding == "gzip" { 453 ctx.Resp.Header().Set("Content-Encoding", "gzip") 454 } 455 log.Debug("[artifact] downloadArtifact, name: %s, path: %s, storage: %s, size: %d", artifact.ArtifactName, artifact.ArtifactPath, artifact.StoragePath, artifact.FileSize) 456 ctx.ServeContent(fd, &context.ServeHeaderOptions{ 457 Filename: artifact.ArtifactName, 458 LastModified: artifact.CreatedUnix.AsLocalTime(), 459 }) 460 }