mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-13 20:11:12 +00:00
c186cc4b8d
Fixes #38773 ## Background Artifacts became attempt-scoped in #37119, and the runner-facing artifact APIs filter strictly by the attempt of the running job. "Re-run failed jobs" creates a new attempt whose passed-through jobs never upload their artifacts again, so a re-run job that downloads one of them fails with "artifact not found". ## Fix The read paths (v3 and v4 list and download) now resolve artifacts across the running job's attempt plus the attempts it inherits from, and an inherited artifact is shadowed by a same-named one from a newer attempt. ## Note GitHub's documentation does not document these behaviors. The conclusions below are based on manual testing, so consistency with GitHub cannot be guaranteed. - In a "partial re-run", a job can download artifacts uploaded by an earlier attempt, every attempt keeps its own copy of a name, and a lookup by name resolves to the newest one. - A full "Re-run all jobs" never downloads artifacts from earlier attempts. --------- Co-authored-by: bircni <bircni@icloud.com>
799 lines
34 KiB
Go
799 lines
34 KiB
Go
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package integration
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"crypto/md5"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
|
actions_model "gitea.dev/models/actions"
|
|
auth_model "gitea.dev/models/auth"
|
|
"gitea.dev/models/db"
|
|
repo_model "gitea.dev/models/repo"
|
|
"gitea.dev/models/unittest"
|
|
user_model "gitea.dev/models/user"
|
|
"gitea.dev/modules/util"
|
|
"gitea.dev/tests"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type uploadArtifactResponse struct {
|
|
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
|
|
}
|
|
|
|
type getUploadArtifactRequest struct {
|
|
Type string
|
|
Name string
|
|
RetentionDays int64
|
|
}
|
|
|
|
func prepareTestEnvActionsArtifacts(t *testing.T) func() {
|
|
t.Helper()
|
|
f := tests.PrepareTestEnv(t, 1)
|
|
tests.PrepareArtifactsStorage(t)
|
|
return f
|
|
}
|
|
|
|
func getArtifactFixtureTask(t *testing.T) *actions_model.ActionTask {
|
|
t.Helper()
|
|
|
|
task, err := actions_model.GetRunningTaskByToken(t.Context(), "8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
require.NoError(t, err)
|
|
require.NoError(t, task.LoadJob(t.Context()))
|
|
ensureArtifactFixtureTaskSteps(t, task)
|
|
return task
|
|
}
|
|
|
|
func ensureArtifactFixtureTaskSteps(t *testing.T, task *actions_model.ActionTask) {
|
|
t.Helper()
|
|
|
|
steps, err := actions_model.GetTaskStepsByTaskID(t.Context(), task.ID)
|
|
require.NoError(t, err)
|
|
|
|
existingIndexes := make(map[int64]bool, len(steps))
|
|
for _, step := range steps {
|
|
existingIndexes[step.Index] = true
|
|
}
|
|
|
|
var stepsToInsert []*actions_model.ActionTaskStep
|
|
for _, idx := range []int64{0, 1} {
|
|
if existingIndexes[idx] {
|
|
continue
|
|
}
|
|
stepsToInsert = append(stepsToInsert, &actions_model.ActionTaskStep{
|
|
TaskID: task.ID,
|
|
Index: idx,
|
|
RepoID: task.RepoID,
|
|
Status: actions_model.StatusWaiting,
|
|
})
|
|
}
|
|
if len(stepsToInsert) == 0 {
|
|
return
|
|
}
|
|
|
|
_, err = db.GetEngine(t.Context()).Insert(stepsToInsert)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestActionsJobSummaryUpload(t *testing.T) {
|
|
defer prepareTestEnvActionsArtifacts(t)()
|
|
|
|
const runnerToken = "8061e833a55f6fc0157c98b883e91fcfeeb1a71a"
|
|
task := getArtifactFixtureTask(t)
|
|
summaryURL := func(stepIndex int64) string {
|
|
return fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/steps/%d/summary", task.Job.RunID, task.Job.ID, stepIndex)
|
|
}
|
|
putSummary := func(stepIndex int64, body, contentType string) *RequestWrapper {
|
|
return NewRequestWithBody(t, "PUT", summaryURL(stepIndex), strings.NewReader(body)).
|
|
AddTokenAuth(runnerToken).
|
|
SetHeader("Content-Type", contentType)
|
|
}
|
|
|
|
t.Run("success", func(t *testing.T) {
|
|
body := "### Uploaded summary\n\n- line one\n"
|
|
MakeRequest(t, putSummary(0, body, "text/markdown; charset=utf-8"), http.StatusOK)
|
|
|
|
summary, err := actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, actions_model.JobSummaryContentTypeMarkdown, summary.ContentType)
|
|
assert.Equal(t, body, summary.Content)
|
|
|
|
staleUpdated := summary.Updated - 60
|
|
_, err = db.GetEngine(t.Context()).ID(summary.ID).Cols("updated").Update(&actions_model.ActionRunJobSummary{Updated: staleUpdated})
|
|
require.NoError(t, err)
|
|
|
|
updatedBody := "### Updated summary\n\n- refreshed\n"
|
|
MakeRequest(t, putSummary(0, updatedBody, actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
|
|
|
|
summary, err = actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, updatedBody, summary.Content)
|
|
assert.Greater(t, summary.Updated, staleUpdated)
|
|
|
|
stepTwoBody := "### Second step summary\n\n- another step\n"
|
|
MakeRequest(t, putSummary(1, stepTwoBody, actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
|
|
|
|
summary, err = actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 1)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, stepTwoBody, summary.Content)
|
|
|
|
summaries, err := actions_model.ListActionRunJobSummaries(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, 0)
|
|
require.NoError(t, err)
|
|
require.Len(t, summaries, 2)
|
|
assert.Equal(t, int64(0), summaries[0].StepIndex)
|
|
assert.Equal(t, int64(1), summaries[1].StepIndex)
|
|
})
|
|
|
|
t.Run("invalid-content-type", func(t *testing.T) {
|
|
resp := MakeRequest(t, putSummary(0, "summary", "text/html"), http.StatusBadRequest)
|
|
assert.Contains(t, resp.Body.String(), "invalid summary content type")
|
|
})
|
|
|
|
t.Run("size-limit", func(t *testing.T) {
|
|
resp := MakeRequest(t, putSummary(0, strings.Repeat("a", actions_model.MaxJobSummarySize+1), actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
|
|
assert.Contains(t, resp.Body.String(), "invalid summary")
|
|
})
|
|
|
|
t.Run("aggregate-size-limit", func(t *testing.T) {
|
|
require.NoError(t, actions_model.UpsertActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0,
|
|
actions_model.JobSummaryContentTypeMarkdown, []byte(strings.Repeat("a", actions_model.MaxJobSummaryAggregateSize-1024))))
|
|
resp := MakeRequest(t, putSummary(1, strings.Repeat("b", 4096), actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
|
|
assert.Contains(t, resp.Body.String(), "aggregate size exceeded")
|
|
})
|
|
|
|
t.Run("job-mismatch", func(t *testing.T) {
|
|
req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/steps/0/summary", task.Job.RunID, task.Job.ID+1), strings.NewReader("summary")).
|
|
AddTokenAuth(runnerToken).
|
|
SetHeader("Content-Type", actions_model.JobSummaryContentTypeMarkdown)
|
|
resp := MakeRequest(t, req, http.StatusBadRequest)
|
|
assert.Contains(t, resp.Body.String(), "job_id mismatch")
|
|
})
|
|
|
|
t.Run("run-mismatch", func(t *testing.T) {
|
|
req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/jobs/%d/steps/0/summary", task.Job.RunID+1, task.Job.ID), strings.NewReader("summary")).
|
|
AddTokenAuth(runnerToken).
|
|
SetHeader("Content-Type", actions_model.JobSummaryContentTypeMarkdown)
|
|
resp := MakeRequest(t, req, http.StatusBadRequest)
|
|
assert.Contains(t, resp.Body.String(), "run-id does not match")
|
|
})
|
|
|
|
t.Run("invalid-step-index", func(t *testing.T) {
|
|
resp := MakeRequest(t, putSummary(-1, "summary", actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
|
|
assert.Contains(t, resp.Body.String(), "invalid step_index")
|
|
})
|
|
|
|
t.Run("step-index-mismatch", func(t *testing.T) {
|
|
resp := MakeRequest(t, putSummary(999, "summary", actions_model.JobSummaryContentTypeMarkdown), http.StatusBadRequest)
|
|
assert.Contains(t, resp.Body.String(), "step_index mismatch")
|
|
})
|
|
|
|
t.Run("empty-body-clears", func(t *testing.T) {
|
|
MakeRequest(t, putSummary(0, "### keep me", actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
|
|
MakeRequest(t, putSummary(0, "", actions_model.JobSummaryContentTypeMarkdown), http.StatusOK)
|
|
|
|
_, err := actions_model.GetActionRunJobSummary(t.Context(), task.Job.RepoID, task.Job.RunID, task.Job.RunAttemptID, task.Job.ID, 0)
|
|
require.ErrorIs(t, err, util.ErrNotExist)
|
|
})
|
|
}
|
|
|
|
func TestActionsArtifactUploadSingleFile(t *testing.T) {
|
|
defer prepareTestEnvActionsArtifacts(t)()
|
|
|
|
// acquire artifact upload url
|
|
req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{
|
|
Type: "actions_storage",
|
|
Name: "artifact",
|
|
}).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
|
|
assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
|
|
|
|
// get upload url
|
|
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
|
|
url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=artifact/abc-2.txt"
|
|
|
|
// upload artifact chunk
|
|
body := strings.Repeat("C", 1024)
|
|
req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)).
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
|
|
SetHeader("Content-Range", "bytes 0-1023/1024").
|
|
SetHeader("x-tfs-filelength", "1024").
|
|
SetHeader("x-actions-results-md5", "XVlf820rMInUi64wmMi6EA==") // base64(md5(body))
|
|
MakeRequest(t, req, http.StatusOK)
|
|
|
|
t.Logf("Create artifact confirm")
|
|
|
|
// confirm artifact upload
|
|
req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName=artifact-single").
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
MakeRequest(t, req, http.StatusOK)
|
|
}
|
|
|
|
func TestActionsArtifactUploadInvalidHash(t *testing.T) {
|
|
defer prepareTestEnvActionsArtifacts(t)()
|
|
|
|
// artifact id 54321 not exist
|
|
url := "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts/8e5b948a454515dbabfc7eb718ddddddd/upload?itemPath=artifact/abc.txt"
|
|
body := strings.Repeat("A", 1024)
|
|
req := NewRequestWithBody(t, "PUT", url, strings.NewReader(body)).
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
|
|
SetHeader("Content-Range", "bytes 0-1023/1024").
|
|
SetHeader("x-tfs-filelength", "1024").
|
|
SetHeader("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body))
|
|
resp := MakeRequest(t, req, http.StatusBadRequest)
|
|
assert.Contains(t, resp.Body.String(), "Invalid artifact hash")
|
|
}
|
|
|
|
func TestActionsArtifactConfirmUploadWithoutName(t *testing.T) {
|
|
defer prepareTestEnvActionsArtifacts(t)()
|
|
|
|
req := NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp := MakeRequest(t, req, http.StatusBadRequest)
|
|
assert.Contains(t, resp.Body.String(), "artifact name is empty")
|
|
}
|
|
|
|
func TestActionsArtifactUploadWithoutToken(t *testing.T) {
|
|
defer prepareTestEnvActionsArtifacts(t)()
|
|
|
|
req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/1/artifacts", nil)
|
|
MakeRequest(t, req, http.StatusUnauthorized)
|
|
}
|
|
|
|
type (
|
|
listArtifactsResponseItem struct {
|
|
Name string `json:"name"`
|
|
FileContainerResourceURL string `json:"fileContainerResourceUrl"`
|
|
}
|
|
listArtifactsResponse struct {
|
|
Count int64 `json:"count"`
|
|
Value []listArtifactsResponseItem `json:"value"`
|
|
}
|
|
downloadArtifactResponseItem struct {
|
|
Path string `json:"path"`
|
|
ItemType string `json:"itemType"`
|
|
ContentLocation string `json:"contentLocation"`
|
|
}
|
|
downloadArtifactResponse struct {
|
|
Value []downloadArtifactResponseItem `json:"value"`
|
|
}
|
|
)
|
|
|
|
func TestActionsArtifactDownload(t *testing.T) {
|
|
defer prepareTestEnvActionsArtifacts(t)()
|
|
|
|
req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
|
|
assert.Equal(t, int64(2), listResp.Count)
|
|
|
|
// Return list might be in any order. Get one file.
|
|
var artifactIdx int
|
|
for i, artifact := range listResp.Value {
|
|
if artifact.Name == "artifact-download" {
|
|
artifactIdx = i
|
|
break
|
|
}
|
|
}
|
|
assert.NotNil(t, artifactIdx)
|
|
assert.Equal(t, "artifact-download", listResp.Value[artifactIdx].Name)
|
|
assert.Contains(t, listResp.Value[artifactIdx].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
|
|
|
|
idx := strings.Index(listResp.Value[artifactIdx].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
|
|
url := listResp.Value[artifactIdx].FileContainerResourceURL[idx:] + "?itemPath=artifact-download"
|
|
req = NewRequest(t, "GET", url).
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
downloadResp := DecodeJSON(t, resp, &downloadArtifactResponse{})
|
|
assert.Len(t, downloadResp.Value, 1)
|
|
assert.Equal(t, "artifact-download/abc.txt", downloadResp.Value[0].Path)
|
|
assert.Equal(t, "file", downloadResp.Value[0].ItemType)
|
|
assert.Contains(t, downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
|
|
|
|
idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
|
|
url = downloadResp.Value[0].ContentLocation[idx:]
|
|
req = NewRequest(t, "GET", url).
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
|
|
body := strings.Repeat("A", 1024)
|
|
assert.Equal(t, body, resp.Body.String())
|
|
}
|
|
|
|
func TestActionsArtifactUploadMultipleFile(t *testing.T) {
|
|
defer prepareTestEnvActionsArtifacts(t)()
|
|
|
|
const testArtifactName = "multi-files"
|
|
|
|
// acquire artifact upload url
|
|
req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{
|
|
Type: "actions_storage",
|
|
Name: testArtifactName,
|
|
}).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
|
|
assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
|
|
|
|
type uploadingFile struct {
|
|
Path string
|
|
Content string
|
|
MD5 string
|
|
}
|
|
|
|
files := []uploadingFile{
|
|
{
|
|
Path: "abc-3.txt",
|
|
Content: strings.Repeat("D", 1024),
|
|
MD5: "9nqj7E8HZmfQtPifCJ5Zww==",
|
|
},
|
|
{
|
|
Path: "xyz/def-2.txt",
|
|
Content: strings.Repeat("E", 1024),
|
|
MD5: "/s1kKvxeHlUX85vaTaVxuA==",
|
|
},
|
|
}
|
|
|
|
for _, f := range files {
|
|
// get upload url
|
|
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
|
|
url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=" + testArtifactName + "/" + f.Path
|
|
|
|
// upload artifact chunk
|
|
req = NewRequestWithBody(t, "PUT", url, strings.NewReader(f.Content)).
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
|
|
SetHeader("Content-Range", "bytes 0-1023/1024").
|
|
SetHeader("x-tfs-filelength", "1024").
|
|
SetHeader("x-actions-results-md5", f.MD5) // base64(md5(body))
|
|
MakeRequest(t, req, http.StatusOK)
|
|
}
|
|
|
|
t.Logf("Create artifact confirm")
|
|
|
|
// confirm artifact upload
|
|
req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName="+testArtifactName).
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
MakeRequest(t, req, http.StatusOK)
|
|
}
|
|
|
|
func TestActionsArtifactDownloadMultiFiles(t *testing.T) {
|
|
defer prepareTestEnvActionsArtifacts(t)()
|
|
|
|
const testArtifactName = "multi-file-download"
|
|
|
|
req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
|
|
assert.Equal(t, int64(2), listResp.Count)
|
|
|
|
var fileContainerResourceURL string
|
|
for _, v := range listResp.Value {
|
|
if v.Name == testArtifactName {
|
|
fileContainerResourceURL = v.FileContainerResourceURL
|
|
break
|
|
}
|
|
}
|
|
assert.Contains(t, fileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
|
|
|
|
idx := strings.Index(fileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
|
|
url := fileContainerResourceURL[idx:] + "?itemPath=" + testArtifactName
|
|
req = NewRequest(t, "GET", url).
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
downloadResp := DecodeJSON(t, resp, &downloadArtifactResponse{})
|
|
assert.Len(t, downloadResp.Value, 2)
|
|
|
|
downloads := [][]string{{"multi-file-download/abc.txt", "B"}, {"multi-file-download/xyz/def.txt", "C"}}
|
|
for _, v := range downloadResp.Value {
|
|
var bodyChar string
|
|
var path string
|
|
for _, d := range downloads {
|
|
if v.Path == d[0] {
|
|
path = d[0]
|
|
bodyChar = d[1]
|
|
break
|
|
}
|
|
}
|
|
value := v
|
|
assert.Equal(t, path, value.Path)
|
|
assert.Equal(t, "file", value.ItemType)
|
|
assert.Contains(t, value.ContentLocation, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
|
|
|
|
idx = strings.Index(value.ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
|
|
url = value.ContentLocation[idx:]
|
|
req = NewRequest(t, "GET", url).
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
assert.Equal(t, strings.Repeat(bodyChar, 1024), resp.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestActionsArtifactUploadWithRetentionDays(t *testing.T) {
|
|
defer prepareTestEnvActionsArtifacts(t)()
|
|
|
|
// acquire artifact upload url
|
|
req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{
|
|
Type: "actions_storage",
|
|
Name: "artifact-retention-days",
|
|
RetentionDays: 9,
|
|
}).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
|
|
assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
|
|
assert.Contains(t, uploadResp.FileContainerResourceURL, "?retentionDays=9")
|
|
|
|
// get upload url
|
|
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
|
|
url := uploadResp.FileContainerResourceURL[idx:] + "&itemPath=artifact-retention-days/abc.txt"
|
|
|
|
// upload artifact chunk
|
|
body := strings.Repeat("A", 1024)
|
|
req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)).
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
|
|
SetHeader("Content-Range", "bytes 0-1023/1024").
|
|
SetHeader("x-tfs-filelength", "1024").
|
|
SetHeader("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body))
|
|
MakeRequest(t, req, http.StatusOK)
|
|
|
|
t.Logf("Create artifact confirm")
|
|
|
|
// confirm artifact upload
|
|
req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName=artifact-retention-days").
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
MakeRequest(t, req, http.StatusOK)
|
|
}
|
|
|
|
func TestActionsArtifactOverwrite(t *testing.T) {
|
|
defer prepareTestEnvActionsArtifacts(t)()
|
|
|
|
{
|
|
// download old artifact uploaded by tests above, it should 1024 A
|
|
req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
|
|
|
|
idx := strings.Index(listResp.Value[0].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
|
|
url := listResp.Value[0].FileContainerResourceURL[idx:] + "?itemPath=artifact-download"
|
|
req = NewRequest(t, "GET", url).
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
downloadResp := DecodeJSON(t, resp, &downloadArtifactResponse{})
|
|
|
|
idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
|
|
url = downloadResp.Value[0].ContentLocation[idx:]
|
|
req = NewRequest(t, "GET", url).
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
body := strings.Repeat("A", 1024)
|
|
assert.Equal(t, resp.Body.String(), body)
|
|
}
|
|
|
|
{
|
|
// upload same artifact, it uses 4096 B
|
|
req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{
|
|
Type: "actions_storage",
|
|
Name: "artifact-download",
|
|
}).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
|
|
|
|
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
|
|
url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=artifact-download/abc.txt"
|
|
body := strings.Repeat("B", 4096)
|
|
req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)).
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
|
|
SetHeader("Content-Range", "bytes 0-4095/4096").
|
|
SetHeader("x-tfs-filelength", "4096").
|
|
SetHeader("x-actions-results-md5", "wUypcJFeZCK5T6r4lfqzqg==") // base64(md5(body))
|
|
MakeRequest(t, req, http.StatusOK)
|
|
|
|
// confirm artifact upload
|
|
req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName=artifact-download").
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
MakeRequest(t, req, http.StatusOK)
|
|
}
|
|
|
|
{
|
|
// download artifact again, it should 4096 B
|
|
req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
|
|
|
|
var uploadedItem listArtifactsResponseItem
|
|
for _, item := range listResp.Value {
|
|
if item.Name == "artifact-download" {
|
|
uploadedItem = item
|
|
break
|
|
}
|
|
}
|
|
assert.Equal(t, "artifact-download", uploadedItem.Name)
|
|
|
|
idx := strings.Index(uploadedItem.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
|
|
url := uploadedItem.FileContainerResourceURL[idx:] + "?itemPath=artifact-download"
|
|
req = NewRequest(t, "GET", url).
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
downloadResp := DecodeJSON(t, resp, &downloadArtifactResponse{})
|
|
|
|
idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
|
|
url = downloadResp.Value[0].ContentLocation[idx:]
|
|
req = NewRequest(t, "GET", url).
|
|
AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
|
body := strings.Repeat("B", 4096)
|
|
assert.Equal(t, resp.Body.String(), body)
|
|
}
|
|
}
|
|
|
|
func TestActionRunAttemptArtifact(t *testing.T) {
|
|
defer prepareTestEnvActionsArtifacts(t)()
|
|
|
|
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
|
session := loginUser(t, user2.Name)
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
|
|
|
|
apiRepo := createActionsTestRepo(t, token, "actions-run-attempt-artifact", false)
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
|
|
httpContext := NewAPITestContext(t, user2.Name, repo.Name, auth_model.AccessTokenScopeWriteRepository)
|
|
defer doAPIDeleteRepository(httpContext)(t)
|
|
|
|
runner := newMockRunner()
|
|
runner.registerAsRepoRunner(t, repo.OwnerName, repo.Name, "mock-runner", []string{"ubuntu-latest"}, false)
|
|
|
|
wfTreePath := ".gitea/workflows/run-attempt-artifact.yml"
|
|
wfFileContent := `name: run-attempt-artifact
|
|
on:
|
|
workflow_dispatch:
|
|
jobs:
|
|
job1:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo 'job1'
|
|
`
|
|
opts := getWorkflowCreateFileOptions(user2, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
|
|
createWorkflowFile(t, token, user2.Name, repo.Name, wfTreePath, opts)
|
|
|
|
urlStr := fmt.Sprintf("/%s/%s/actions/run?workflow=%s", user2.Name, repo.Name, "run-attempt-artifact.yml")
|
|
req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
|
|
"ref": "refs/heads/main",
|
|
})
|
|
session.MakeRequest(t, req, http.StatusSeeOther)
|
|
|
|
t.Run("testActionRunAttemptArtifactV3", func(t *testing.T) {
|
|
testActionRunAttemptArtifactV3(t, repo, session, runner)
|
|
})
|
|
|
|
t.Run("testActionRunAttemptArtifactV4", func(t *testing.T) {
|
|
testActionRunAttemptArtifactV4(t, repo, session, runner)
|
|
})
|
|
|
|
t.Run("testPartialRerunArtifactInheritance", func(t *testing.T) {
|
|
testPartialRerunArtifactInheritance(t, user2, token, repo, session, runner)
|
|
})
|
|
})
|
|
}
|
|
|
|
func testActionRunAttemptArtifactV3(t *testing.T, repo *repo_model.Repository, session *TestSession, runner *mockRunner) {
|
|
// first run
|
|
task1 := runner.fetchTask(t)
|
|
_, job1, run := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
|
require.NotZero(t, job1.RunAttemptID)
|
|
taskToken1 := task1.Context.GetFields()["gitea_runtime_token"].GetStringValue()
|
|
require.NotEmpty(t, taskToken1)
|
|
uploadTestArtifactFile(t, run.ID, taskToken1, "artifact-attempt-1", "attempt-1.txt", strings.Repeat("A", 32))
|
|
uploadTestArtifactFile(t, run.ID, taskToken1, "artifact-shared", "shared.txt", strings.Repeat("C", 32))
|
|
attempt1Names := listArtifactNamesForRun(t, run.ID, taskToken1)
|
|
assert.ElementsMatch(t, []string{"artifact-attempt-1", "artifact-shared"}, attempt1Names)
|
|
|
|
runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS}) // complete first run
|
|
|
|
// rerun
|
|
req := NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun", repo.OwnerName, repo.Name, run.ID))
|
|
session.MakeRequest(t, req, http.StatusOK)
|
|
task2 := runner.fetchTask(t)
|
|
_, job2, _ := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
|
require.NotZero(t, job2.RunAttemptID)
|
|
assert.NotEqual(t, job1.RunAttemptID, job2.RunAttemptID)
|
|
taskToken2 := task2.Context.GetFields()["gitea_runtime_token"].GetStringValue()
|
|
require.NotEmpty(t, taskToken2)
|
|
uploadTestArtifactFile(t, run.ID, taskToken2, "artifact-attempt-2", "attempt-2.txt", strings.Repeat("B", 32))
|
|
uploadTestArtifactFile(t, run.ID, taskToken2, "artifact-shared", "shared.txt", strings.Repeat("D", 32))
|
|
attempt2Names := listArtifactNamesForRun(t, run.ID, taskToken2)
|
|
assert.ElementsMatch(t, []string{"artifact-attempt-2", "artifact-shared"}, attempt2Names)
|
|
assert.NotContains(t, attempt2Names, "artifact-attempt-1")
|
|
|
|
// "artifact-attempt-1" belongs to the first attempt, so the rerun token cannot access it
|
|
req = NewRequest(t, "GET", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts/%x/download_url?itemPath=artifact-attempt-1", run.ID, md5.Sum([]byte("artifact-attempt-1")))).
|
|
AddTokenAuth(taskToken2)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
|
|
// "artifact-shared" for each attempt has different content
|
|
sharedContent1 := downloadArtifactFileContentByAttempt(t, session, repo.OwnerName, repo.Name, run.ID, "artifact-shared", 1, "shared.txt")
|
|
assert.Equal(t, strings.Repeat("C", 32), sharedContent1)
|
|
sharedContent2 := downloadArtifactFileContentByAttempt(t, session, repo.OwnerName, repo.Name, run.ID, "artifact-shared", 2, "shared.txt")
|
|
assert.Equal(t, strings.Repeat("D", 32), sharedContent2)
|
|
}
|
|
|
|
func testPartialRerunArtifactInheritance(t *testing.T, user *user_model.User, token string, repo *repo_model.Repository, session *TestSession, runner *mockRunner) {
|
|
wfTreePath := ".gitea/workflows/partial-rerun-artifact.yml"
|
|
wfFileContent := `name: partial-rerun-artifact
|
|
on:
|
|
workflow_dispatch:
|
|
jobs:
|
|
job1:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- run: echo 'job1'
|
|
job2:
|
|
runs-on: ubuntu-latest
|
|
needs: job1
|
|
steps:
|
|
- run: echo 'job2'
|
|
`
|
|
opts := getWorkflowCreateFileOptions(user, repo.DefaultBranch, "create "+wfTreePath, wfFileContent)
|
|
createWorkflowFile(t, token, user.Name, repo.Name, wfTreePath, opts)
|
|
|
|
req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/actions/run?workflow=%s", repo.OwnerName, repo.Name, "partial-rerun-artifact.yml"), map[string]string{
|
|
"ref": "refs/heads/main",
|
|
})
|
|
session.MakeRequest(t, req, http.StatusSeeOther)
|
|
|
|
// job1 uploads the artifacts and succeeds
|
|
task1 := runner.fetchTask(t)
|
|
_, job1, run := getTaskAndJobAndRunByTaskID(t, task1.Id)
|
|
taskToken1 := task1.Context.GetFields()["gitea_runtime_token"].GetStringValue()
|
|
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "job1-only", strings.Repeat("A", 32))
|
|
uploadTestArtifactFileV4(t, run.ID, job1.ID, taskToken1, "job1-shared", strings.Repeat("B", 32))
|
|
// a v3 artifact is one row per file, so this one spans two rows
|
|
uploadTestArtifactFile(t, run.ID, taskToken1, "job1-v3", "a.txt", strings.Repeat("D", 32))
|
|
uploadTestArtifactFile(t, run.ID, taskToken1, "job1-v3", "b.txt", strings.Repeat("E", 32))
|
|
runner.execTask(t, task1, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
|
|
|
// job2 fails
|
|
task2 := runner.fetchTask(t)
|
|
_, job2, _ := getTaskAndJobAndRunByTaskID(t, task2.Id)
|
|
runner.execTask(t, task2, &mockTaskOutcome{result: runnerv1.Result_RESULT_FAILURE})
|
|
|
|
// re-run only the failed job, so job1 is passed through and never uploads its artifacts again
|
|
req = NewRequest(t, "POST", fmt.Sprintf("/%s/%s/actions/runs/%d/rerun-failed", repo.OwnerName, repo.Name, run.ID))
|
|
session.MakeRequest(t, req, http.StatusOK)
|
|
|
|
task3 := runner.fetchTask(t)
|
|
_, job3, _ := getTaskAndJobAndRunByTaskID(t, task3.Id)
|
|
require.Equal(t, job2.JobID, job3.JobID)
|
|
require.NotEqual(t, job2.RunAttemptID, job3.RunAttemptID)
|
|
taskToken3 := task3.Context.GetFields()["gitea_runtime_token"].GetStringValue()
|
|
|
|
// the new attempt inherits what the previous attempt uploaded
|
|
assert.ElementsMatch(t, []string{"job1-only", "job1-shared"}, listArtifactNamesForRunV4(t, run.ID, job3.ID, taskToken3))
|
|
assert.Equal(t, strings.Repeat("A", 32), downloadArtifactContentV4ByTask(t, run.ID, job3.ID, taskToken3, "job1-only"))
|
|
assert.Contains(t, listArtifactNamesForRun(t, run.ID, taskToken3), "job1-v3")
|
|
|
|
// a pending upload of this attempt must not shadow the confirmed copy it inherited
|
|
createTestArtifactV4(t, run.ID, job3.ID, taskToken3, "job1-only")
|
|
assert.Equal(t, strings.Repeat("A", 32), downloadArtifactContentV4ByTask(t, run.ID, job3.ID, taskToken3, "job1-only"))
|
|
|
|
// both rows of the inherited v3 artifact are readable
|
|
inheritedV3 := getArtifactDownloadItemsForRun(t, run.ID, taskToken3, "job1-v3")
|
|
require.Len(t, inheritedV3, 2)
|
|
assert.Equal(t, strings.Repeat("D", 32), downloadArtifactItemContent(t, taskToken3, inheritedV3[0]))
|
|
|
|
// uploading an inherited name in this attempt shadows the inherited artifact
|
|
inheritedSharedID := listArtifactIDForRunV4(t, run.ID, job3.ID, taskToken3, "job1-shared")
|
|
require.Len(t, listArtifactsByIDV4(t, run.ID, job3.ID, inheritedSharedID, taskToken3), 1)
|
|
uploadTestArtifactFileV4(t, run.ID, job3.ID, taskToken3, "job1-shared", strings.Repeat("C", 32))
|
|
assert.ElementsMatch(t, []string{"job1-only", "job1-shared"}, listArtifactNamesForRunV4(t, run.ID, job3.ID, taskToken3))
|
|
assert.Equal(t, strings.Repeat("C", 32), downloadArtifactContentV4ByTask(t, run.ID, job3.ID, taskToken3, "job1-shared"))
|
|
|
|
// a shadowed artifact is not listed by id either: a download resolves by name
|
|
assert.Empty(t, listArtifactsByIDV4(t, run.ID, job3.ID, inheritedSharedID, taskToken3))
|
|
|
|
// the shadowed v3 artifact is dropped as a whole, its b.txt row must not survive next to the new a.txt
|
|
uploadTestArtifactFile(t, run.ID, taskToken3, "job1-v3", "a.txt", strings.Repeat("F", 32))
|
|
shadowedV3 := getArtifactDownloadItemsForRun(t, run.ID, taskToken3, "job1-v3")
|
|
require.Len(t, shadowedV3, 1)
|
|
assert.Equal(t, strings.Repeat("F", 32), downloadArtifactItemContent(t, taskToken3, shadowedV3[0]))
|
|
|
|
runner.execTask(t, task3, &mockTaskOutcome{result: runnerv1.Result_RESULT_SUCCESS})
|
|
}
|
|
|
|
func getArtifactDownloadItemsForRun(t *testing.T, runID int64, taskToken, artifactName string) []downloadArtifactResponseItem {
|
|
t.Helper()
|
|
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts/%x/download_url?itemPath=%s", runID, md5.Sum([]byte(artifactName)), artifactName)).
|
|
AddTokenAuth(taskToken)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
return DecodeJSON(t, resp, &downloadArtifactResponse{}).Value
|
|
}
|
|
|
|
func downloadArtifactItemContent(t *testing.T, taskToken string, item downloadArtifactResponseItem) string {
|
|
t.Helper()
|
|
|
|
idx := strings.Index(item.ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
|
|
require.NotEqual(t, -1, idx)
|
|
req := NewRequest(t, "GET", item.ContentLocation[idx:]).AddTokenAuth(taskToken)
|
|
return MakeRequest(t, req, http.StatusOK).Body.String()
|
|
}
|
|
|
|
func uploadTestArtifactFile(t *testing.T, runID int64, authToken, artifactName, fileName, content string) {
|
|
t.Helper()
|
|
|
|
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts", runID), getUploadArtifactRequest{
|
|
Type: "actions_storage",
|
|
Name: artifactName,
|
|
}).AddTokenAuth(authToken)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
uploadResp := DecodeJSON(t, resp, &uploadArtifactResponse{})
|
|
|
|
idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
|
|
uploadURL := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=" + artifactName + "/" + fileName
|
|
contentLen := strconv.Itoa(len(content))
|
|
contentMD5 := md5.Sum([]byte(content))
|
|
req = NewRequestWithBody(t, "PUT", uploadURL, strings.NewReader(content)).
|
|
AddTokenAuth(authToken).
|
|
SetHeader("Content-Range", fmt.Sprintf("bytes 0-%d/%d", len(content)-1, len(content))).
|
|
SetHeader("x-tfs-filelength", contentLen).
|
|
SetHeader("x-actions-results-md5", base64.StdEncoding.EncodeToString(contentMD5[:]))
|
|
MakeRequest(t, req, http.StatusOK)
|
|
|
|
req = NewRequest(t, "PATCH", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts?artifactName=%s", runID, artifactName)).
|
|
AddTokenAuth(authToken)
|
|
MakeRequest(t, req, http.StatusOK)
|
|
}
|
|
|
|
func listArtifactNamesForRun(t *testing.T, runID int64, taskToken string) []string {
|
|
t.Helper()
|
|
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/api/actions_pipeline/_apis/pipelines/workflows/%d/artifacts", runID)).
|
|
AddTokenAuth(taskToken)
|
|
resp := MakeRequest(t, req, http.StatusOK)
|
|
listResp := DecodeJSON(t, resp, &listArtifactsResponse{})
|
|
|
|
names := make([]string, 0, len(listResp.Value))
|
|
for _, item := range listResp.Value {
|
|
names = append(names, item.Name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func downloadArtifactFileContentByAttempt(t *testing.T, session *TestSession, owner, repo string, runID int64, artifactName string, attempt int64, fileName string) string {
|
|
t.Helper()
|
|
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/actions/runs/%d/artifacts/%s?attempt=%d", owner, repo, runID, url.PathEscape(artifactName), attempt))
|
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
|
|
|
zr, err := zip.NewReader(bytes.NewReader(resp.Body.Bytes()), int64(resp.Body.Len()))
|
|
require.NoError(t, err)
|
|
for _, f := range zr.File {
|
|
if f.Name != fileName {
|
|
continue
|
|
}
|
|
rc, err := f.Open()
|
|
require.NoError(t, err)
|
|
content, err := io.ReadAll(rc)
|
|
rc.Close()
|
|
require.NoError(t, err)
|
|
return string(content)
|
|
}
|
|
|
|
require.FailNowf(t, "artifact file not found", "artifact %q attempt %d does not contain file %q", artifactName, attempt, fileName)
|
|
return ""
|
|
}
|