From ff3075b877da190e009d77ea53ecaf6c2a653755 Mon Sep 17 00:00:00 2001 From: Chmouel Boudjnah Date: Tue, 23 Jun 2026 09:54:34 +0200 Subject: [PATCH] feat: add --log-failed flag to pipelinerun logs command Add a --log-failed flag to `tkn pipelinerun logs` that filters output to only show logs from failed TaskRuns, similar to how GitHub Actions shows failed job logs. When --log-failed is set and the PipelineRun is still running, the completed-logs path is used so only TaskRuns that have already failed are shown. If no TaskRuns have failed, an informative error is returned. The flag composes with --task (intersection of both filters). Signed-off-by: Chmouel Boudjnah Signed-off-by: Chmouel Boudjnah --- docs/cmd/tkn_pipelinerun_logs.md | 5 + docs/man/man1/tkn-pipelinerun-logs.1 | 16 ++ pkg/cmd/pipelinerun/logs.go | 5 + pkg/cmd/pipelinerun/logs_test.go | 408 +++++++++++++++++++++++++++ pkg/log/pipeline_reader.go | 24 +- pkg/log/reader.go | 2 + pkg/options/logs.go | 1 + pkg/taskrun/taskrun.go | 18 ++ 8 files changed, 468 insertions(+), 11 deletions(-) diff --git a/docs/cmd/tkn_pipelinerun_logs.md b/docs/cmd/tkn_pipelinerun_logs.md index c9264e1899..3e3585d0f3 100644 --- a/docs/cmd/tkn_pipelinerun_logs.md +++ b/docs/cmd/tkn_pipelinerun_logs.md @@ -25,6 +25,10 @@ Show the logs of PipelineRun named 'microservice-1' for Task 'build' only from n Show the logs of PipelineRun named 'microservice-1' for all Tasks and steps (including init steps) from namespace 'foo': tkn pr logs microservice-1 -a -n foo + +Show the logs of PipelineRun named 'microservice-1' for failed Tasks only from namespace 'bar': + + tkn pr logs microservice-1 --log-failed -n bar ### Options @@ -37,6 +41,7 @@ Show the logs of PipelineRun named 'microservice-1' for all Tasks and steps (inc -h, --help help for logs -L, --last show logs for last PipelineRun --limit int lists number of PipelineRuns (default 5) + --log-failed show logs for failed tasks only --long show logs with task display name (display name and step name) --prefix prefix each log line with the log source (task name and step name) (default true) -t, --task strings show logs for mentioned Tasks only diff --git a/docs/man/man1/tkn-pipelinerun-logs.1 b/docs/man/man1/tkn-pipelinerun-logs.1 index b27e83f617..2e90afde2c 100644 --- a/docs/man/man1/tkn-pipelinerun-logs.1 +++ b/docs/man/man1/tkn-pipelinerun-logs.1 @@ -47,6 +47,10 @@ Show the logs of a PipelineRun \fB\-\-limit\fP=5 lists number of PipelineRuns +.PP +\fB\-\-log\-failed\fP[=false] + show logs for failed tasks only + .PP \fB\-\-long\fP[=false] show logs with task display name (display name and step name) @@ -119,6 +123,18 @@ tkn pr logs microservice\-1 \-a \-n foo .fi .RE +.PP +Show the logs of PipelineRun named 'microservice\-1' for failed Tasks only from namespace 'bar': + +.PP +.RS + +.nf +tkn pr logs microservice\-1 \-\-log\-failed \-n bar + +.fi +.RE + .SH SEE ALSO .PP diff --git a/pkg/cmd/pipelinerun/logs.go b/pkg/cmd/pipelinerun/logs.go index 1bdd69a3c5..119e6a4ebd 100644 --- a/pkg/cmd/pipelinerun/logs.go +++ b/pkg/cmd/pipelinerun/logs.go @@ -47,6 +47,10 @@ Show the logs of PipelineRun named 'microservice-1' for Task 'build' only from n Show the logs of PipelineRun named 'microservice-1' for all Tasks and steps (including init steps) from namespace 'foo': tkn pr logs microservice-1 -a -n foo + +Show the logs of PipelineRun named 'microservice-1' for failed Tasks only from namespace 'bar': + + tkn pr logs microservice-1 --log-failed -n bar ` c := &cobra.Command{ @@ -86,6 +90,7 @@ Show the logs of PipelineRun named 'microservice-1' for all Tasks and steps (inc c.Flags().BoolVarP(&opts.Prefixing, "prefix", "", true, "prefix each log line with the log source (task name and step name)") c.Flags().BoolVarP(&opts.Long, "long", "", false, "show logs with task display name (display name and step name)") c.Flags().BoolVarP(&opts.ExitWithPrError, "exit-with-pipelinerun-error", "E", false, "exit with pipelinerun to the unix shell, 0 if success, 1 if error, 2 on unknown status") + c.Flags().BoolVarP(&opts.Failed, "log-failed", "", false, "show logs for failed tasks only") c.Flags().StringSliceVarP(&opts.Tasks, "task", "t", []string{}, "show logs for mentioned Tasks only") c.Flags().IntVarP(&opts.Limit, "limit", "", defaultLimit, "lists number of PipelineRuns") return c diff --git a/pkg/cmd/pipelinerun/logs_test.go b/pkg/cmd/pipelinerun/logs_test.go index 1528fb3f7f..afefa854a2 100644 --- a/pkg/cmd/pipelinerun/logs_test.go +++ b/pkg/cmd/pipelinerun/logs_test.go @@ -1147,6 +1147,414 @@ func TestPipelinerunLogs(t *testing.T) { } } +func TestPipelinerunLogs_log_failed(t *testing.T) { + var ( + pipelineName = "output-pipeline" + prName = "output-pipeline-1" + prstart = test.FakeClock() + ns = "namespace" + + task1Name = "output-task" + tr1Name = "output-task-1" + tr1StartTime = prstart.Now().Add(20 * time.Second) + tr1Pod = "output-task-pod-123456" + tr1Step1Name = "writefile-step" + + task2Name = "read-task" + tr2Name = "read-task-1" + tr2StartTime = prstart.Now().Add(2 * time.Minute) + tr2Pod = "read-task-pod-123456" + tr2Step1Name = "readfile-step" + + nopStep = "nop" + ) + + nsList := []*corev1.Namespace{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: ns, + }, + }, + } + + trs := []*v1.TaskRun{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: tr1Name, + }, + Spec: v1.TaskRunSpec{ + TaskRef: &v1.TaskRef{ + Name: task1Name, + }, + }, + Status: v1.TaskRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + { + Status: corev1.ConditionTrue, + Type: apis.ConditionSucceeded, + }, + }, + }, + TaskRunStatusFields: v1.TaskRunStatusFields{ + StartTime: &metav1.Time{Time: tr1StartTime}, + PodName: tr1Pod, + Steps: []v1.StepState{ + { + Name: tr1Step1Name, + ContainerState: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 0, + }, + }, + }, + { + Name: nopStep, + ContainerState: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 0, + }, + }, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: tr2Name, + }, + Spec: v1.TaskRunSpec{ + TaskRef: &v1.TaskRef{ + Name: task2Name, + }, + }, + Status: v1.TaskRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + { + Status: corev1.ConditionFalse, + Type: apis.ConditionSucceeded, + Message: "task failed", + }, + }, + }, + TaskRunStatusFields: v1.TaskRunStatusFields{ + StartTime: &metav1.Time{Time: tr2StartTime}, + PodName: tr2Pod, + Steps: []v1.StepState{ + { + Name: tr2Step1Name, + ContainerState: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 1, + }, + }, + }, + { + Name: nopStep, + ContainerState: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 0, + }, + }, + }, + }, + }, + }, + }, + } + + prs := []*v1.PipelineRun{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: prName, + Namespace: ns, + Labels: map[string]string{"tekton.dev/pipeline": prName}, + }, + Spec: v1.PipelineRunSpec{ + PipelineRef: &v1.PipelineRef{ + Name: pipelineName, + }, + }, + Status: v1.PipelineRunStatus{ + PipelineRunStatusFields: v1.PipelineRunStatusFields{ + ChildReferences: []v1.ChildStatusReference{ + { + Name: tr1Name, + PipelineTaskName: task1Name, + TypeMeta: runtime.TypeMeta{ + APIVersion: "tekton.dev/v1", + Kind: "TaskRun", + }, + }, { + Name: tr2Name, + PipelineTaskName: task2Name, + TypeMeta: runtime.TypeMeta{ + APIVersion: "tekton.dev/v1", + Kind: "TaskRun", + }, + }, + }, + }, + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + { + Status: corev1.ConditionFalse, + Reason: v1.PipelineRunReasonFailed.String(), + Message: "Tasks Completed: 1 (Failed: 1, Cancelled 0), Skipped: 0", + }, + }, + }, + }, + }, + } + pps := []*v1.Pipeline{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: pipelineName, + Namespace: ns, + }, + Spec: v1.PipelineSpec{ + Tasks: []v1.PipelineTask{ + { + Name: task1Name, + TaskRef: &v1.TaskRef{ + Name: task1Name, + }, + }, + { + Name: task2Name, + TaskRef: &v1.TaskRef{ + Name: task2Name, + }, + }, + }, + }, + }, + } + + p := []*corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: tr1Pod, + Namespace: ns, + Labels: map[string]string{"tekton.dev/task": pipelineName}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: tr1Step1Name, + Image: tr1Step1Name + ":latest", + }, + { + Name: nopStep, + Image: "override-with-nop:latest", + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: tr2Pod, + Namespace: ns, + Labels: map[string]string{"tekton.dev/task": pipelineName}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: tr2Step1Name, + Image: tr2Step1Name + ":latest", + }, + { + Name: nopStep, + Image: "override-with-nop:latest", + }, + }, + }, + }, + } + + fakeLogs := fake.Logs( + fake.Task(tr1Pod, + fake.Step(tr1Step1Name, "written a file"), + fake.Step(nopStep, "Build successful"), + ), + fake.Task(tr2Pod, + fake.Step(tr2Step1Name, "unable to read a file"), + fake.Step(nopStep, "Build failed"), + ), + ) + + t.Run("shows only failed task logs", func(t *testing.T) { + cs, _ := test.SeedTestData(t, pipelinetest.Data{PipelineRuns: prs, Pipelines: pps, TaskRuns: trs, Pods: p, Namespaces: nsList}) + cs.Pipeline.Resources = cb.APIResourceList(version, []string{"task", "taskrun", "pipeline", "pipelinerun"}) + tdc := testDynamic.Options{} + dc, err := tdc.Client( + cb.UnstructuredP(pps[0], version), + cb.UnstructuredPR(prs[0], version), + cb.UnstructuredTR(trs[0], version), + cb.UnstructuredTR(trs[1], version), + ) + if err != nil { + t.Errorf("unable to create dynamic client: %v", err) + } + prlo := logOpts(prName, ns, cs, dc, fake.Streamer(fakeLogs), false, false, true) + prlo.Failed = true + output, _ := fetchLogs(prlo) + + expected := "task read-task has failed: task failed\n" + + "[read-task : readfile-step] unable to read a file\n\n" + + "[read-task : nop] Build failed\n\n" + + "Tasks Completed: 1 (Failed: 1, Cancelled 0), Skipped: 0\n" + + test.AssertOutput(t, expected, output) + }) + + t.Run("no failed tasks returns error", func(t *testing.T) { + allSucceedTrs := []*v1.TaskRun{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: tr1Name, + }, + Spec: v1.TaskRunSpec{ + TaskRef: &v1.TaskRef{ + Name: task1Name, + }, + }, + Status: v1.TaskRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + { + Status: corev1.ConditionTrue, + Type: apis.ConditionSucceeded, + }, + }, + }, + TaskRunStatusFields: v1.TaskRunStatusFields{ + StartTime: &metav1.Time{Time: tr1StartTime}, + PodName: tr1Pod, + Steps: []v1.StepState{ + { + Name: tr1Step1Name, + ContainerState: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 0, + }, + }, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: ns, + Name: tr2Name, + }, + Spec: v1.TaskRunSpec{ + TaskRef: &v1.TaskRef{ + Name: task2Name, + }, + }, + Status: v1.TaskRunStatus{ + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + { + Status: corev1.ConditionTrue, + Type: apis.ConditionSucceeded, + }, + }, + }, + TaskRunStatusFields: v1.TaskRunStatusFields{ + StartTime: &metav1.Time{Time: tr2StartTime}, + PodName: tr2Pod, + Steps: []v1.StepState{ + { + Name: tr2Step1Name, + ContainerState: corev1.ContainerState{ + Terminated: &corev1.ContainerStateTerminated{ + ExitCode: 0, + }, + }, + }, + }, + }, + }, + }, + } + + allSucceedPrs := []*v1.PipelineRun{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: prName, + Namespace: ns, + Labels: map[string]string{"tekton.dev/pipeline": prName}, + }, + Spec: v1.PipelineRunSpec{ + PipelineRef: &v1.PipelineRef{ + Name: pipelineName, + }, + }, + Status: v1.PipelineRunStatus{ + PipelineRunStatusFields: v1.PipelineRunStatusFields{ + ChildReferences: []v1.ChildStatusReference{ + { + Name: tr1Name, + PipelineTaskName: task1Name, + TypeMeta: runtime.TypeMeta{ + APIVersion: "tekton.dev/v1", + Kind: "TaskRun", + }, + }, { + Name: tr2Name, + PipelineTaskName: task2Name, + TypeMeta: runtime.TypeMeta{ + APIVersion: "tekton.dev/v1", + Kind: "TaskRun", + }, + }, + }, + }, + Status: duckv1.Status{ + Conditions: duckv1.Conditions{ + { + Status: corev1.ConditionTrue, + Reason: v1.PipelineRunReasonSuccessful.String(), + }, + }, + }, + }, + }, + } + + cs, _ := test.SeedTestData(t, pipelinetest.Data{PipelineRuns: allSucceedPrs, Pipelines: pps, TaskRuns: allSucceedTrs, Pods: p, Namespaces: nsList}) + cs.Pipeline.Resources = cb.APIResourceList(version, []string{"task", "taskrun", "pipeline", "pipelinerun"}) + tdc := testDynamic.Options{} + dc, err := tdc.Client( + cb.UnstructuredP(pps[0], version), + cb.UnstructuredPR(allSucceedPrs[0], version), + cb.UnstructuredTR(allSucceedTrs[0], version), + cb.UnstructuredTR(allSucceedTrs[1], version), + ) + if err != nil { + t.Errorf("unable to create dynamic client: %v", err) + } + prlo := logOpts(prName, ns, cs, dc, fake.Streamer(fakeLogs), false, false, true) + prlo.Failed = true + _, err = fetchLogs(prlo) + + if err == nil { + t.Errorf("expected error when no failed tasks found") + } + expected := "no failed TaskRuns found in PipelineRun " + prName + test.AssertOutput(t, expected, err.Error()) + }) +} + func updatePRv1beta1(finalRuns []*v1beta1.PipelineRun, watcher *watch.FakeWatcher) { go func() { for _, pr := range finalRuns { diff --git a/pkg/log/pipeline_reader.go b/pkg/log/pipeline_reader.go index 19cb59312b..400e0ce03c 100644 --- a/pkg/log/pipeline_reader.go +++ b/pkg/log/pipeline_reader.go @@ -37,7 +37,7 @@ func (r *Reader) readPipelineLog() (<-chan Log, <-chan error, error) { return nil, nil, err } - if !pr.IsDone() && r.follow { + if !pr.IsDone() && r.follow && !r.failed { return r.readLivePipelineLogs(pr) } return r.readAvailablePipelineLogs(pr) @@ -89,7 +89,7 @@ func (r *Reader) readAvailablePipelineLogs(pr *v1.PipelineRun) (<-chan Log, <-ch return nil, nil, err } - ordered, err := r.getOrderedTasks(pr) + ordered, trsMap, err := r.getOrderedTasks(pr) if err != nil { return nil, nil, err } @@ -103,6 +103,11 @@ func (r *Reader) readAvailablePipelineLogs(pr *v1.PipelineRun) (<-chan Log, <-ch return nil, nil, fmt.Errorf("passed filtered tasks: %v is not available, available tasks are: %v", r.tasks, availTasks) } + taskRuns = taskrunpkg.FilterByStatus(taskRuns, trsMap, r.failed) + if len(taskRuns) == 0 && r.failed { + return nil, nil, fmt.Errorf("no failed TaskRuns found in PipelineRun %s", r.run) + } + logC := make(chan Log) errC := make(chan error) @@ -212,9 +217,7 @@ func (r *Reader) setUpTask(taskNumber int, tr taskrunpkg.Run) { r.setRetries(tr.Retries) } -// getOrderedTasks get Tasks in order from Spec.PipelineRef or Spec.PipelineSpec -// and return trh.Run after converted taskruns into trh.Run. -func (r *Reader) getOrderedTasks(pr *v1.PipelineRun) ([]taskrunpkg.Run, error) { +func (r *Reader) getOrderedTasks(pr *v1.PipelineRun) ([]taskrunpkg.Run, map[string]*v1.PipelineRunTaskRunStatus, error) { var tasks []v1.PipelineTask switch { case pr.Spec.PipelineRef != nil: @@ -222,12 +225,12 @@ func (r *Reader) getOrderedTasks(pr *v1.PipelineRun) ([]taskrunpkg.Run, error) { if pr.Status.PipelineSpec != nil { tasks = append(tasks, pr.Status.PipelineSpec.Tasks...) } else { - return nil, fmt.Errorf("pipelinerun %s does not have the PipelineRunSpec", pr.Name) + return nil, nil, fmt.Errorf("pipelinerun %s does not have the PipelineRunSpec", pr.Name) } } else { pl, err := pipelinepkg.GetPipeline(pipelineGroupResource, r.clients, pr.Spec.PipelineRef.Name, r.ns) if err != nil { - return nil, err + return nil, nil, err } tasks = pl.Spec.Tasks tasks = append(tasks, pl.Spec.Finally...) @@ -236,16 +239,15 @@ func (r *Reader) getOrderedTasks(pr *v1.PipelineRun) ([]taskrunpkg.Run, error) { tasks = pr.Spec.PipelineSpec.Tasks tasks = append(tasks, pr.Spec.PipelineSpec.Finally...) default: - return nil, fmt.Errorf("pipelinerun %s did not provide PipelineRef or PipelineSpec", pr.Name) + return nil, nil, fmt.Errorf("pipelinerun %s did not provide PipelineRef or PipelineSpec", pr.Name) } trsMap, err := pipelinerunpkg.GetTaskRunsWithStatus(pr, r.clients, r.ns) if err != nil { - return nil, err + return nil, nil, err } - // Sort taskruns, to display the taskrun logs as per pipeline tasks order - return taskrunpkg.SortTasksBySpecOrder(tasks, trsMap), nil + return taskrunpkg.SortTasksBySpecOrder(tasks, trsMap), trsMap, nil } func empty(status v1.PipelineRunStatus) bool { diff --git a/pkg/log/reader.go b/pkg/log/reader.go index 2ebe019e64..4b5872e75a 100644 --- a/pkg/log/reader.go +++ b/pkg/log/reader.go @@ -35,6 +35,7 @@ type Reader struct { timestamps bool tasks []string steps []string + failed bool logType string task string displayName string @@ -76,6 +77,7 @@ func NewReader(logType string, opts *options.LogOptions) (*Reader, error) { follow: opts.Follow, timestamps: opts.Timestamps, allSteps: opts.AllSteps, + failed: opts.Failed, tasks: opts.Tasks, steps: opts.Steps, logType: logType, diff --git a/pkg/options/logs.go b/pkg/options/logs.go index 241521fd05..e09d0e8373 100644 --- a/pkg/options/logs.go +++ b/pkg/options/logs.go @@ -52,6 +52,7 @@ type LogOptions struct { Prefixing bool Long bool ExitWithPrError bool + Failed bool // ActivityTimeout is the amount of time to wait for some activity // (e.g. Pod ready) before giving up. ActivityTimeout time.Duration diff --git a/pkg/taskrun/taskrun.go b/pkg/taskrun/taskrun.go index 64d314c5cf..545cc7511b 100644 --- a/pkg/taskrun/taskrun.go +++ b/pkg/taskrun/taskrun.go @@ -18,6 +18,7 @@ import ( "sort" v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -79,6 +80,23 @@ func Filter(trs []Run, ts []string) []Run { return filtered } +func FilterByStatus(trs []Run, trsMap map[string]*v1.PipelineRunTaskRunStatus, failed bool) []Run { + if !failed { + return trs + } + filtered := []Run{} + for _, tr := range trs { + if status, ok := trsMap[tr.Name]; ok { + if status.Status != nil && + len(status.Status.Conditions) > 0 && + status.Status.Conditions[0].Status == corev1.ConditionFalse { + filtered = append(filtered, tr) + } + } + } + return filtered +} + func SortTasksBySpecOrder(pipelineTasks []v1.PipelineTask, pipelinesTaskRuns map[string]*v1.PipelineRunTaskRunStatus) []Run { trNames := map[string]string{}