Add pipeline log download endpoint (#6876)

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude <claude@anthropic.com>
This commit is contained in:
6543
2026-08-17 22:34:10 +02:00
committed by GitHub
co-authored by Codex Claude
parent 1992b586d8
commit 042b9536e0
10 changed files with 496 additions and 227 deletions
+50
View File
@@ -2831,6 +2831,56 @@ const docTemplate = `{
}
}
},
"/repos/{repo_id}/logs/{pipeline_number}/{step_id}/download": {
"get": {
"produces": [
"text/plain"
],
"tags": [
"Pipeline logs"
],
"summary": "Download logs for a pipeline step",
"parameters": [
{
"type": "string",
"default": "Bearer \u003cpersonal access token\u003e",
"description": "Insert your personal access token",
"name": "Authorization",
"in": "header",
"required": true
},
{
"type": "integer",
"description": "the repository id",
"name": "repo_id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "the number of the pipeline",
"name": "pipeline_number",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "the step id",
"name": "step_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "string"
}
}
}
}
},
"/repos/{repo_id}/move": {
"post": {
"produces": [
+68 -135
View File
@@ -20,6 +20,8 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"strconv"
"strings"
@@ -199,27 +201,14 @@ func GetPipelines(c *gin.Context) {
// @Param repo_id path int true "the repository id"
// @Param pipeline_number path int true "the number of the pipeline"
func DeletePipeline(c *gin.Context) {
_store := store.FromContext(c)
repo := session.Repo(c)
num, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
pl, err := _store.GetPipelineNumber(repo, num)
if err != nil {
handleDBError(c, err)
return
}
pl := session.Pipeline(c)
if ok := pipelineDeleteAllowed(pl); !ok {
c.String(http.StatusUnprocessableEntity, "Cannot delete pipeline with status %s", pl.Status)
return
}
err = store.FromContext(c).DeletePipeline(pl)
err := store.FromContext(c).DeletePipeline(pl)
if err != nil {
c.String(http.StatusInternalServerError, "Error deleting pipeline. %s", err)
return
@@ -295,34 +284,7 @@ func GetPipelineLastByBranch(c *gin.Context) {
// @Param pipeline_number path int true "the number of the pipeline"
// @Param step_id path int true "the step id"
func GetStepLogs(c *gin.Context) {
_store := store.FromContext(c)
repo := session.Repo(c)
// parse the pipeline number and step sequence number from
// the request parameter.
num, err := strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
pl, err := _store.GetPipelineNumber(repo, num)
if err != nil {
handleDBError(c, err)
return
}
stepID, err := strconv.ParseInt(c.Params.ByName("step_id"), 10, 64)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
step, err := _store.StepLoad(pl.ID, stepID)
if err != nil {
handleDBError(c, err)
return
}
step := session.Step(c)
logs, err := server.Config.Services.LogStore.LogFind(step)
if err != nil {
@@ -333,6 +295,59 @@ func GetStepLogs(c *gin.Context) {
c.JSON(http.StatusOK, logs)
}
// DownloadStepLogs
//
// @Summary Download logs for a pipeline step
// @Router /repos/{repo_id}/logs/{pipeline_number}/{step_id}/download [get]
// @Produce plain
// @Success 200 {string} string
// @Tags Pipeline logs
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
// @Param repo_id path int true "the repository id"
// @Param pipeline_number path int true "the number of the pipeline"
// @Param step_id path int true "the step id"
func DownloadStepLogs(c *gin.Context) {
step := session.Step(c)
logs, err := server.Config.Services.LogStore.LogFind(step)
if err != nil {
handleDBError(c, err)
return
}
repo := session.Repo(c)
pl := session.Pipeline(c)
filename := sanitizeDownloadFilename(fmt.Sprintf("%s-%s-%d-%s.log", repo.Owner, repo.Name, pl.Number, step.Name))
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("X-Content-Type-Options", "nosniff")
c.Status(http.StatusOK)
for i, entry := range logs {
if i > 0 {
if _, err = io.WriteString(c.Writer, "\n"); err != nil {
_ = c.Error(err)
return
}
}
if _, err = c.Writer.Write(entry.Data); err != nil {
_ = c.Error(err)
return
}
}
}
func sanitizeDownloadFilename(filename string) string {
filename = strings.Map(func(r rune) rune {
if r < ' ' || r == '\x7f' || strings.ContainsRune(`<>:"/\|?*`, r) {
return '-'
}
return r
}, filename)
filename = strings.TrimRight(filename, ".")
return strings.TrimSpace(filename)
}
// DeleteStepLogs
//
// @Summary Delete step logs of a pipeline
@@ -346,31 +361,7 @@ func GetStepLogs(c *gin.Context) {
// @Param step_id path int true "the step id"
func DeleteStepLogs(c *gin.Context) {
_store := store.FromContext(c)
repo := session.Repo(c)
pipelineNumber, err := strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
_pipeline, err := _store.GetPipelineNumber(repo, pipelineNumber)
if err != nil {
handleDBError(c, err)
return
}
stepID, err := strconv.ParseInt(c.Params.ByName("step_id"), 10, 64)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
_step, err := _store.StepLoad(_pipeline.ID, stepID)
if err != nil {
handleDBError(c, err)
return
}
_step := session.Step(c)
switch _step.State {
case model.StatusRunning, model.StatusPending:
@@ -378,7 +369,7 @@ func DeleteStepLogs(c *gin.Context) {
return
}
err = _store.LogDelete(_step)
err := _store.LogDelete(_step)
if err != nil {
handleDBError(c, err)
return
@@ -399,18 +390,7 @@ func DeleteStepLogs(c *gin.Context) {
// @Param pipeline_number path int true "the number of the pipeline"
func GetPipelineConfig(c *gin.Context) {
_store := store.FromContext(c)
repo := session.Repo(c)
num, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
pl, err := _store.GetPipelineNumber(repo, num)
if err != nil {
handleDBError(c, err)
return
}
pl := session.Pipeline(c)
configs, err := _store.ConfigsForPipeline(pl.ID)
if err != nil {
@@ -433,18 +413,8 @@ func GetPipelineConfig(c *gin.Context) {
// @Param pipeline_number path int true "the number of the pipeline"
func GetPipelineMetadata(c *gin.Context) {
repo := session.Repo(c)
num, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
_store := store.FromContext(c)
currentPipeline, err := _store.GetPipelineNumber(repo, num)
if err != nil {
handleDBError(c, err)
return
}
currentPipeline := session.Pipeline(c)
forge, err := server.Config.Services.Manager.ForgeFromRepo(repo)
if err != nil {
@@ -483,13 +453,7 @@ func CancelPipeline(c *gin.Context) {
return
}
num, _ := strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64)
pl, err := _store.GetPipelineNumber(repo, num)
if err != nil {
handleDBError(c, err)
return
}
pl := session.Pipeline(c)
if err := pipeline.Cancel(c, _forge, _store, repo, user, pl, &model.CancelInfo{
CanceledByUser: user.Login,
@@ -515,15 +479,9 @@ func PostApproval(c *gin.Context) {
_store = store.FromContext(c)
repo = session.Repo(c)
user = session.User(c)
num, _ = strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64)
pl = session.Pipeline(c)
)
pl, err := _store.GetPipelineNumber(repo, num)
if err != nil {
handleDBError(c, err)
return
}
newPipeline, err := pipeline.Approve(c, _store, pl, user, repo)
if err != nil {
handlePipelineErr(c, err)
@@ -547,16 +505,10 @@ func PostDecline(c *gin.Context) {
_store = store.FromContext(c)
repo = session.Repo(c)
user = session.User(c)
num, _ = strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64)
pl = session.Pipeline(c)
)
pl, err := _store.GetPipelineNumber(repo, num)
if err != nil {
handleDBError(c, err)
return
}
pl, err = pipeline.Decline(c, _store, pl, user, repo)
pl, err := pipeline.Decline(c, _store, pl, user, repo)
if err != nil {
handlePipelineErr(c, err)
} else {
@@ -597,12 +549,7 @@ func GetPipelineQueue(c *gin.Context) {
func PostPipeline(c *gin.Context) {
_store := store.FromContext(c)
repo := session.Repo(c)
num, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
pl := session.Pipeline(c)
user, err := _store.GetUser(repo.UserID)
if err != nil {
@@ -610,12 +557,6 @@ func PostPipeline(c *gin.Context) {
return
}
pl, err := _store.GetPipelineNumber(repo, num)
if err != nil {
handleDBError(c, err)
return
}
// refresh the token to make sure, pipeline.Restart can still obtain the pipeline config if necessary again
refreshUserToken(c, user)
@@ -676,15 +617,7 @@ func PostPipeline(c *gin.Context) {
// @Param pipeline_number path int true "the number of the pipeline"
func DeletePipelineLogs(c *gin.Context) {
_store := store.FromContext(c)
repo := session.Repo(c)
num, _ := strconv.ParseInt(c.Params.ByName("pipeline_number"), 10, 64)
pl, err := _store.GetPipelineNumber(repo, num)
if err != nil {
handleDBError(c, err)
return
}
pl := session.Pipeline(c)
steps, err := _store.StepList(pl.ID)
if err != nil {
+39 -43
View File
@@ -35,6 +35,7 @@ import (
queue_mocks "go.woodpecker-ci.org/woodpecker/v3/server/queue/mocks"
"go.woodpecker-ci.org/woodpecker/v3/server/scheduler"
config_service_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/config/mocks"
log_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/log/mocks"
manager_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/mocks"
registry_service_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/registry/mocks"
secret_service_mocks "go.woodpecker-ci.org/woodpecker/v3/server/services/secret/mocks"
@@ -125,47 +126,69 @@ func TestGetPipelines(t *testing.T) {
})
}
func TestDownloadStepLogs(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &model.Repo{ID: 1, Owner: "woodpecker-ci", Name: "woodpecker"}
pipeline := &model.Pipeline{ID: 2, Number: 42}
step := &model.Step{ID: 3, PipelineID: pipeline.ID, Name: "build"}
logs := []*model.LogEntry{
{StepID: step.ID, Line: 0, Data: []byte("first line")},
{StepID: step.ID, Line: 1, Data: []byte("\x1b[31msecond line\x1b[0m")},
}
mockLogStore := log_mocks.NewMockService(t)
mockLogStore.On("LogFind", step).Return(logs, nil)
originalLogStore := server.Config.Services.LogStore
server.Config.Services.LogStore = mockLogStore
t.Cleanup(func() {
server.Config.Services.LogStore = originalLogStore
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("repo", repo)
c.Set("pipeline", pipeline)
c.Set("step", step)
DownloadStepLogs(c)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "text/plain; charset=utf-8", w.Header().Get("Content-Type"))
assert.Equal(t, "attachment; filename=woodpecker-ci-woodpecker-42-build.log", w.Header().Get("Content-Disposition"))
assert.Equal(t, "first line\n\x1b[31msecond line\x1b[0m", w.Body.String())
}
func TestDeletePipeline(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("should delete pipeline", func(t *testing.T) {
mockStore := store_mocks.NewMockStore(t)
mockStore.On("GetPipelineNumber", mock.Anything, mock.Anything).Return(fakePipeline, nil)
mockStore.On("DeletePipeline", mock.Anything).Return(nil)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set("store", mockStore)
c.Params = gin.Params{{Key: "pipeline_number", Value: "2"}}
c.Set("pipeline", fakePipeline)
DeletePipeline(c)
mockStore.AssertCalled(t, "GetPipelineNumber", mock.Anything, mock.Anything)
mockStore.AssertCalled(t, "DeletePipeline", mock.Anything)
assert.Equal(t, http.StatusNoContent, c.Writer.Status())
})
t.Run("should not delete without pipeline number", func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
DeletePipeline(c)
assert.Equal(t, http.StatusBadRequest, c.Writer.Status())
})
t.Run("should not delete pending", func(t *testing.T) {
fakePipeline := *fakePipeline
fakePipeline.Status = model.StatusPending
mockStore := store_mocks.NewMockStore(t)
mockStore.On("GetPipelineNumber", mock.Anything, mock.Anything).Return(&fakePipeline, nil)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set("store", mockStore)
c.Params = gin.Params{{Key: "pipeline_number", Value: "2"}}
c.Set("pipeline", &fakePipeline)
DeletePipeline(c)
mockStore.AssertCalled(t, "GetPipelineNumber", mock.Anything, mock.Anything)
mockStore.AssertNotCalled(t, "DeletePipeline", mock.Anything)
assert.Equal(t, http.StatusUnprocessableEntity, c.Writer.Status())
})
@@ -191,17 +214,16 @@ func TestGetPipelineMetadata(t *testing.T) {
server.Config.Services.Manager = mockManager
mockStore := store_mocks.NewMockStore(t)
mockStore.On("GetPipelineNumber", mock.Anything, int64(2)).Return(fakePipeline, nil)
mockStore.On("GetPipelineLastBefore", mock.Anything, mock.Anything, int64(2)).Return(prevPipeline, nil)
t.Run("PipelineMetadata", func(t *testing.T) {
t.Run("should get pipeline metadata", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "pipeline_number", Value: "2"}}
c.Set("store", mockStore)
c.Set("forge", mockForge)
c.Set("repo", fakeRepo)
c.Set("pipeline", fakePipeline)
GetPipelineMetadata(c)
@@ -215,31 +237,6 @@ func TestGetPipelineMetadata(t *testing.T) {
assert.Equal(t, int64(2), response.Curr.Number)
assert.Equal(t, int64(1), response.Prev.Number)
})
t.Run("should return bad request for invalid pipeline number", func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "pipeline_number", Value: "invalid"}}
GetPipelineMetadata(c)
assert.Equal(t, http.StatusBadRequest, w.Code)
})
t.Run("should return not found for non-existent pipeline", func(t *testing.T) {
mockStore := store_mocks.NewMockStore(t)
mockStore.On("GetPipelineNumber", mock.Anything, int64(3)).Return((*model.Pipeline)(nil), types.ErrRecordNotExist)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "pipeline_number", Value: "3"}}
c.Set("store", mockStore)
c.Set("repo", fakeRepo)
GetPipelineMetadata(c)
assert.Equal(t, http.StatusNotFound, w.Code)
})
})
}
@@ -258,7 +255,6 @@ func TestCancelPipeline(t *testing.T) {
mockForge := forge_mocks.NewMockForge(t)
mockStore := store_mocks.NewMockStore(t)
mockStore.On("GetPipelineNumber", fakeRepo, int64(2)).Return(runningPipeline, nil)
mockStore.On("WorkflowGetTree", mock.Anything).Return([]*model.Workflow{}, nil)
mockStore.On("UpdatePipeline", mock.Anything).Return(nil)
@@ -272,7 +268,7 @@ func TestCancelPipeline(t *testing.T) {
c.Set("store", mockStore)
c.Set("repo", fakeRepo)
c.Set("user", fakeUser)
c.Params = gin.Params{{Key: "pipeline_number", Value: "2"}}
c.Set("pipeline", runningPipeline)
CancelPipeline(c)
+11 -10
View File
@@ -113,22 +113,23 @@ func apiRoutes(e *gin.RouterGroup) {
repo.GET("/pipelines", api.GetPipelines)
repo.POST("/pipelines", session.MustPush, api.CreatePipeline)
repo.DELETE("/pipelines/:pipeline_number", session.MustRepoAdmin(), api.DeletePipeline)
repo.DELETE("/pipelines/:pipeline_number", session.MustRepoAdmin(), session.SetPipeline(), api.DeletePipeline)
repo.GET("/pipelines/:pipeline_number", api.GetPipeline)
repo.GET("/pipelines/:pipeline_number/config", api.GetPipelineConfig)
repo.GET("/pipelines/:pipeline_number/metadata", session.MustPush, api.GetPipelineMetadata)
repo.GET("/pipelines/:pipeline_number/config", session.SetPipeline(), api.GetPipelineConfig)
repo.GET("/pipelines/:pipeline_number/metadata", session.MustPush, session.SetPipeline(), api.GetPipelineMetadata)
// requires push permissions
repo.POST("/pipelines/:pipeline_number", session.MustPush, api.PostPipeline)
repo.POST("/pipelines/:pipeline_number/cancel", session.MustPush, api.CancelPipeline)
repo.POST("/pipelines/:pipeline_number/approve", session.MustPush, api.PostApproval)
repo.POST("/pipelines/:pipeline_number/decline", session.MustPush, api.PostDecline)
repo.POST("/pipelines/:pipeline_number", session.MustPush, session.SetPipeline(), api.PostPipeline)
repo.POST("/pipelines/:pipeline_number/cancel", session.MustPush, session.SetPipeline(), api.CancelPipeline)
repo.POST("/pipelines/:pipeline_number/approve", session.MustPush, session.SetPipeline(), api.PostApproval)
repo.POST("/pipelines/:pipeline_number/decline", session.MustPush, session.SetPipeline(), api.PostDecline)
repo.GET("/logs/:pipeline_number/:step_id", api.GetStepLogs)
repo.DELETE("/logs/:pipeline_number/:step_id", session.MustPush, api.DeleteStepLogs)
repo.GET("/logs/:pipeline_number/:step_id", session.SetPipeline(), session.SetStep(), api.GetStepLogs)
repo.GET("/logs/:pipeline_number/:step_id/download", session.SetPipeline(), session.SetStep(), api.DownloadStepLogs)
repo.DELETE("/logs/:pipeline_number/:step_id", session.MustPush, session.SetPipeline(), session.SetStep(), api.DeleteStepLogs)
// requires push permissions
repo.DELETE("/logs/:pipeline_number", session.MustPush, api.DeletePipelineLogs)
repo.DELETE("/logs/:pipeline_number", session.MustPush, session.SetPipeline(), api.DeletePipelineLogs)
// requires push permissions
repo.GET("/secrets", session.MustPush, api.GetSecretList)
@@ -0,0 +1,68 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package session
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"go.woodpecker-ci.org/woodpecker/v3/server/model"
"go.woodpecker-ci.org/woodpecker/v3/server/store"
"go.woodpecker-ci.org/woodpecker/v3/server/store/types"
)
// Pipeline returns the pipeline resolved by SetPipeline.
func Pipeline(c *gin.Context) *model.Pipeline {
v, ok := c.Get("pipeline")
if !ok {
return nil
}
p, ok := v.(*model.Pipeline)
if !ok {
return nil
}
return p
}
// SetPipeline resolves the `pipeline_number` path param within the repo of the
// request and stores the pipeline in the context. It must run after SetRepo.
func SetPipeline() gin.HandlerFunc {
return func(c *gin.Context) {
_store := store.FromContext(c)
repo := Repo(c)
number, err := strconv.ParseInt(c.Param("pipeline_number"), 10, 64)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
pipeline, err := _store.GetPipelineNumber(repo, number)
if err != nil {
if errors.Is(err, types.ErrRecordNotExist) {
c.AbortWithStatus(http.StatusNotFound)
return
}
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Set("pipeline", pipeline)
c.Next()
}
}
@@ -0,0 +1,93 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build test
package session
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.woodpecker-ci.org/woodpecker/v3/server/model"
store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks"
"go.woodpecker-ci.org/woodpecker/v3/server/store/types"
)
func TestSetPipeline(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &model.Repo{ID: 1}
newCtx := func(t *testing.T, pipelineNumber string) (*gin.Context, *httptest.ResponseRecorder, *store_mocks.MockStore) {
t.Helper()
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
mockStore := store_mocks.NewMockStore(t)
c.Set("store", mockStore)
c.Set("repo", repo)
c.Params = gin.Params{{Key: "pipeline_number", Value: pipelineNumber}}
return c, rec, mockStore
}
t.Run("should resolve the pipeline of the repo", func(t *testing.T) {
pipeline := &model.Pipeline{ID: 3, Number: 2}
c, _, mockStore := newCtx(t, "2")
mockStore.On("GetPipelineNumber", repo, int64(2)).Return(pipeline, nil)
SetPipeline()(c)
require.False(t, c.IsAborted())
assert.Equal(t, pipeline, Pipeline(c))
})
t.Run("should reject a non numeric pipeline number", func(t *testing.T) {
c, rec, _ := newCtx(t, "latest")
SetPipeline()(c)
assert.True(t, c.IsAborted())
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Nil(t, Pipeline(c))
})
t.Run("should return not found for an unknown pipeline", func(t *testing.T) {
c, rec, mockStore := newCtx(t, "3")
mockStore.On("GetPipelineNumber", repo, int64(3)).Return((*model.Pipeline)(nil), types.ErrRecordNotExist)
SetPipeline()(c)
assert.True(t, c.IsAborted())
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.Nil(t, Pipeline(c))
})
t.Run("should return internal server error on store failure", func(t *testing.T) {
c, rec, mockStore := newCtx(t, "4")
mockStore.On("GetPipelineNumber", repo, int64(4)).Return((*model.Pipeline)(nil), errors.New("database on fire"))
SetPipeline()(c)
assert.True(t, c.IsAborted())
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Nil(t, Pipeline(c))
})
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package session
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"go.woodpecker-ci.org/woodpecker/v3/server/model"
"go.woodpecker-ci.org/woodpecker/v3/server/store"
"go.woodpecker-ci.org/woodpecker/v3/server/store/types"
)
// Step returns the step resolved by SetStep.
func Step(c *gin.Context) *model.Step {
v, ok := c.Get("step")
if !ok {
return nil
}
s, ok := v.(*model.Step)
if !ok {
return nil
}
return s
}
// SetStep resolves the `step_id` path param within the pipeline of the request
// and stores the step in the context. It must run after SetPipeline.
func SetStep() gin.HandlerFunc {
return func(c *gin.Context) {
_store := store.FromContext(c)
pipeline := Pipeline(c)
stepID, err := strconv.ParseInt(c.Param("step_id"), 10, 64)
if err != nil {
_ = c.AbortWithError(http.StatusBadRequest, err)
return
}
step, err := _store.StepLoad(pipeline.ID, stepID)
if err != nil {
if errors.Is(err, types.ErrRecordNotExist) {
c.AbortWithStatus(http.StatusNotFound)
return
}
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.Set("step", step)
c.Next()
}
}
@@ -0,0 +1,93 @@
// Copyright 2026 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//go:build test
package session
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.woodpecker-ci.org/woodpecker/v3/server/model"
store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks"
"go.woodpecker-ci.org/woodpecker/v3/server/store/types"
)
func TestSetStep(t *testing.T) {
gin.SetMode(gin.TestMode)
pipeline := &model.Pipeline{ID: 7, Number: 2}
newCtx := func(t *testing.T, stepID string) (*gin.Context, *httptest.ResponseRecorder, *store_mocks.MockStore) {
t.Helper()
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
mockStore := store_mocks.NewMockStore(t)
c.Set("store", mockStore)
c.Set("pipeline", pipeline)
c.Params = gin.Params{{Key: "step_id", Value: stepID}}
return c, rec, mockStore
}
t.Run("should resolve the step of the pipeline", func(t *testing.T) {
step := &model.Step{ID: 3, PipelineID: pipeline.ID}
c, _, mockStore := newCtx(t, "3")
mockStore.On("StepLoad", pipeline.ID, int64(3)).Return(step, nil)
SetStep()(c)
require.False(t, c.IsAborted())
assert.Equal(t, step, Step(c))
})
t.Run("should reject a non numeric step id", func(t *testing.T) {
c, rec, _ := newCtx(t, "build")
SetStep()(c)
assert.True(t, c.IsAborted())
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Nil(t, Step(c))
})
t.Run("should return not found for an unknown step", func(t *testing.T) {
c, rec, mockStore := newCtx(t, "4")
mockStore.On("StepLoad", pipeline.ID, int64(4)).Return((*model.Step)(nil), types.ErrRecordNotExist)
SetStep()(c)
assert.True(t, c.IsAborted())
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.Nil(t, Step(c))
})
t.Run("should return internal server error on store failure", func(t *testing.T) {
c, rec, mockStore := newCtx(t, "5")
mockStore.On("StepLoad", pipeline.ID, int64(5)).Return((*model.Step)(nil), errors.New("database on fire"))
SetStep()(c)
assert.True(t, c.IsAborted())
assert.Equal(t, http.StatusInternalServerError, rec.Code)
assert.Nil(t, Step(c))
})
}
-1
View File
@@ -227,7 +227,6 @@
"no_logs": "No logs",
"pipeline": "Pipeline #{pipelineId}",
"log_title": "Step Logs",
"log_download_error": "An error occurred while downloading the log file",
"log_delete_confirm": "Do you really want to delete the step logs?",
"log_delete_error": "An error occurred when deleting the step logs",
"actions": {
@@ -31,11 +31,10 @@
/>
<IconButton
v-if="step?.finished !== undefined && hasLogs"
:is-loading="downloadInProgress"
:title="$t('repo.pipeline.actions.log_download')"
class="hover:bg-white/10!"
icon="download"
@click="download"
:href="logDownloadUrl"
/>
<IconButton
v-if="step?.finished !== undefined && hasLogs && hasPushPermission"
@@ -219,6 +218,8 @@ const pipelineConfigs = requiredInject('pipeline-configs');
const apiClient = useApiClient();
const route = useRoute();
const config = useConfig();
const loadedStepSlug = ref<string>();
const stepSlug = computed(() => `${repo?.value.owner} - ${repo?.value.name} - ${pipeline.value.id} - ${stepId.value}`);
const step = computed(() => pipeline.value && findStep(pipeline.value.workflows || [], stepId.value));
@@ -226,6 +227,9 @@ const stream = ref<EventSource>();
const log = ref<LogLine[]>();
const consoleElement = ref<Element>();
const fullscreen = ref(false);
const logDownloadUrl = computed(
() => `${config.rootPath}/api/repos/${repo.value.id}/logs/${pipeline.value.number}/${step.value?.id}/download`,
);
const loadedLogs = computed(() => !!log.value);
const hasLogs = computed(
@@ -235,13 +239,10 @@ const hasLogs = computed(
);
const autoScroll = useStorage('woodpecker:log-auto-scroll', true);
const showActions = ref(false);
const downloadInProgress = ref(false);
const ansiUp = ref(new AnsiUp());
ansiUp.value.use_classes = true;
const logBuffer = ref<LogLine[]>([]);
const config = useConfig();
const maxLineCount = config.maxPipelineLogLineCount; // TODO(2653): implement lazy-loading support
const hasPushPermission = computed(() => repoPermissions?.value?.push);
@@ -435,39 +436,6 @@ const flushLogs = debounce((scroll: boolean) => {
}
}, 500);
async function download() {
if (!repo?.value || !pipeline.value || !step.value) {
throw new Error('The repository, pipeline or step was undefined');
}
let logs;
try {
downloadInProgress.value = true;
logs = await apiClient.getLogs(repo.value.id, pipeline.value.number, step.value.id);
} catch (e) {
notifications.notifyError(e as Error, i18n.t('repo.pipeline.log_download_error'));
return;
} finally {
downloadInProgress.value = false;
}
const fileURL = window.URL.createObjectURL(
new Blob([logs.map((line) => decode(line.data ?? '')).join('\n')], {
type: 'text/plain',
}),
);
const fileLink = document.createElement('a');
fileLink.href = fileURL;
fileLink.setAttribute(
'download',
`${repo.value.owner}-${repo.value.name}-${pipeline.value.number}-${step.value.name}.log`,
);
document.body.appendChild(fileLink);
fileLink.click();
document.body.removeChild(fileLink);
window.URL.revokeObjectURL(fileURL);
}
async function loadLogs() {
if (loadedStepSlug.value === stepSlug.value) {
return;