mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-06-10 11:44:30 +02:00
- Add GitHub-style Actions **job summaries** support (writes to `GITHUB_STEP_SUMMARY` / `workflow/SUMMARY.md`) and render them in the run UI. - Gitea stores summaries internally (DB) and serves them in the run view payload. - `act_runner` uploads the summary **only when Gitea advertises support** (`X-Gitea-Actions-Capabilities: job-summary`), and warns on upload failures without failing the job. ## Compatibility - New Gitea + old runner: no upload → no summary shown (no behavior change) - New runner + old Gitea: capability not advertised → runner skips upload (no behavior change) ## Issue - Fixes go-gitea/gitea#23721 Reviewed-on: https://gitea.com/gitea/runner/pulls/917 Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
677 lines
18 KiB
Go
677 lines
18 KiB
Go
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
// Copyright 2022 The nektos/act Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package runner
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common"
|
|
"gitea.com/gitea/runner/act/container"
|
|
"gitea.com/gitea/runner/act/model"
|
|
|
|
logrustest "github.com/sirupsen/logrus/hooks/test"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestJobExecutor(t *testing.T) {
|
|
// Dryrun only checks syntax/planning; all cases resolve locally, so this runs offline.
|
|
tables := []TestJobFileInfo{
|
|
{workdir, "uses-and-run-in-one-step", "push", "Invalid run/uses syntax for job:test step:Test", platforms, secrets},
|
|
{workdir, "uses-github-empty", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
|
|
{workdir, "uses-github-noref", "push", "Expected format {org}/{repo}[/path]@ref", platforms, secrets},
|
|
{workdir, "uses-github-root", "push", "", platforms, secrets},
|
|
{workdir, "uses-docker-url", "push", "", platforms, secrets},
|
|
{workdir, "job-nil-step", "push", "invalid Step 0: missing run or uses key", platforms, secrets},
|
|
}
|
|
// These tests are sufficient to only check syntax.
|
|
ctx := common.WithDryrun(context.Background(), true)
|
|
for _, table := range tables {
|
|
t.Run(table.workflowPath, func(t *testing.T) {
|
|
table.runTest(ctx, t, &Config{})
|
|
})
|
|
}
|
|
}
|
|
|
|
type jobInfoMock struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (jim *jobInfoMock) matrix() map[string]any {
|
|
args := jim.Called()
|
|
return args.Get(0).(map[string]any)
|
|
}
|
|
|
|
func (jim *jobInfoMock) steps() []*model.Step {
|
|
args := jim.Called()
|
|
|
|
return args.Get(0).([]*model.Step)
|
|
}
|
|
|
|
func (jim *jobInfoMock) startContainer() common.Executor {
|
|
args := jim.Called()
|
|
|
|
return args.Get(0).(func(context.Context) error)
|
|
}
|
|
|
|
func (jim *jobInfoMock) stopContainer() common.Executor {
|
|
args := jim.Called()
|
|
|
|
return args.Get(0).(func(context.Context) error)
|
|
}
|
|
|
|
func (jim *jobInfoMock) closeContainer() common.Executor {
|
|
args := jim.Called()
|
|
|
|
return args.Get(0).(func(context.Context) error)
|
|
}
|
|
|
|
func (jim *jobInfoMock) interpolateOutputs() common.Executor {
|
|
args := jim.Called()
|
|
|
|
return args.Get(0).(func(context.Context) error)
|
|
}
|
|
|
|
func (jim *jobInfoMock) result(result string) {
|
|
jim.Called(result)
|
|
}
|
|
|
|
type jobContainerMock struct {
|
|
container.Container
|
|
container.LinuxContainerEnvironmentExtensions
|
|
}
|
|
|
|
func (jcm *jobContainerMock) ReplaceLogWriter(_, _ io.Writer) (io.Writer, io.Writer) {
|
|
return nil, nil
|
|
}
|
|
|
|
type stepFactoryMock struct {
|
|
mock.Mock
|
|
}
|
|
|
|
func (sfm *stepFactoryMock) newStep(model *model.Step, rc *RunContext) (step, error) {
|
|
args := sfm.Called(model, rc)
|
|
return args.Get(0).(step), args.Error(1)
|
|
}
|
|
|
|
func TestNewJobExecutor(t *testing.T) {
|
|
table := []struct {
|
|
name string
|
|
steps []*model.Step
|
|
preSteps []bool
|
|
postSteps []bool
|
|
executedSteps []string
|
|
result string
|
|
hasError bool
|
|
}{
|
|
{
|
|
name: "zeroSteps",
|
|
steps: []*model.Step{},
|
|
preSteps: []bool{},
|
|
postSteps: []bool{},
|
|
executedSteps: []string{},
|
|
result: "success",
|
|
hasError: false,
|
|
},
|
|
{
|
|
name: "stepWithoutPrePost",
|
|
steps: []*model.Step{{
|
|
ID: "1",
|
|
}},
|
|
preSteps: []bool{false},
|
|
postSteps: []bool{false},
|
|
executedSteps: []string{
|
|
"startContainer",
|
|
"step1",
|
|
"stopContainer",
|
|
"interpolateOutputs",
|
|
"closeContainer",
|
|
},
|
|
result: "success",
|
|
hasError: false,
|
|
},
|
|
{
|
|
name: "stepWithFailure",
|
|
steps: []*model.Step{{
|
|
ID: "1",
|
|
}},
|
|
preSteps: []bool{false},
|
|
postSteps: []bool{false},
|
|
executedSteps: []string{
|
|
"startContainer",
|
|
"step1",
|
|
"interpolateOutputs",
|
|
"closeContainer",
|
|
},
|
|
result: "failure",
|
|
hasError: true,
|
|
},
|
|
{
|
|
name: "stepWithPre",
|
|
steps: []*model.Step{{
|
|
ID: "1",
|
|
}},
|
|
preSteps: []bool{true},
|
|
postSteps: []bool{false},
|
|
executedSteps: []string{
|
|
"startContainer",
|
|
"pre1",
|
|
"step1",
|
|
"stopContainer",
|
|
"interpolateOutputs",
|
|
"closeContainer",
|
|
},
|
|
result: "success",
|
|
hasError: false,
|
|
},
|
|
{
|
|
name: "stepWithPost",
|
|
steps: []*model.Step{{
|
|
ID: "1",
|
|
}},
|
|
preSteps: []bool{false},
|
|
postSteps: []bool{true},
|
|
executedSteps: []string{
|
|
"startContainer",
|
|
"step1",
|
|
"post1",
|
|
"stopContainer",
|
|
"interpolateOutputs",
|
|
"closeContainer",
|
|
},
|
|
result: "success",
|
|
hasError: false,
|
|
},
|
|
{
|
|
name: "stepWithPreAndPost",
|
|
steps: []*model.Step{{
|
|
ID: "1",
|
|
}},
|
|
preSteps: []bool{true},
|
|
postSteps: []bool{true},
|
|
executedSteps: []string{
|
|
"startContainer",
|
|
"pre1",
|
|
"step1",
|
|
"post1",
|
|
"stopContainer",
|
|
"interpolateOutputs",
|
|
"closeContainer",
|
|
},
|
|
result: "success",
|
|
hasError: false,
|
|
},
|
|
{
|
|
name: "stepsWithPreAndPost",
|
|
steps: []*model.Step{{
|
|
ID: "1",
|
|
}, {
|
|
ID: "2",
|
|
}, {
|
|
ID: "3",
|
|
}},
|
|
preSteps: []bool{true, false, true},
|
|
postSteps: []bool{false, true, true},
|
|
executedSteps: []string{
|
|
"startContainer",
|
|
"pre1",
|
|
"pre3",
|
|
"step1",
|
|
"step2",
|
|
"step3",
|
|
"post3",
|
|
"post2",
|
|
"stopContainer",
|
|
"interpolateOutputs",
|
|
"closeContainer",
|
|
},
|
|
result: "success",
|
|
hasError: false,
|
|
},
|
|
}
|
|
|
|
contains := func(needle string, haystack []string) bool {
|
|
return slices.Contains(haystack, needle)
|
|
}
|
|
|
|
for _, tt := range table {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
fmt.Printf("::group::%s\n", tt.name) //nolint:forbidigo // pre-existing issue from nektos/act
|
|
|
|
ctx := common.WithJobErrorContainer(context.Background())
|
|
jim := &jobInfoMock{}
|
|
sfm := &stepFactoryMock{}
|
|
rc := &RunContext{
|
|
JobContainer: &jobContainerMock{},
|
|
Run: &model.Run{
|
|
JobID: "test",
|
|
Workflow: &model.Workflow{
|
|
Jobs: map[string]*model.Job{
|
|
"test": {},
|
|
},
|
|
},
|
|
},
|
|
Config: &Config{},
|
|
}
|
|
rc.ExprEval = rc.NewExpressionEvaluator(ctx)
|
|
executorOrder := make([]string, 0)
|
|
|
|
jim.On("steps").Return(tt.steps)
|
|
|
|
if len(tt.steps) > 0 {
|
|
jim.On("startContainer").Return(func(ctx context.Context) error {
|
|
executorOrder = append(executorOrder, "startContainer")
|
|
return nil
|
|
})
|
|
}
|
|
|
|
for i, stepModel := range tt.steps {
|
|
sm := &stepMock{}
|
|
|
|
sfm.On("newStep", stepModel, rc).Return(sm, nil)
|
|
|
|
sm.On("pre").Return(func(ctx context.Context) error {
|
|
if tt.preSteps[i] {
|
|
executorOrder = append(executorOrder, "pre"+stepModel.ID)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
sm.On("main").Return(func(ctx context.Context) error {
|
|
executorOrder = append(executorOrder, "step"+stepModel.ID)
|
|
if tt.hasError {
|
|
return errors.New("error")
|
|
}
|
|
return nil
|
|
})
|
|
|
|
sm.On("post").Return(func(ctx context.Context) error {
|
|
if tt.postSteps[i] {
|
|
executorOrder = append(executorOrder, "post"+stepModel.ID)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
defer sm.AssertExpectations(t)
|
|
}
|
|
|
|
if len(tt.steps) > 0 {
|
|
jim.On("matrix").Return(map[string]any{})
|
|
|
|
jim.On("interpolateOutputs").Return(func(ctx context.Context) error {
|
|
executorOrder = append(executorOrder, "interpolateOutputs")
|
|
return nil
|
|
})
|
|
|
|
if contains("stopContainer", tt.executedSteps) {
|
|
jim.On("stopContainer").Return(func(ctx context.Context) error {
|
|
executorOrder = append(executorOrder, "stopContainer")
|
|
return nil
|
|
})
|
|
}
|
|
|
|
jim.On("result", tt.result)
|
|
|
|
jim.On("closeContainer").Return(func(ctx context.Context) error {
|
|
executorOrder = append(executorOrder, "closeContainer")
|
|
return nil
|
|
})
|
|
}
|
|
|
|
executor := newJobExecutor(jim, sfm, rc)
|
|
err := executor(ctx)
|
|
assert.NoError(t, err) //nolint:testifylint // pre-existing issue from nektos/act
|
|
assert.Equal(t, tt.executedSteps, executorOrder)
|
|
|
|
jim.AssertExpectations(t)
|
|
sfm.AssertExpectations(t)
|
|
|
|
fmt.Println("::endgroup::") //nolint:forbidigo // pre-existing issue from nektos/act
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHasJobSummaryCapability(t *testing.T) {
|
|
assert.True(t, hasJobSummaryCapability("cache,job-summary artifacts"))
|
|
assert.True(t, hasJobSummaryCapability("cache,\njob-summary\tartifacts"))
|
|
assert.False(t, hasJobSummaryCapability("not-job-summary,job-summary-v2"))
|
|
}
|
|
|
|
// fakeRuntimeToken builds a JWT-shaped string whose middle (claims) segment encodes
|
|
// the given JobID. The header and signature segments are filler — the runner does not
|
|
// verify the signature; the server does.
|
|
func fakeRuntimeToken(jobID int64) string {
|
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
|
claims := base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, `{"JobID":%d}`, jobID))
|
|
sig := base64.RawURLEncoding.EncodeToString([]byte("sig"))
|
|
return header + "." + claims + "." + sig
|
|
}
|
|
|
|
func newJobSummaryRC(env map[string]string, jobContainer container.ExecutionsEnvironment, stepCount int) *RunContext {
|
|
steps := make([]*model.Step, stepCount)
|
|
for i := range steps {
|
|
steps[i] = &model.Step{ID: strconv.Itoa(i)}
|
|
}
|
|
return &RunContext{
|
|
Config: &Config{},
|
|
JobContainer: jobContainer,
|
|
Env: env,
|
|
Run: &model.Run{
|
|
JobID: "test",
|
|
Workflow: &model.Workflow{
|
|
Jobs: map[string]*model.Job{
|
|
"test": {Steps: steps},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestTryUploadJobSummaryRetriesTransientFailure(t *testing.T) {
|
|
oldDelay := jobSummaryUploadRetryDelay
|
|
jobSummaryUploadRetryDelay = 0
|
|
defer func() {
|
|
jobSummaryUploadRetryDelay = oldDelay
|
|
}()
|
|
|
|
runtimeToken := fakeRuntimeToken(34)
|
|
|
|
requests := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests++
|
|
assert.Equal(t, http.MethodPut, r.Method)
|
|
assert.Equal(t, "/_apis/pipelines/workflows/12/jobs/34/steps/0/summary", r.URL.Path)
|
|
assert.Equal(t, "Bearer "+runtimeToken, r.Header.Get("Authorization"))
|
|
assert.Equal(t, "text/markdown; charset=utf-8", r.Header.Get("Content-Type"))
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, []byte("# summary"), body)
|
|
if requests == 1 {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx := context.Background()
|
|
cm := &containerMock{}
|
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "# summary"}))),
|
|
nil,
|
|
).Once()
|
|
|
|
rc := newJobSummaryRC(map[string]string{
|
|
"GITEA_ACTIONS_CAPABILITIES": "cache, job-summary",
|
|
"ACTIONS_RUNTIME_URL": server.URL,
|
|
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
|
"GITEA_RUN_ID": "12",
|
|
}, cm, 1)
|
|
|
|
tryUploadJobSummary(ctx, rc)
|
|
|
|
assert.Equal(t, 2, requests)
|
|
cm.AssertExpectations(t)
|
|
}
|
|
|
|
func TestTryUploadJobSummaryStopsAtPhaseTimeout(t *testing.T) {
|
|
oldPhase := jobSummaryUploadPhaseTimeout
|
|
jobSummaryUploadPhaseTimeout = 100 * time.Millisecond
|
|
defer func() {
|
|
jobSummaryUploadPhaseTimeout = oldPhase
|
|
}()
|
|
|
|
runtimeToken := fakeRuntimeToken(34)
|
|
|
|
// The server blocks until either the request context is cancelled (the behaviour
|
|
// under test: the phase timeout aborts the in-flight upload) or the test tears it
|
|
// down. Without the phase timeout the upload would hang until the 30s client
|
|
// timeout instead of releasing the cleanup budget. The release channel guarantees
|
|
// the handler always returns so server.Close() cannot itself hang.
|
|
release := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
select {
|
|
case <-r.Context().Done():
|
|
case <-release:
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
defer close(release)
|
|
|
|
ctx := context.Background()
|
|
cm := &containerMock{}
|
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "# summary"}))),
|
|
nil,
|
|
).Once()
|
|
|
|
rc := newJobSummaryRC(map[string]string{
|
|
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
|
|
"ACTIONS_RUNTIME_URL": server.URL,
|
|
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
|
"GITEA_RUN_ID": "12",
|
|
}, cm, 1)
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
tryUploadJobSummary(ctx, rc)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("tryUploadJobSummary did not honour the phase timeout")
|
|
}
|
|
cm.AssertExpectations(t)
|
|
}
|
|
|
|
func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
|
runtimeToken := fakeRuntimeToken(34)
|
|
|
|
type upload struct {
|
|
path string
|
|
body string
|
|
}
|
|
var got []upload
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
got = append(got, upload{r.URL.Path, string(body)})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
ctx := context.Background()
|
|
cm := &containerMock{}
|
|
// Three steps: 0 has content, 1 has empty content (skipped), 2 has content.
|
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-0.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-0.md", body: "first"}))),
|
|
nil,
|
|
).Once()
|
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-1.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-1.md", body: ""}))),
|
|
nil,
|
|
).Once()
|
|
cm.On("GetContainerArchive", mock.Anything, "/var/run/act/workflow/step-summary-2.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "step-summary-2.md", body: "third"}))),
|
|
nil,
|
|
).Once()
|
|
|
|
rc := newJobSummaryRC(map[string]string{
|
|
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
|
|
"ACTIONS_RUNTIME_URL": server.URL,
|
|
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
|
"GITEA_RUN_ID": "12",
|
|
}, cm, 3)
|
|
|
|
tryUploadJobSummary(ctx, rc)
|
|
|
|
assert.Equal(t, []upload{
|
|
{"/_apis/pipelines/workflows/12/jobs/34/steps/0/summary", "first"},
|
|
{"/_apis/pipelines/workflows/12/jobs/34/steps/2/summary", "third"},
|
|
}, got)
|
|
cm.AssertExpectations(t)
|
|
}
|
|
|
|
func TestTryUploadJobSummaryRequiresExactCapability(t *testing.T) {
|
|
requests := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests++
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
rc := newJobSummaryRC(map[string]string{
|
|
"GITEA_ACTIONS_CAPABILITIES": "not-job-summary,job-summary-v2",
|
|
"ACTIONS_RUNTIME_URL": server.URL,
|
|
"ACTIONS_RUNTIME_TOKEN": fakeRuntimeToken(34),
|
|
"GITEA_RUN_ID": "12",
|
|
}, &containerMock{}, 1)
|
|
|
|
tryUploadJobSummary(context.Background(), rc)
|
|
|
|
assert.Equal(t, 0, requests)
|
|
}
|
|
|
|
func TestTryUploadJobSummarySkipsWhenJobIDMissingFromToken(t *testing.T) {
|
|
requests := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests++
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
defer server.Close()
|
|
|
|
rc := newJobSummaryRC(map[string]string{
|
|
"GITEA_ACTIONS_CAPABILITIES": "job-summary",
|
|
"ACTIONS_RUNTIME_URL": server.URL,
|
|
"ACTIONS_RUNTIME_TOKEN": "not-a-jwt",
|
|
"GITEA_RUN_ID": "12",
|
|
}, &containerMock{}, 1)
|
|
|
|
tryUploadJobSummary(context.Background(), rc)
|
|
|
|
assert.Equal(t, 0, requests)
|
|
}
|
|
|
|
func TestExtractJobIDFromRuntimeToken(t *testing.T) {
|
|
assert.Equal(t, int64(42), extractJobIDFromRuntimeToken(fakeRuntimeToken(42)))
|
|
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken("not-a-jwt"))
|
|
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken("a.b.c"))
|
|
assert.Equal(t, int64(0), extractJobIDFromRuntimeToken(""))
|
|
}
|
|
|
|
func TestReadSingleFileFromContainerArchiveFindsMatchingRegularFile(t *testing.T) {
|
|
ctx := context.Background()
|
|
cm := &containerMock{}
|
|
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t,
|
|
tarEntry{name: "workflow", typeflag: tar.TypeDir},
|
|
tarEntry{name: "other.md", body: "wrong"},
|
|
tarEntry{name: "SUMMARY.md", body: "right"},
|
|
))),
|
|
nil,
|
|
).Once()
|
|
|
|
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", 1024)
|
|
|
|
assert.True(t, ok)
|
|
assert.Equal(t, []byte("right"), body)
|
|
cm.AssertExpectations(t)
|
|
}
|
|
|
|
func TestReadSingleFileFromContainerArchiveTruncatesWhenTooLarge(t *testing.T) {
|
|
logger, hook := logrustest.NewNullLogger()
|
|
ctx := common.WithLogger(context.Background(), logger)
|
|
cm := &containerMock{}
|
|
content := strings.Repeat("a", 300)
|
|
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: content}))),
|
|
nil,
|
|
).Once()
|
|
|
|
const maxBytes = 200
|
|
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", maxBytes)
|
|
|
|
// Oversized summaries are truncated to the limit (reserving room for the marker)
|
|
// rather than dropped entirely, and the truncation marker is appended.
|
|
assert.True(t, ok)
|
|
assert.LessOrEqual(t, len(body), maxBytes)
|
|
keep := maxBytes - len(jobSummaryTruncationMarker)
|
|
assert.Equal(t, []byte(content[:keep]+jobSummaryTruncationMarker), body)
|
|
if assert.Len(t, hook.Entries, 1) {
|
|
assert.Contains(t, hook.Entries[0].Message, "job summary truncated")
|
|
}
|
|
cm.AssertExpectations(t)
|
|
}
|
|
|
|
func TestReadSingleFileFromContainerArchiveKeepsExactLimitWithoutWarning(t *testing.T) {
|
|
logger, hook := logrustest.NewNullLogger()
|
|
ctx := common.WithLogger(context.Background(), logger)
|
|
cm := &containerMock{}
|
|
cm.On("GetContainerArchive", ctx, "/var/run/act/workflow/SUMMARY.md").Return(
|
|
io.NopCloser(bytes.NewReader(tarArchive(t, tarEntry{name: "SUMMARY.md", body: "abc"}))),
|
|
nil,
|
|
).Once()
|
|
|
|
body, ok := readSingleFileFromContainerArchive(ctx, cm, "/var/run/act/workflow/SUMMARY.md", 3)
|
|
|
|
// A summary that is exactly at the limit is kept whole and not flagged as truncated.
|
|
assert.True(t, ok)
|
|
assert.Equal(t, []byte("abc"), body)
|
|
assert.Empty(t, hook.Entries)
|
|
cm.AssertExpectations(t)
|
|
}
|
|
|
|
type tarEntry struct {
|
|
name string
|
|
body string
|
|
typeflag byte
|
|
}
|
|
|
|
func tarArchive(t *testing.T, entries ...tarEntry) []byte {
|
|
t.Helper()
|
|
|
|
buf := &bytes.Buffer{}
|
|
tw := tar.NewWriter(buf)
|
|
for _, entry := range entries {
|
|
typeflag := entry.typeflag
|
|
if typeflag == 0 {
|
|
typeflag = tar.TypeReg
|
|
}
|
|
header := &tar.Header{
|
|
Name: entry.name,
|
|
Typeflag: typeflag,
|
|
Mode: 0o644,
|
|
Size: int64(len(entry.body)),
|
|
}
|
|
if typeflag == tar.TypeDir {
|
|
header.Mode = 0o755
|
|
header.Size = 0
|
|
}
|
|
require.NoError(t, tw.WriteHeader(header))
|
|
if typeflag == tar.TypeReg {
|
|
_, err := tw.Write([]byte(entry.body))
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
require.NoError(t, tw.Close())
|
|
return buf.Bytes()
|
|
}
|