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  }