From fcc57dfc3831807150fae8de210e4f95b93734b2 Mon Sep 17 00:00:00 2001 From: 6543 <6543@obermui.de> Date: Wed, 25 Sep 2024 07:20:51 +0200 Subject: [PATCH] Replay pipeline using `cli exec` by downloading metadata (#4103) Co-authored-by: Anbraten <6918444+anbraten@users.noreply.github.com> --- cli/exec/exec.go | 25 +- cli/exec/flags.go | 5 + cli/exec/metadata.go | 214 +++++++------- cli/exec/metadata_test.go | 142 ++++++++++ cmd/server/docs/docs.go | 262 ++++++++++++++++++ server/api/pipeline.go | 43 +++ server/api/pipeline_test.go | 224 ++++++++++----- server/pipeline/items.go | 4 +- server/pipeline/stepbuilder/metadata.go | 4 +- server/pipeline/stepbuilder/metadata_test.go | 6 +- server/pipeline/stepbuilder/stepBuilder.go | 4 +- .../pipeline/stepbuilder/stepBuilder_test.go | 24 +- server/router/api.go | 1 + web/src/assets/locales/en.json | 11 +- web/src/lib/api/index.ts | 4 + web/src/router.ts | 5 + web/src/views/repo/pipeline/PipelineDebug.vue | 77 +++++ .../views/repo/pipeline/PipelineWrapper.vue | 9 + woodpecker-go/woodpecker/interface.go | 3 + woodpecker-go/woodpecker/mocks/client.go | 30 ++ woodpecker-go/woodpecker/pipeline.go | 24 +- 21 files changed, 927 insertions(+), 194 deletions(-) create mode 100644 cli/exec/metadata_test.go create mode 100644 web/src/views/repo/pipeline/PipelineDebug.vue diff --git a/cli/exec/exec.go b/cli/exec/exec.go index f532208cf..d33d96c65 100644 --- a/cli/exec/exec.go +++ b/cli/exec/exec.go @@ -37,6 +37,7 @@ import ( "go.woodpecker-ci.org/woodpecker/v2/pipeline/backend/kubernetes" "go.woodpecker-ci.org/woodpecker/v2/pipeline/backend/local" backend_types "go.woodpecker-ci.org/woodpecker/v2/pipeline/backend/types" + "go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/metadata" "go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/yaml" "go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/yaml/compiler" "go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/yaml/linter" @@ -76,6 +77,7 @@ func execDir(ctx context.Context, c *cli.Command, dir string) error { if runtime.GOOS == "windows" { repoPath = convertPathForWindows(repoPath) } + // TODO: respect depends_on and do parallel runs with output to multiple _windows_ e.g. tmux like return filepath.Walk(dir, func(path string, info os.FileInfo, e error) error { if e != nil { return e @@ -84,7 +86,7 @@ func execDir(ctx context.Context, c *cli.Command, dir string) error { // check if it is a regular file (not dir) if info.Mode().IsRegular() && (strings.HasSuffix(info.Name(), ".yaml") || strings.HasSuffix(info.Name(), ".yml")) { fmt.Println("#", info.Name()) - _ = runExec(ctx, c, path, repoPath) // TODO: should we drop errors or store them and report back? + _ = runExec(ctx, c, path, repoPath, false) // TODO: should we drop errors or store them and report back? fmt.Println("") return nil } @@ -103,10 +105,10 @@ func execFile(ctx context.Context, c *cli.Command, file string) error { if runtime.GOOS == "windows" { repoPath = convertPathForWindows(repoPath) } - return runExec(ctx, c, file, repoPath) + return runExec(ctx, c, file, repoPath, true) } -func runExec(ctx context.Context, c *cli.Command, file, repoPath string) error { +func runExec(ctx context.Context, c *cli.Command, file, repoPath string, singleExec bool) error { dat, err := os.ReadFile(file) if err != nil { return err @@ -121,7 +123,7 @@ func runExec(ctx context.Context, c *cli.Command, file, repoPath string) error { axes = append(axes, matrix.Axis{}) } for _, axis := range axes { - err := execWithAxis(ctx, c, file, repoPath, axis) + err := execWithAxis(ctx, c, file, repoPath, axis, singleExec) if err != nil { return err } @@ -129,11 +131,20 @@ func runExec(ctx context.Context, c *cli.Command, file, repoPath string) error { return nil } -func execWithAxis(ctx context.Context, c *cli.Command, file, repoPath string, axis matrix.Axis) error { - metadata, err := metadataFromContext(ctx, c, axis) +func execWithAxis(ctx context.Context, c *cli.Command, file, repoPath string, axis matrix.Axis, singleExec bool) error { + var metadataWorkflow *metadata.Workflow + if !singleExec { + // TODO: proper try to use the engine to generate the same metadata for workflows + // https://github.com/woodpecker-ci/woodpecker/pull/3967 + metadataWorkflow.Name = strings.TrimSuffix(strings.TrimSuffix(file, ".yaml"), ".yml") + } + metadata, err := metadataFromContext(ctx, c, axis, metadataWorkflow) if err != nil { return fmt.Errorf("could not create metadata: %w", err) + } else if metadata == nil { + return fmt.Errorf("metadata is nil") } + environ := metadata.Environ() var secrets []compiler.Secret for key, val := range metadata.Workflow.Matrix { @@ -239,7 +250,7 @@ func execWithAxis(ctx context.Context, c *cli.Command, file, repoPath string, ax c.String("netrc-password"), c.String("netrc-machine"), ), - compiler.WithMetadata(metadata), + compiler.WithMetadata(*metadata), compiler.WithSecret(secrets...), compiler.WithEnviron(pipelineEnv), ).Compile(conf) diff --git a/cli/exec/flags.go b/cli/exec/flags.go index c7a985d69..3044628a7 100644 --- a/cli/exec/flags.go +++ b/cli/exec/flags.go @@ -32,6 +32,11 @@ var flags = []cli.Flag{ Name: "repo-path", Usage: "path to local repository", }, + &cli.StringFlag{ + Sources: cli.EnvVars("WOODPECKER_METADATA_FILE"), + Name: "metadata-file", + Usage: "path to pipeline metadata file (normally downloaded from UI). Parameters can be adjusted by applying additional cli flags", + }, &cli.DurationFlag{ Sources: cli.EnvVars("WOODPECKER_TIMEOUT"), Name: "timeout", diff --git a/cli/exec/metadata.go b/cli/exec/metadata.go index 23e23ffd1..c0bc47c93 100644 --- a/cli/exec/metadata.go +++ b/cli/exec/metadata.go @@ -18,6 +18,7 @@ import ( "context" "encoding/json" "fmt" + "os" "runtime" "strings" @@ -29,108 +30,131 @@ import ( ) // return the metadata from the cli context. -func metadataFromContext(_ context.Context, c *cli.Command, axis matrix.Axis) (metadata.Metadata, error) { +func metadataFromContext(_ context.Context, c *cli.Command, axis matrix.Axis, w *metadata.Workflow) (*metadata.Metadata, error) { + m := &metadata.Metadata{} + + if c.IsSet("metadata-file") { + metadataFile, err := os.Open(c.String("metadata-file")) + if err != nil { + return nil, err + } + defer metadataFile.Close() + + if err := json.NewDecoder(metadataFile).Decode(m); err != nil { + return nil, err + } + } + platform := c.String("system-platform") if platform == "" { platform = runtime.GOOS + "/" + runtime.GOARCH } - fullRepoName := c.String("repo-name") - repoOwner := "" - repoName := "" - if idx := strings.LastIndex(fullRepoName, "/"); idx != -1 { - repoOwner = fullRepoName[:idx] - repoName = fullRepoName[idx+1:] + metadataFileAndOverrideOrDefault(c, "repo-name", func(fullRepoName string) { + if idx := strings.LastIndex(fullRepoName, "/"); idx != -1 { + m.Repo.Owner = fullRepoName[:idx] + m.Repo.Name = fullRepoName[idx+1:] + } + }, c.String) + + var err error + metadataFileAndOverrideOrDefault(c, "pipeline-changed-files", func(changedFilesRaw string) { + var changedFiles []string + if len(changedFilesRaw) != 0 && changedFilesRaw[0] == '[' { + if jsonErr := json.Unmarshal([]byte(changedFilesRaw), &changedFiles); jsonErr != nil { + err = fmt.Errorf("pipeline-changed-files detected json but could not parse it: %w", jsonErr) + } + } else { + for _, file := range strings.Split(changedFilesRaw, ",") { + changedFiles = append(changedFiles, strings.TrimSpace(file)) + } + } + m.Curr.Commit.ChangedFiles = changedFiles + }, c.String) + if err != nil { + return nil, err } - var changedFiles []string - changedFilesRaw := c.String("pipeline-changed-files") - if len(changedFilesRaw) != 0 && changedFilesRaw[0] == '[' { - if err := json.Unmarshal([]byte(changedFilesRaw), &changedFiles); err != nil { - return metadata.Metadata{}, fmt.Errorf("pipeline-changed-files detected json but could not parse it: %w", err) - } - } else { - for _, file := range strings.Split(changedFilesRaw, ",") { - changedFiles = append(changedFiles, strings.TrimSpace(file)) - } + // Repo + metadataFileAndOverrideOrDefault(c, "repo-remote-id", func(s string) { m.Repo.RemoteID = s }, c.String) + metadataFileAndOverrideOrDefault(c, "repo-url", func(s string) { m.Repo.ForgeURL = s }, c.String) + metadataFileAndOverrideOrDefault(c, "repo-scm", func(s string) { m.Repo.SCM = s }, c.String) + metadataFileAndOverrideOrDefault(c, "repo-default-branch", func(s string) { m.Repo.Branch = s }, c.String) + metadataFileAndOverrideOrDefault(c, "repo-clone-url", func(s string) { m.Repo.CloneURL = s }, c.String) + metadataFileAndOverrideOrDefault(c, "repo-clone-ssh-url", func(s string) { m.Repo.CloneSSHURL = s }, c.String) + metadataFileAndOverrideOrDefault(c, "repo-private", func(b bool) { m.Repo.Private = b }, c.Bool) + metadataFileAndOverrideOrDefault(c, "repo-trusted", func(b bool) { m.Repo.Trusted = b }, c.Bool) + + // Current Pipeline + metadataFileAndOverrideOrDefault(c, "pipeline-number", func(i int64) { m.Curr.Number = i }, c.Int) + metadataFileAndOverrideOrDefault(c, "pipeline-parent", func(i int64) { m.Curr.Parent = i }, c.Int) + metadataFileAndOverrideOrDefault(c, "pipeline-created", func(i int64) { m.Curr.Created = i }, c.Int) + metadataFileAndOverrideOrDefault(c, "pipeline-started", func(i int64) { m.Curr.Started = i }, c.Int) + metadataFileAndOverrideOrDefault(c, "pipeline-finished", func(i int64) { m.Curr.Finished = i }, c.Int) + metadataFileAndOverrideOrDefault(c, "pipeline-status", func(s string) { m.Curr.Status = s }, c.String) + metadataFileAndOverrideOrDefault(c, "pipeline-event", func(s string) { m.Curr.Event = s }, c.String) + metadataFileAndOverrideOrDefault(c, "pipeline-url", func(s string) { m.Curr.ForgeURL = s }, c.String) + metadataFileAndOverrideOrDefault(c, "pipeline-deploy-to", func(s string) { m.Curr.DeployTo = s }, c.String) + metadataFileAndOverrideOrDefault(c, "pipeline-deploy-task", func(s string) { m.Curr.DeployTask = s }, c.String) + + // Current Pipeline Commit + metadataFileAndOverrideOrDefault(c, "commit-sha", func(s string) { m.Curr.Commit.Sha = s }, c.String) + metadataFileAndOverrideOrDefault(c, "commit-ref", func(s string) { m.Curr.Commit.Ref = s }, c.String) + metadataFileAndOverrideOrDefault(c, "commit-refspec", func(s string) { m.Curr.Commit.Refspec = s }, c.String) + metadataFileAndOverrideOrDefault(c, "commit-branch", func(s string) { m.Curr.Commit.Branch = s }, c.String) + metadataFileAndOverrideOrDefault(c, "commit-message", func(s string) { m.Curr.Commit.Message = s }, c.String) + metadataFileAndOverrideOrDefault(c, "commit-author-name", func(s string) { m.Curr.Commit.Author.Name = s }, c.String) + metadataFileAndOverrideOrDefault(c, "commit-author-email", func(s string) { m.Curr.Commit.Author.Email = s }, c.String) + metadataFileAndOverrideOrDefault(c, "commit-author-avatar", func(s string) { m.Curr.Commit.Author.Avatar = s }, c.String) + + metadataFileAndOverrideOrDefault(c, "commit-pull-labels", func(sl []string) { m.Curr.Commit.PullRequestLabels = sl }, c.StringSlice) + metadataFileAndOverrideOrDefault(c, "commit-release-is-pre", func(b bool) { m.Curr.Commit.IsPrerelease = b }, c.Bool) + + // Previous Pipeline + metadataFileAndOverrideOrDefault(c, "prev-pipeline-number", func(i int64) { m.Prev.Number = i }, c.Int) + metadataFileAndOverrideOrDefault(c, "prev-pipeline-created", func(i int64) { m.Prev.Created = i }, c.Int) + metadataFileAndOverrideOrDefault(c, "prev-pipeline-started", func(i int64) { m.Prev.Started = i }, c.Int) + metadataFileAndOverrideOrDefault(c, "prev-pipeline-finished", func(i int64) { m.Prev.Finished = i }, c.Int) + metadataFileAndOverrideOrDefault(c, "prev-pipeline-status", func(s string) { m.Prev.Status = s }, c.String) + metadataFileAndOverrideOrDefault(c, "prev-pipeline-event", func(s string) { m.Prev.Event = s }, c.String) + metadataFileAndOverrideOrDefault(c, "prev-pipeline-url", func(s string) { m.Prev.ForgeURL = s }, c.String) + + // Previous Pipeline Commit + metadataFileAndOverrideOrDefault(c, "prev-commit-sha", func(s string) { m.Prev.Commit.Sha = s }, c.String) + metadataFileAndOverrideOrDefault(c, "prev-commit-ref", func(s string) { m.Prev.Commit.Ref = s }, c.String) + metadataFileAndOverrideOrDefault(c, "prev-commit-refspec", func(s string) { m.Prev.Commit.Refspec = s }, c.String) + metadataFileAndOverrideOrDefault(c, "prev-commit-branch", func(s string) { m.Prev.Commit.Branch = s }, c.String) + metadataFileAndOverrideOrDefault(c, "prev-commit-message", func(s string) { m.Prev.Commit.Message = s }, c.String) + metadataFileAndOverrideOrDefault(c, "prev-commit-author-name", func(s string) { m.Prev.Commit.Author.Name = s }, c.String) + metadataFileAndOverrideOrDefault(c, "prev-commit-author-email", func(s string) { m.Prev.Commit.Author.Email = s }, c.String) + metadataFileAndOverrideOrDefault(c, "prev-commit-author-avatar", func(s string) { m.Prev.Commit.Author.Avatar = s }, c.String) + + // Workflow + metadataFileAndOverrideOrDefault(c, "workflow-name", func(s string) { m.Workflow.Name = s }, c.String) + metadataFileAndOverrideOrDefault(c, "workflow-number", func(i int64) { m.Workflow.Number = int(i) }, c.Int) + m.Workflow.Matrix = axis + + // System + metadataFileAndOverrideOrDefault(c, "system-name", func(s string) { m.Sys.Name = s }, c.String) + metadataFileAndOverrideOrDefault(c, "system-url", func(s string) { m.Sys.URL = s }, c.String) + metadataFileAndOverrideOrDefault(c, "system-host", func(s string) { m.Sys.Host = s }, c.String) + m.Sys.Platform = platform + m.Sys.Version = version.Version + + // Forge + metadataFileAndOverrideOrDefault(c, "forge-type", func(s string) { m.Forge.Type = s }, c.String) + metadataFileAndOverrideOrDefault(c, "forge-url", func(s string) { m.Forge.URL = s }, c.String) + + if w != nil { + m.Workflow = *w } - return metadata.Metadata{ - Repo: metadata.Repo{ - Name: repoName, - Owner: repoOwner, - RemoteID: c.String("repo-remote-id"), - ForgeURL: c.String("repo-url"), - SCM: c.String("repo-scm"), - Branch: c.String("repo-default-branch"), - CloneURL: c.String("repo-clone-url"), - CloneSSHURL: c.String("repo-clone-ssh-url"), - Private: c.Bool("repo-private"), - Trusted: c.Bool("repo-trusted"), - }, - Curr: metadata.Pipeline{ - Number: c.Int("pipeline-number"), - Parent: c.Int("pipeline-parent"), - Created: c.Int("pipeline-created"), - Started: c.Int("pipeline-started"), - Finished: c.Int("pipeline-finished"), - Status: c.String("pipeline-status"), - Event: c.String("pipeline-event"), - ForgeURL: c.String("pipeline-url"), - DeployTo: c.String("pipeline-deploy-to"), - DeployTask: c.String("pipeline-deploy-task"), - Commit: metadata.Commit{ - Sha: c.String("commit-sha"), - Ref: c.String("commit-ref"), - Refspec: c.String("commit-refspec"), - Branch: c.String("commit-branch"), - Message: c.String("commit-message"), - Author: metadata.Author{ - Name: c.String("commit-author-name"), - Email: c.String("commit-author-email"), - Avatar: c.String("commit-author-avatar"), - }, - PullRequestLabels: c.StringSlice("commit-pull-labels"), - IsPrerelease: c.Bool("commit-release-is-pre"), - ChangedFiles: changedFiles, - }, - }, - Prev: metadata.Pipeline{ - Number: c.Int("prev-pipeline-number"), - Created: c.Int("prev-pipeline-created"), - Started: c.Int("prev-pipeline-started"), - Finished: c.Int("prev-pipeline-finished"), - Status: c.String("prev-pipeline-status"), - Event: c.String("prev-pipeline-event"), - ForgeURL: c.String("prev-pipeline-url"), - Commit: metadata.Commit{ - Sha: c.String("prev-commit-sha"), - Ref: c.String("prev-commit-ref"), - Refspec: c.String("prev-commit-refspec"), - Branch: c.String("prev-commit-branch"), - Message: c.String("prev-commit-message"), - Author: metadata.Author{ - Name: c.String("prev-commit-author-name"), - Email: c.String("prev-commit-author-email"), - Avatar: c.String("prev-commit-author-avatar"), - }, - }, - }, - Workflow: metadata.Workflow{ - Name: c.String("workflow-name"), - Number: int(c.Int("workflow-number")), - Matrix: axis, - }, - Sys: metadata.System{ - Name: c.String("system-name"), - URL: c.String("system-url"), - Host: c.String("system-host"), - Platform: platform, - Version: version.Version, - }, - Forge: metadata.Forge{ - Type: c.String("forge-type"), - URL: c.String("forge-url"), - }, - }, nil + return m, nil +} + +// metadataFileAndOverrideOrDefault will either use the flag default or if metadata file is set only overload if explicit set. +func metadataFileAndOverrideOrDefault[T any](c *cli.Command, flag string, setter func(T), getter func(string) T) { + if !c.IsSet("metadata-file") || c.IsSet(flag) { + setter(getter(flag)) + } } diff --git a/cli/exec/metadata_test.go b/cli/exec/metadata_test.go new file mode 100644 index 000000000..188426e8d --- /dev/null +++ b/cli/exec/metadata_test.go @@ -0,0 +1,142 @@ +// Copyright 2024 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 exec + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + + "go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/metadata" + "go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/yaml/matrix" +) + +func TestMetadataFromContext(t *testing.T) { + sampleMetadata := &metadata.Metadata{ + Repo: metadata.Repo{Owner: "test-user", Name: "test-repo"}, + Curr: metadata.Pipeline{Number: 5}, + } + + runCommand := func(flags []cli.Flag, fn func(c *cli.Command)) { + c := &cli.Command{ + Flags: flags, + Action: func(_ context.Context, c *cli.Command) error { + fn(c) + return nil + }, + } + assert.NoError(t, c.Run(context.Background(), []string{"woodpecker-cli"})) + } + + t.Run("LoadFromFile", func(t *testing.T) { + tempFileName := createTempFile(t, sampleMetadata) + + flags := []cli.Flag{ + &cli.StringFlag{Name: "metadata-file"}, + } + + runCommand(flags, func(c *cli.Command) { + _ = c.Set("metadata-file", tempFileName) + + m, err := metadataFromContext(context.Background(), c, nil, nil) + require.NoError(t, err) + assert.Equal(t, "test-repo", m.Repo.Name) + assert.Equal(t, int64(5), m.Curr.Number) + }) + }) + + t.Run("OverrideFromFlags", func(t *testing.T) { + tempFileName := createTempFile(t, sampleMetadata) + + flags := []cli.Flag{ + &cli.StringFlag{Name: "metadata-file"}, + &cli.StringFlag{Name: "repo-name"}, + &cli.IntFlag{Name: "pipeline-number"}, + } + + runCommand(flags, func(c *cli.Command) { + _ = c.Set("metadata-file", tempFileName) + _ = c.Set("repo-name", "aUser/override-repo") + _ = c.Set("pipeline-number", "10") + + m, err := metadataFromContext(context.Background(), c, nil, nil) + require.NoError(t, err) + assert.Equal(t, "override-repo", m.Repo.Name) + assert.Equal(t, int64(10), m.Curr.Number) + }) + }) + + t.Run("InvalidFile", func(t *testing.T) { + tempFile, err := os.CreateTemp("", "invalid.json") + require.NoError(t, err) + t.Cleanup(func() { os.Remove(tempFile.Name()) }) + + _, err = tempFile.Write([]byte("invalid json")) + require.NoError(t, err) + + flags := []cli.Flag{ + &cli.StringFlag{Name: "metadata-file"}, + } + + runCommand(flags, func(c *cli.Command) { + _ = c.Set("metadata-file", tempFile.Name()) + + _, err = metadataFromContext(context.Background(), c, nil, nil) + assert.Error(t, err) + }) + }) + + t.Run("DefaultValues", func(t *testing.T) { + flags := []cli.Flag{ + &cli.StringFlag{Name: "repo-name", Value: "test/default-repo"}, + &cli.IntFlag{Name: "pipeline-number", Value: 1}, + } + + runCommand(flags, func(c *cli.Command) { + m, err := metadataFromContext(context.Background(), c, nil, nil) + require.NoError(t, err) + if assert.NotNil(t, m) { + assert.Equal(t, "test", m.Repo.Owner) + assert.Equal(t, "default-repo", m.Repo.Name) + assert.Equal(t, int64(1), m.Curr.Number) + } + }) + }) + + t.Run("MatrixAxis", func(t *testing.T) { + runCommand([]cli.Flag{}, func(c *cli.Command) { + axis := matrix.Axis{"go": "1.16", "os": "linux"} + m, err := metadataFromContext(context.Background(), c, axis, nil) + require.NoError(t, err) + assert.EqualValues(t, map[string]string{"go": "1.16", "os": "linux"}, m.Workflow.Matrix) + }) + }) +} + +func createTempFile(t *testing.T, content any) string { + t.Helper() + tempFile, err := os.CreateTemp("", "metadata.json") + require.NoError(t, err) + t.Cleanup(func() { os.Remove(tempFile.Name()) }) + + err = json.NewEncoder(tempFile).Encode(content) + require.NoError(t, err) + return tempFile.Name() +} diff --git a/cmd/server/docs/docs.go b/cmd/server/docs/docs.go index ac80d2883..16e6670c6 100644 --- a/cmd/server/docs/docs.go +++ b/cmd/server/docs/docs.go @@ -3144,6 +3144,49 @@ const docTemplate = `{ } } }, + "/repos/{repo_id}/pipelines/{number}/metadata": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Pipelines" + ], + "summary": "Get metadata for a pipeline or a specific workflow, including previous pipeline info", + "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": "number", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/metadata.Metadata" + } + } + } + } + }, "/repos/{repo_id}/pull_requests": { "get": { "produces": [ @@ -5148,6 +5191,225 @@ const docTemplate = `{ "EventManual" ] }, + "metadata.Author": { + "type": "object", + "properties": { + "avatar": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "metadata.Commit": { + "type": "object", + "properties": { + "author": { + "$ref": "#/definitions/metadata.Author" + }, + "branch": { + "type": "string" + }, + "changed_files": { + "type": "array", + "items": { + "type": "string" + } + }, + "is_prerelease": { + "type": "boolean" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "message": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "refspec": { + "type": "string" + }, + "sha": { + "type": "string" + } + } + }, + "metadata.Forge": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "metadata.Metadata": { + "type": "object", + "properties": { + "curr": { + "$ref": "#/definitions/metadata.Pipeline" + }, + "forge": { + "$ref": "#/definitions/metadata.Forge" + }, + "id": { + "type": "string" + }, + "prev": { + "$ref": "#/definitions/metadata.Pipeline" + }, + "repo": { + "$ref": "#/definitions/metadata.Repo" + }, + "step": { + "$ref": "#/definitions/metadata.Step" + }, + "sys": { + "$ref": "#/definitions/metadata.System" + }, + "workflow": { + "$ref": "#/definitions/metadata.Workflow" + } + } + }, + "metadata.Pipeline": { + "type": "object", + "properties": { + "commit": { + "$ref": "#/definitions/metadata.Commit" + }, + "created": { + "type": "integer" + }, + "cron": { + "type": "string" + }, + "event": { + "type": "string" + }, + "finished": { + "type": "integer" + }, + "forge_url": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "parent": { + "type": "integer" + }, + "started": { + "type": "integer" + }, + "status": { + "type": "string" + }, + "target": { + "type": "string" + }, + "task": { + "type": "string" + } + } + }, + "metadata.Repo": { + "type": "object", + "properties": { + "clone_url": { + "type": "string" + }, + "clone_url_ssh": { + "type": "string" + }, + "default_branch": { + "type": "string" + }, + "forge_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "private": { + "type": "boolean" + }, + "remote_id": { + "type": "string" + }, + "scm": { + "type": "string" + }, + "trusted": { + "type": "boolean" + } + } + }, + "metadata.Step": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "number": { + "type": "integer" + } + } + }, + "metadata.System": { + "type": "object", + "properties": { + "arch": { + "type": "string" + }, + "host": { + "type": "string" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "metadata.Workflow": { + "type": "object", + "properties": { + "matrix": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer" + } + } + }, "model.ForgeType": { "type": "string", "enum": [ diff --git a/server/api/pipeline.go b/server/api/pipeline.go index f1e4b41a0..a1b191ab4 100644 --- a/server/api/pipeline.go +++ b/server/api/pipeline.go @@ -30,8 +30,10 @@ import ( "go.woodpecker-ci.org/woodpecker/v2/server" "go.woodpecker-ci.org/woodpecker/v2/server/model" "go.woodpecker-ci.org/woodpecker/v2/server/pipeline" + "go.woodpecker-ci.org/woodpecker/v2/server/pipeline/stepbuilder" "go.woodpecker-ci.org/woodpecker/v2/server/router/middleware/session" "go.woodpecker-ci.org/woodpecker/v2/server/store" + "go.woodpecker-ci.org/woodpecker/v2/server/store/types" ) // CreatePipeline @@ -392,6 +394,47 @@ func GetPipelineConfig(c *gin.Context) { c.JSON(http.StatusOK, configs) } +// GetPipelineMetadata +// +// @Summary Get metadata for a pipeline or a specific workflow, including previous pipeline info +// @Router /repos/{repo_id}/pipelines/{number}/metadata [get] +// @Produce json +// @Success 200 {object} metadata.Metadata +// @Tags Pipelines +// @Param Authorization header string true "Insert your personal access token" default(Bearer ) +// @Param repo_id path int true "the repository id" +// @Param number path int true "the number of the pipeline" +func GetPipelineMetadata(c *gin.Context) { + repo := session.Repo(c) + num, err := strconv.ParseInt(c.Param("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 + } + + forge, err := server.Config.Services.Manager.ForgeFromRepo(repo) + if err != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + prevPipeline, err := _store.GetPipelineLastBefore(repo, currentPipeline.Branch, currentPipeline.ID) + if err != nil && !errors.Is(err, types.RecordNotExist) { + handleDBError(c, err) + return + } + + metadata := stepbuilder.MetadataFromStruct(forge, repo, currentPipeline, prevPipeline, nil, server.Config.Server.Host) + c.JSON(http.StatusOK, metadata) +} + // CancelPipeline // // @Summary Cancel a pipeline diff --git a/server/api/pipeline_test.go b/server/api/pipeline_test.go index d66301170..80e2e7965 100644 --- a/server/api/pipeline_test.go +++ b/server/api/pipeline_test.go @@ -1,129 +1,217 @@ +// Copyright 2024 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 api import ( + "encoding/json" "net/http" "net/http/httptest" "testing" - "github.com/franela/goblin" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "go.woodpecker-ci.org/woodpecker/v2/pipeline/frontend/metadata" + "go.woodpecker-ci.org/woodpecker/v2/server" + forge_mocks "go.woodpecker-ci.org/woodpecker/v2/server/forge/mocks" "go.woodpecker-ci.org/woodpecker/v2/server/model" - "go.woodpecker-ci.org/woodpecker/v2/server/store/mocks" + mocks_manager "go.woodpecker-ci.org/woodpecker/v2/server/services/mocks" + store_mocks "go.woodpecker-ci.org/woodpecker/v2/server/store/mocks" + "go.woodpecker-ci.org/woodpecker/v2/server/store/types" ) var fakePipeline = &model.Pipeline{ + ID: 2, + Number: 2, Status: model.StatusSuccess, } func TestGetPipelines(t *testing.T) { gin.SetMode(gin.TestMode) - g := goblin.Goblin(t) - g.Describe("Pipeline", func() { - g.It("should get pipelines", func() { - pipelines := []*model.Pipeline{fakePipeline} + t.Run("should get pipelines", func(t *testing.T) { + pipelines := []*model.Pipeline{fakePipeline} - mockStore := mocks.NewStore(t) - mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil) + mockStore := store_mocks.NewStore(t) + mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil) - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Set("store", mockStore) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("store", mockStore) - GetPipelines(c) + GetPipelines(c) - mockStore.AssertCalled(t, "GetPipelineList", mock.Anything, mock.Anything, mock.Anything) - assert.Equal(t, http.StatusOK, c.Writer.Status()) - }) + mockStore.AssertCalled(t, "GetPipelineList", mock.Anything, mock.Anything, mock.Anything) + assert.Equal(t, http.StatusOK, c.Writer.Status()) + }) - g.It("should not parse pipeline filter", func() { - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request, _ = http.NewRequest(http.MethodDelete, "/?before=2023-01-16&after=2023-01-15", nil) + t.Run("should not parse pipeline filter", func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request, _ = http.NewRequest(http.MethodDelete, "/?before=2023-01-16&after=2023-01-15", nil) - GetPipelines(c) + GetPipelines(c) - assert.Equal(t, http.StatusBadRequest, c.Writer.Status()) - }) + assert.Equal(t, http.StatusBadRequest, c.Writer.Status()) + }) - g.It("should parse pipeline filter", func() { - pipelines := []*model.Pipeline{fakePipeline} + t.Run("should parse pipeline filter", func(t *testing.T) { + pipelines := []*model.Pipeline{fakePipeline} - mockStore := mocks.NewStore(t) - mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil) + mockStore := store_mocks.NewStore(t) + mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil) - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Set("store", mockStore) - c.Request, _ = http.NewRequest(http.MethodDelete, "/?2023-01-16T15:00:00Z&after=2023-01-15T15:00:00Z", nil) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set("store", mockStore) + c.Request, _ = http.NewRequest(http.MethodDelete, "/?2023-01-16T15:00:00Z&after=2023-01-15T15:00:00Z", nil) - GetPipelines(c) + GetPipelines(c) - assert.Equal(t, http.StatusOK, c.Writer.Status()) - }) + assert.Equal(t, http.StatusOK, c.Writer.Status()) + }) - g.It("should parse pipeline filter with tz offset", func() { - pipelines := []*model.Pipeline{fakePipeline} + t.Run("should parse pipeline filter with tz offset", func(t *testing.T) { + pipelines := []*model.Pipeline{fakePipeline} - mockStore := mocks.NewStore(t) - mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil) + mockStore := store_mocks.NewStore(t) + mockStore.On("GetPipelineList", mock.Anything, mock.Anything, mock.Anything).Return(pipelines, nil) - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Set("store", mockStore) - c.Request, _ = http.NewRequest(http.MethodDelete, "/?before=2023-01-16T15:00:00%2B01:00&after=2023-01-15T15:00:00%2B01:00", nil) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set("store", mockStore) + c.Request, _ = http.NewRequest(http.MethodDelete, "/?before=2023-01-16T15:00:00%2B01:00&after=2023-01-15T15:00:00%2B01:00", nil) - GetPipelines(c) + GetPipelines(c) - assert.Equal(t, http.StatusOK, c.Writer.Status()) - }) + assert.Equal(t, http.StatusOK, c.Writer.Status()) }) } func TestDeletePipeline(t *testing.T) { gin.SetMode(gin.TestMode) - g := goblin.Goblin(t) - g.Describe("Pipeline", func() { - g.It("should delete pipeline", func() { - mockStore := mocks.NewStore(t) - mockStore.On("GetPipelineNumber", mock.Anything, mock.Anything).Return(fakePipeline, nil) - mockStore.On("DeletePipeline", mock.Anything).Return(nil) + t.Run("should delete pipeline", func(t *testing.T) { + mockStore := store_mocks.NewStore(t) + mockStore.On("GetPipelineNumber", mock.Anything, mock.Anything).Return(fakePipeline, nil) + mockStore.On("DeletePipeline", mock.Anything).Return(nil) - c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set("store", mockStore) + c.Params = gin.Params{{Key: "number", Value: "2"}} + + 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.NewStore(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: "number", Value: "2"}} + + DeletePipeline(c) + + mockStore.AssertCalled(t, "GetPipelineNumber", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "DeletePipeline", mock.Anything) + assert.Equal(t, http.StatusUnprocessableEntity, c.Writer.Status()) + }) +} + +func TestGetPipelineMetadata(t *testing.T) { + gin.SetMode(gin.TestMode) + + prevPipeline := &model.Pipeline{ + ID: 1, + Number: 1, + Status: model.StatusFailure, + } + + fakeRepo := &model.Repo{ID: 1} + + mockForge := forge_mocks.NewForge(t) + mockForge.On("Name").Return("mock") + mockForge.On("URL").Return("https://codeberg.org") + + mockManager := mocks_manager.NewManager(t) + mockManager.On("ForgeFromRepo", fakeRepo).Return(mockForge, nil) + server.Config.Services.Manager = mockManager + + mockStore := store_mocks.NewStore(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: "number", Value: "2"}} c.Set("store", mockStore) - c.Params = gin.Params{{Key: "number", Value: "1"}} + c.Set("forge", mockForge) + c.Set("repo", fakeRepo) - DeletePipeline(c) + GetPipelineMetadata(c) - mockStore.AssertCalled(t, "GetPipelineNumber", mock.Anything, mock.Anything) - mockStore.AssertCalled(t, "DeletePipeline", mock.Anything) - assert.Equal(t, http.StatusNoContent, c.Writer.Status()) + assert.Equal(t, http.StatusOK, w.Code) + + var response metadata.Metadata + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + + assert.Equal(t, int64(1), response.Repo.ID) + assert.Equal(t, int64(2), response.Curr.Number) + assert.Equal(t, int64(1), response.Prev.Number) }) - g.It("should not delete without pipeline number", func() { - c, _ := gin.CreateTestContext(httptest.NewRecorder()) + 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: "number", Value: "invalid"}} - DeletePipeline(c) + GetPipelineMetadata(c) - assert.Equal(t, http.StatusBadRequest, c.Writer.Status()) + assert.Equal(t, http.StatusBadRequest, w.Code) }) - g.It("should not delete pending", func() { - fakePipeline.Status = model.StatusPending + t.Run("should return not found for non-existent pipeline", func(t *testing.T) { + mockStore := store_mocks.NewStore(t) + mockStore.On("GetPipelineNumber", mock.Anything, int64(3)).Return((*model.Pipeline)(nil), types.RecordNotExist) - mockStore := mocks.NewStore(t) - mockStore.On("GetPipelineNumber", mock.Anything, mock.Anything).Return(fakePipeline, nil) - - c, _ := gin.CreateTestContext(httptest.NewRecorder()) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "number", Value: "3"}} c.Set("store", mockStore) - c.Params = gin.Params{{Key: "number", Value: "1"}} + c.Set("repo", fakeRepo) - DeletePipeline(c) + GetPipelineMetadata(c) - mockStore.AssertCalled(t, "GetPipelineNumber", mock.Anything, mock.Anything) - mockStore.AssertNotCalled(t, "DeletePipeline", mock.Anything) - assert.Equal(t, http.StatusUnprocessableEntity, c.Writer.Status()) + assert.Equal(t, http.StatusNotFound, w.Code) }) }) } diff --git a/server/pipeline/items.go b/server/pipeline/items.go index b6b2dc917..70e31f348 100644 --- a/server/pipeline/items.go +++ b/server/pipeline/items.go @@ -38,7 +38,7 @@ func parsePipeline(forge forge.Forge, store store.Store, currentPipeline *model. } // get the previous pipeline so that we can send status change notifications - last, err := store.GetPipelineLastBefore(repo, currentPipeline.Branch, currentPipeline.ID) + prev, err := store.GetPipelineLastBefore(repo, currentPipeline.Branch, currentPipeline.ID) if err != nil && !errors.Is(err, sql.ErrNoRows) { log.Error().Err(err).Str("repo", repo.FullName).Msgf("error getting last pipeline before pipeline number '%d'", currentPipeline.Number) } @@ -74,7 +74,7 @@ func parsePipeline(forge forge.Forge, store store.Store, currentPipeline *model. b := stepbuilder.StepBuilder{ Repo: repo, Curr: currentPipeline, - Last: last, + Prev: prev, Netrc: netrc, Secs: secs, Regs: regs, diff --git a/server/pipeline/stepbuilder/metadata.go b/server/pipeline/stepbuilder/metadata.go index 3ea13f9e4..24ba90e96 100644 --- a/server/pipeline/stepbuilder/metadata.go +++ b/server/pipeline/stepbuilder/metadata.go @@ -25,7 +25,7 @@ import ( ) // MetadataFromStruct return the metadata from a pipeline will run with. -func MetadataFromStruct(forge metadata.ServerForge, repo *model.Repo, pipeline, last *model.Pipeline, workflow *model.Workflow, sysURL string) metadata.Metadata { +func MetadataFromStruct(forge metadata.ServerForge, repo *model.Repo, pipeline, prev *model.Pipeline, workflow *model.Workflow, sysURL string) metadata.Metadata { host := sysURL uri, err := url.Parse(sysURL) if err == nil { @@ -78,7 +78,7 @@ func MetadataFromStruct(forge metadata.ServerForge, repo *model.Repo, pipeline, return metadata.Metadata{ Repo: fRepo, Curr: metadataPipelineFromModelPipeline(pipeline, true), - Prev: metadataPipelineFromModelPipeline(last, false), + Prev: metadataPipelineFromModelPipeline(prev, false), Workflow: fWorkflow, Step: metadata.Step{}, Sys: metadata.System{ diff --git a/server/pipeline/stepbuilder/metadata_test.go b/server/pipeline/stepbuilder/metadata_test.go index 368eae47e..53cf388e6 100644 --- a/server/pipeline/stepbuilder/metadata_test.go +++ b/server/pipeline/stepbuilder/metadata_test.go @@ -33,7 +33,7 @@ func TestMetadataFromStruct(t *testing.T) { name string forge metadata.ServerForge repo *model.Repo - pipeline, last *model.Pipeline + pipeline, prev *model.Pipeline workflow *model.Workflow sysURL string expectedMetadata metadata.Metadata @@ -63,7 +63,7 @@ func TestMetadataFromStruct(t *testing.T) { forge: forge, repo: &model.Repo{FullName: "testUser/testRepo", ForgeURL: "https://gitea.com/testUser/testRepo", Clone: "https://gitea.com/testUser/testRepo.git", CloneSSH: "git@gitea.com:testUser/testRepo.git", Branch: "main", IsSCMPrivate: true, SCMKind: "git"}, pipeline: &model.Pipeline{Number: 3, ChangedFiles: []string{"test.go", "markdown file.md"}}, - last: &model.Pipeline{Number: 2}, + prev: &model.Pipeline{Number: 2}, workflow: &model.Workflow{Name: "hello"}, sysURL: "https://example.com", expectedMetadata: metadata.Metadata{ @@ -98,7 +98,7 @@ func TestMetadataFromStruct(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - result := MetadataFromStruct(testCase.forge, testCase.repo, testCase.pipeline, testCase.last, testCase.workflow, testCase.sysURL) + result := MetadataFromStruct(testCase.forge, testCase.repo, testCase.pipeline, testCase.prev, testCase.workflow, testCase.sysURL) assert.EqualValues(t, testCase.expectedMetadata, result) assert.EqualValues(t, testCase.expectedEnviron, result.Environ()) }) diff --git a/server/pipeline/stepbuilder/stepBuilder.go b/server/pipeline/stepbuilder/stepBuilder.go index aeb91a25a..a59f4b0ba 100644 --- a/server/pipeline/stepbuilder/stepBuilder.go +++ b/server/pipeline/stepbuilder/stepBuilder.go @@ -42,7 +42,7 @@ import ( type StepBuilder struct { Repo *model.Repo Curr *model.Pipeline - Last *model.Pipeline + Prev *model.Pipeline Netrc *model.Netrc Secs []*model.Secret Regs []*model.Registry @@ -115,7 +115,7 @@ func (b *StepBuilder) Build() (items []*Item, errorsAndWarnings error) { } func (b *StepBuilder) genItemForWorkflow(workflow *model.Workflow, axis matrix.Axis, data string) (item *Item, errorsAndWarnings error) { - workflowMetadata := MetadataFromStruct(b.Forge, b.Repo, b.Curr, b.Last, workflow, b.Host) + workflowMetadata := MetadataFromStruct(b.Forge, b.Repo, b.Curr, b.Prev, workflow, b.Host) environ := b.environmentVariables(workflowMetadata, axis) // add global environment variables for substituting diff --git a/server/pipeline/stepbuilder/stepBuilder_test.go b/server/pipeline/stepbuilder/stepBuilder_test.go index ae86ba35f..c9b616e4d 100644 --- a/server/pipeline/stepbuilder/stepBuilder_test.go +++ b/server/pipeline/stepbuilder/stepBuilder_test.go @@ -42,7 +42,7 @@ func TestGlobalEnvsubst(t *testing.T) { Message: "aaa", Event: model.EventPush, }, - Last: &model.Pipeline{}, + Prev: &model.Pipeline{}, Netrc: &model.Netrc{}, Secs: []*model.Secret{}, Regs: []*model.Registry{}, @@ -81,7 +81,7 @@ func TestMissingGlobalEnvsubst(t *testing.T) { Message: "aaa", Event: model.EventPush, }, - Last: &model.Pipeline{}, + Prev: &model.Pipeline{}, Netrc: &model.Netrc{}, Secs: []*model.Secret{}, Regs: []*model.Registry{}, @@ -116,7 +116,7 @@ func TestMultilineEnvsubst(t *testing.T) { Message: `aaa bbb`, }, - Last: &model.Pipeline{}, + Prev: &model.Pipeline{}, Netrc: &model.Netrc{}, Secs: []*model.Secret{}, Regs: []*model.Registry{}, @@ -159,7 +159,7 @@ func TestMultiPipeline(t *testing.T) { Curr: &model.Pipeline{ Event: model.EventPush, }, - Last: &model.Pipeline{}, + Prev: &model.Pipeline{}, Netrc: &model.Netrc{}, Secs: []*model.Secret{}, Regs: []*model.Registry{}, @@ -200,7 +200,7 @@ func TestDependsOn(t *testing.T) { Curr: &model.Pipeline{ Event: model.EventPush, }, - Last: &model.Pipeline{}, + Prev: &model.Pipeline{}, Netrc: &model.Netrc{}, Secs: []*model.Secret{}, Regs: []*model.Registry{}, @@ -255,7 +255,7 @@ func TestRunsOn(t *testing.T) { Curr: &model.Pipeline{ Event: model.EventPush, }, - Last: &model.Pipeline{}, + Prev: &model.Pipeline{}, Netrc: &model.Netrc{}, Secs: []*model.Secret{}, Regs: []*model.Registry{}, @@ -296,7 +296,7 @@ func TestPipelineName(t *testing.T) { Curr: &model.Pipeline{ Event: model.EventPush, }, - Last: &model.Pipeline{}, + Prev: &model.Pipeline{}, Netrc: &model.Netrc{}, Secs: []*model.Secret{}, Regs: []*model.Registry{}, @@ -339,7 +339,7 @@ func TestBranchFilter(t *testing.T) { Branch: "dev", Event: model.EventPush, }, - Last: &model.Pipeline{}, + Prev: &model.Pipeline{}, Netrc: &model.Netrc{}, Secs: []*model.Secret{}, Regs: []*model.Registry{}, @@ -382,7 +382,7 @@ func TestRootWhenFilter(t *testing.T) { Forge: getMockForge(t), Repo: &model.Repo{}, Curr: &model.Pipeline{Event: "tag"}, - Last: &model.Pipeline{}, + Prev: &model.Pipeline{}, Netrc: &model.Netrc{}, Secs: []*model.Secret{}, Regs: []*model.Registry{}, @@ -432,7 +432,7 @@ func TestZeroSteps(t *testing.T) { Forge: getMockForge(t), Repo: &model.Repo{}, Curr: pipeline, - Last: &model.Pipeline{}, + Prev: &model.Pipeline{}, Netrc: &model.Netrc{}, Secs: []*model.Secret{}, Regs: []*model.Registry{}, @@ -472,7 +472,7 @@ func TestZeroStepsAsMultiPipelineDeps(t *testing.T) { Forge: getMockForge(t), Repo: &model.Repo{}, Curr: pipeline, - Last: &model.Pipeline{}, + Prev: &model.Pipeline{}, Netrc: &model.Netrc{}, Secs: []*model.Secret{}, Regs: []*model.Registry{}, @@ -530,7 +530,7 @@ func TestZeroStepsAsMultiPipelineTransitiveDeps(t *testing.T) { Forge: getMockForge(t), Repo: &model.Repo{}, Curr: pipeline, - Last: &model.Pipeline{}, + Prev: &model.Pipeline{}, Netrc: &model.Netrc{}, Secs: []*model.Secret{}, Regs: []*model.Registry{}, diff --git a/server/router/api.go b/server/router/api.go index a7c9f8f5e..4ee7a9554 100644 --- a/server/router/api.go +++ b/server/router/api.go @@ -102,6 +102,7 @@ func apiRoutes(e *gin.RouterGroup) { repo.DELETE("/pipelines/:number", session.MustRepoAdmin(), api.DeletePipeline) repo.GET("/pipelines/:number", api.GetPipeline) repo.GET("/pipelines/:number/config", api.GetPipelineConfig) + repo.GET("/pipelines/:number/metadata", session.MustPush, api.GetPipelineMetadata) // requires push permissions repo.POST("/pipelines/:number", session.MustPush, api.PostPipeline) diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json index d5d586f41..8783b65e3 100644 --- a/web/src/assets/locales/en.json +++ b/web/src/assets/locales/en.json @@ -246,7 +246,16 @@ "show_errors": "Show errors", "we_got_some_errors": "Oh no, we got some errors!", "duration": "Pipeline duration", - "created": "Created: {created}" + "created": "Created: {created}", + "debug": { + "title": "Debug", + "download_metadata": "Download metadata", + "metadata_download_error": "Error downloading metadata", + "metadata_download_successful": "Metadata downloaded successfully", + "no_permission": "You don't have permission to access debug information.", + "metadata_exec_title": "Rerun pipeline locally", + "metadata_exec_desc": "Download this pipeline's metadata to run it locally. This allows you to troubleshoot issues and test changes before committing them." + } } }, "org": { diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index e06c401a8..83025f75a 100644 --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -119,6 +119,10 @@ export default class WoodpeckerClient extends ApiClient { return this._get(`/api/repos/${repoId}/pipelines/${pipelineNumber}/config`) as Promise; } + async getPipelineMetadata(repoId: number, pipelineNumber: number): Promise { + return this._get(`/api/repos/${repoId}/pipelines/${pipelineNumber}/metadata`) as Promise; + } + async getPipelineFeed(): Promise { return this._get(`/api/user/feed`) as Promise; } diff --git a/web/src/router.ts b/web/src/router.ts index 238fcc0aa..ad807561b 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -94,6 +94,11 @@ const routes: RouteRecordRaw[] = [ component: (): Component => import('~/views/repo/pipeline/PipelineErrors.vue'), props: true, }, + { + path: 'debug', + name: 'repo-pipeline-debug', + component: (): Component => import('~/views/repo/pipeline/PipelineDebug.vue'), + }, ], }, { diff --git a/web/src/views/repo/pipeline/PipelineDebug.vue b/web/src/views/repo/pipeline/PipelineDebug.vue new file mode 100644 index 000000000..58fcfdae5 --- /dev/null +++ b/web/src/views/repo/pipeline/PipelineDebug.vue @@ -0,0 +1,77 @@ + + + diff --git a/web/src/views/repo/pipeline/PipelineWrapper.vue b/web/src/views/repo/pipeline/PipelineWrapper.vue index a0b898c4c..5cbf6f7b2 100644 --- a/web/src/views/repo/pipeline/PipelineWrapper.vue +++ b/web/src/views/repo/pipeline/PipelineWrapper.vue @@ -93,6 +93,7 @@ id="changed-files" :title="$t('repo.pipeline.files', { files: pipeline.changed_files?.length })" /> + @@ -219,6 +220,10 @@ const activeTab = computed({ return 'errors'; } + if (route.name === 'repo-pipeline-debug' && repoPermissions.value?.push) { + return 'debug'; + } + return 'tasks'; }, set(tab: string) { @@ -237,6 +242,10 @@ const activeTab = computed({ if (tab === 'errors') { router.replace({ name: 'repo-pipeline-errors' }); } + + if (tab === 'debug' && repoPermissions.value?.push) { + router.replace({ name: 'repo-pipeline-debug' }); + } }, }); diff --git a/woodpecker-go/woodpecker/interface.go b/woodpecker-go/woodpecker/interface.go index 3eb54a2b6..95ff60859 100644 --- a/woodpecker-go/woodpecker/interface.go +++ b/woodpecker-go/woodpecker/interface.go @@ -110,6 +110,9 @@ type Client interface { // PipelineKill force kills the running pipeline. PipelineKill(repoID, pipeline int64) error + // PipelineMetadata returns metadata for a pipeline. + PipelineMetadata(repoID int64, pipelineNumber int) ([]byte, error) + // StepLogEntries returns the LogEntries for the given pipeline step StepLogEntries(repoID, pipeline, stepID int64) ([]*LogEntry, error) diff --git a/woodpecker-go/woodpecker/mocks/client.go b/woodpecker-go/woodpecker/mocks/client.go index 0d23709dd..4e670aa92 100644 --- a/woodpecker-go/woodpecker/mocks/client.go +++ b/woodpecker-go/woodpecker/mocks/client.go @@ -1211,6 +1211,36 @@ func (_m *Client) PipelineList(repoID int64) ([]*woodpecker.Pipeline, error) { return r0, r1 } +// PipelineMetadata provides a mock function with given fields: repoID, pipelineNumber +func (_m *Client) PipelineMetadata(repoID int64, pipelineNumber int) ([]byte, error) { + ret := _m.Called(repoID, pipelineNumber) + + if len(ret) == 0 { + panic("no return value specified for PipelineMetadata") + } + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func(int64, int) ([]byte, error)); ok { + return rf(repoID, pipelineNumber) + } + if rf, ok := ret.Get(0).(func(int64, int) []byte); ok { + r0 = rf(repoID, pipelineNumber) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func(int64, int) error); ok { + r1 = rf(repoID, pipelineNumber) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // PipelineQueue provides a mock function with given fields: func (_m *Client) PipelineQueue() ([]*woodpecker.Feed, error) { ret := _m.Called() diff --git a/woodpecker-go/woodpecker/pipeline.go b/woodpecker-go/woodpecker/pipeline.go index 8ed4d6d20..4eec3ef01 100644 --- a/woodpecker-go/woodpecker/pipeline.go +++ b/woodpecker-go/woodpecker/pipeline.go @@ -1,8 +1,15 @@ package woodpecker -import "fmt" +import ( + "fmt" + "io" + "net/http" +) -const pathPipelineQueue = "%s/api/pipelines" +const ( + pathPipelineQueue = "%s/api/pipelines" + pathPipelineMetadata = "%s/api/repos/%d/pipelines/%d/metadata" +) // PipelineQueue returns a list of enqueued pipelines. func (c *client) PipelineQueue() ([]*Feed, error) { @@ -11,3 +18,16 @@ func (c *client) PipelineQueue() ([]*Feed, error) { err := c.get(uri, &out) return out, err } + +// PipelineMetadata returns metadata for a pipeline, workflow name is optional. +func (c *client) PipelineMetadata(repoID int64, pipelineNumber int) ([]byte, error) { + uri := fmt.Sprintf(pathPipelineMetadata, c.addr, repoID, pipelineNumber) + + body, err := c.open(uri, http.MethodGet, nil) + if err != nil { + return nil, err + } + defer body.Close() + + return io.ReadAll(body) +}