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  }