mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2026-09-05 20:07:25 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user