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  }