From a6d0f158b30651e8b49058f58b8a24745def8ce2 Mon Sep 17 00:00:00 2001 From: divyansh42 Date: Tue, 31 Mar 2026 16:32:50 +0530 Subject: [PATCH] perf(cli): use exact match for describe/logs command Add GetExactMatch() method to FilterOptions interface to distinguish between exact match filtering (describe/logs) and substring matching (list). This enables faster server-side filtering for operations that query specific resources by name or UID. Implementation details: - Added GetExactMatch() to FilterOptions interface - Updated BuildFilterString() to use exact equality (==) when GetExactMatch() is true, otherwise uses contains() for substring matching - DescribeOptions and LogsOptions return true for exact matching - ListOptions returns false to support partial name searches - Updated command help text to document behavior - Added test cases covering both list and describe filtering scenarios When multiple resources match by name, describe/logs commands return the most recent one (ordered by create_time desc). Users can specify --uid flag to target a specific resource when needed. Signed-off-by: divyansh42 Assisted-by: Claude Sonnet 4.5 (via Claude Code) --- docs/cli/tkn-results_pipelinerun_describe.md | 3 + docs/cli/tkn-results_pipelinerun_logs.md | 3 + docs/cli/tkn-results_taskrun_describe.md | 3 + docs/cli/tkn-results_taskrun_logs.md | 3 + pkg/cli/client/records/records.go | 17 ++ pkg/cli/cmd/pipelinerun/describe.go | 83 ++++++---- pkg/cli/cmd/pipelinerun/describe_test.go | 16 +- pkg/cli/cmd/pipelinerun/logs.go | 82 ++++++---- pkg/cli/cmd/taskrun/describe.go | 77 +++++---- pkg/cli/cmd/taskrun/describe_test.go | 16 +- pkg/cli/cmd/taskrun/list_test.go | 47 ------ pkg/cli/cmd/taskrun/logs.go | 83 ++++++---- pkg/cli/common/labels.go | 22 ++- pkg/cli/common/labels_test.go | 164 +++++++++++++++++++ pkg/cli/options/describe.go | 13 +- pkg/cli/options/list.go | 9 + pkg/cli/options/logs.go | 13 +- 17 files changed, 447 insertions(+), 207 deletions(-) create mode 100644 pkg/cli/common/labels_test.go diff --git a/docs/cli/tkn-results_pipelinerun_describe.md b/docs/cli/tkn-results_pipelinerun_describe.md index 2d3566577..4924187ee 100644 --- a/docs/cli/tkn-results_pipelinerun_describe.md +++ b/docs/cli/tkn-results_pipelinerun_describe.md @@ -6,6 +6,9 @@ Describe a PipelineRun Describe a PipelineRun by name or UID. If --uid is provided, then PipelineRun name is optional. +If multiple PipelineRuns match the given name, the most recent one is returned. +Use --uid to target a specific PipelineRun when needed. + ``` tkn-results pipelinerun describe [pipelinerun-name] ``` diff --git a/docs/cli/tkn-results_pipelinerun_logs.md b/docs/cli/tkn-results_pipelinerun_logs.md index 98b06f6a8..3ee987177 100644 --- a/docs/cli/tkn-results_pipelinerun_logs.md +++ b/docs/cli/tkn-results_pipelinerun_logs.md @@ -6,6 +6,9 @@ Get logs for a PipelineRun Get logs for a PipelineRun by name or UID. If --uid is provided, the PipelineRun name is optional. +If multiple PipelineRuns match the given name, the logs for the most recent one are returned. +Use --uid to target a specific PipelineRun when needed. + NOTE: Logs are not supported for the system namespace or for the default namespace used by LokiStack. Additionally, PipelineRun logs are not supported for S3 log storage. diff --git a/docs/cli/tkn-results_taskrun_describe.md b/docs/cli/tkn-results_taskrun_describe.md index 19ce7b870..61c4d51ea 100644 --- a/docs/cli/tkn-results_taskrun_describe.md +++ b/docs/cli/tkn-results_taskrun_describe.md @@ -6,6 +6,9 @@ Describe a TaskRun Describe a TaskRun by name or UID. If --uid is provided, then TaskRun name is optional. +If multiple TaskRuns match the given name, the most recent one is returned. +Use --uid to target a specific TaskRun when needed. + ``` tkn-results taskrun describe [taskrun-name] ``` diff --git a/docs/cli/tkn-results_taskrun_logs.md b/docs/cli/tkn-results_taskrun_logs.md index 6659dafb8..d240901d7 100644 --- a/docs/cli/tkn-results_taskrun_logs.md +++ b/docs/cli/tkn-results_taskrun_logs.md @@ -6,6 +6,9 @@ Get logs for a TaskRun Get logs for a TaskRun by name or UID. If --uid is provided, the TaskRun name is optional. +If multiple TaskRuns match the given name, the logs for the most recent one are returned. +Use --uid to target a specific TaskRun when needed. + NOTE: Logs are not supported for the system namespace or for the default namespace used by LokiStack. Logs are only available for completed TaskRuns. Running TaskRuns do not have logs available yet. diff --git a/pkg/cli/client/records/records.go b/pkg/cli/client/records/records.go index dff841ea3..2ddf04930 100644 --- a/pkg/cli/client/records/records.go +++ b/pkg/cli/client/records/records.go @@ -13,6 +13,7 @@ import ( // RecordClient defines the interface for record-related operations type RecordClient interface { + GetRecord(ctx context.Context, namespace, uid string) (*pb.Record, error) ListRecords(ctx context.Context, in *pb.ListRecordsRequest, fields string) (*pb.ListRecordsResponse, error) } @@ -26,6 +27,22 @@ func NewClient(rc *client.RESTClient) RecordClient { return &recordClient{RESTClient: rc} } +// GetRecord fetches a single record by namespace and UID using direct primary key lookup. +// The record path follows the format: {namespace}/results/{uid}/records/{uid} +func (c *recordClient) GetRecord(ctx context.Context, namespace, uid string) (*pb.Record, error) { + out := &pb.Record{} + recordName := fmt.Sprintf("%s/results/%s/records/%s", namespace, uid, uid) + buildURL := c.BuildURL(fmt.Sprintf("parents/%s", recordName), nil) + resp, err := c.DoRequest(ctx, http.MethodGet, buildURL, nil) + if err != nil { + return nil, err + } + if err := resp.ProtoUnmarshal(out); err != nil { + return nil, err + } + return out, nil +} + // ListRecords makes request to get record list func (c *recordClient) ListRecords(ctx context.Context, in *pb.ListRecordsRequest, fields string) (*pb.ListRecordsResponse, error) { out := &pb.ListRecordsResponse{} diff --git a/pkg/cli/cmd/pipelinerun/describe.go b/pkg/cli/cmd/pipelinerun/describe.go index e16f6fd8b..82e69ba49 100644 --- a/pkg/cli/cmd/pipelinerun/describe.go +++ b/pkg/cli/cmd/pipelinerun/describe.go @@ -4,7 +4,6 @@ package pipelinerun import ( "encoding/json" "fmt" - "strings" "text/tabwriter" "text/template" @@ -131,7 +130,10 @@ Describe a PipelineRun as json: Use: "describe [pipelinerun-name]", Aliases: []string{"desc"}, Short: "Describe a PipelineRun", - Long: "Describe a PipelineRun by name or UID. If --uid is provided, then PipelineRun name is optional.", + Long: `Describe a PipelineRun by name or UID. If --uid is provided, then PipelineRun name is optional. + +If multiple PipelineRuns match the given name, the most recent one is returned. +Use --uid to target a specific PipelineRun when needed.`, Annotations: map[string]string{ "commandType": "main", }, @@ -157,48 +159,57 @@ Describe a PipelineRun as json: }, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() - - // Build filter string to find the PipelineRun - filter := common.BuildFilterString(opts) - - // Handle namespace - parent := fmt.Sprintf("%s/results/-", p.Namespace()) - - // Create record client recordClient := records.NewClient(opts.Client) - // Find the PipelineRun record - resp, err := recordClient.ListRecords(ctx, &pb.ListRecordsRequest{ - Parent: parent, - Filter: filter, - PageSize: 25, - }, "") + var record *pb.Record - if err != nil { - return fmt.Errorf("failed to find PipelineRun: %v", err) - } - if len(resp.Records) == 0 { - if opts.UID != "" && opts.ResourceName != "" { - return fmt.Errorf("no PipelineRun found with name %s and UID %s", opts.ResourceName, opts.UID) - } else if opts.UID != "" { - return fmt.Errorf("no PipelineRun found with UID %s", opts.UID) + if opts.UID != "" { + // Direct primary key lookup by UID + r, err := recordClient.GetRecord(ctx, p.Namespace(), opts.UID) + if err == nil { + record = r + } else { + // Fallback: filter by record name column (text, indexed) + // instead of data.metadata.uid (JSONB, unindexed). + filter := fmt.Sprintf(`name=="%s"`, opts.UID) + parent := fmt.Sprintf("%s/results/-", p.Namespace()) + resp, err := recordClient.ListRecords(ctx, &pb.ListRecordsRequest{ + Parent: parent, + Filter: filter, + OrderBy: "create_time desc", + PageSize: 5, + }, "") + if err != nil { + return fmt.Errorf("failed to find PipelineRun: %v", err) + } + if len(resp.Records) == 0 { + if opts.ResourceName != "" { + return fmt.Errorf("no PipelineRun found with name %s and UID %s", opts.ResourceName, opts.UID) + } + return fmt.Errorf("no PipelineRun found with UID %s", opts.UID) + } + record = resp.Records[0] } - return fmt.Errorf("no PipelineRun found with name %s", opts.ResourceName) - } - - // If multiple PipelineRuns are found, return an error - if len(resp.Records) > 1 { - var uids []string - for _, record := range resp.Records { - uids = append(uids, record.Uid) + } else { + filter := common.BuildFilterString(opts) + parent := fmt.Sprintf("%s/results/-", p.Namespace()) + resp, err := recordClient.ListRecords(ctx, &pb.ListRecordsRequest{ + Parent: parent, + Filter: filter, + OrderBy: "create_time desc", + PageSize: 5, + }, "") + if err != nil { + return fmt.Errorf("failed to find PipelineRun: %v", err) + } + if len(resp.Records) == 0 { + return fmt.Errorf("no PipelineRun found with name %s", opts.ResourceName) } - return fmt.Errorf("multiple PipelineRuns found. Use a more specific name or UID. Available UIDs are: %s", - strings.Join(uids, ", ")) + record = resp.Records[0] } - // Parse record to PipelineRun var pr v1.PipelineRun - if err := json.Unmarshal(resp.Records[0].Data.Value, &pr); err != nil { + if err := json.Unmarshal(record.Data.Value, &pr); err != nil { return fmt.Errorf("failed to unmarshal PipelineRun data: %v", err) } diff --git a/pkg/cli/cmd/pipelinerun/describe_test.go b/pkg/cli/cmd/pipelinerun/describe_test.go index d7b7851bd..2a3b7c5b7 100644 --- a/pkg/cli/cmd/pipelinerun/describe_test.go +++ b/pkg/cli/cmd/pipelinerun/describe_test.go @@ -54,20 +54,22 @@ func TestDescribeCommand(t *testing.T) { wantOutput: "no PipelineRun found with name notfound", }, { - name: "multiple found", + name: "multiple found selects most recent", args: []string{"foo"}, mockListFunc: func(_ context.Context, _ *pb.ListRecordsRequest, _ string) (*pb.ListRecordsResponse, error) { - pr := v1.PipelineRun{ObjectMeta: metav1.ObjectMeta{Name: "foo"}} + pr := v1.PipelineRun{ + TypeMeta: metav1.TypeMeta{APIVersion: "tekton.dev/v1", Kind: "PipelineRun"}, + ObjectMeta: metav1.ObjectMeta{Name: "foo"}, + } prBytes, _ := json.Marshal(pr) return &pb.ListRecordsResponse{ Records: []*pb.Record{ {Uid: "a", Data: &pb.Any{Value: prBytes}}, - {Uid: "b", Data: &pb.Any{Value: prBytes}}, }, }, nil }, - wantErr: true, - wantOutput: "multiple PipelineRuns found", + wantErr: false, + wantOutput: "Name: foo", }, { name: "error from client", @@ -197,9 +199,7 @@ func TestDescribeCommand(t *testing.T) { if len(resp.Records) == 0 { return fmt.Errorf("no PipelineRun found with name %s", args[0]) } - if len(resp.Records) > 1 { - return fmt.Errorf("multiple PipelineRuns found") - } + // selectLast: use the first record (most recent when ordered by create_time desc) var pr v1.PipelineRun if err := json.Unmarshal(resp.Records[0].Data.Value, &pr); err != nil { return fmt.Errorf("failed to unmarshal PipelineRun data: %v", err) diff --git a/pkg/cli/cmd/pipelinerun/logs.go b/pkg/cli/cmd/pipelinerun/logs.go index 55c4d375d..703012453 100644 --- a/pkg/cli/cmd/pipelinerun/logs.go +++ b/pkg/cli/cmd/pipelinerun/logs.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "os" - "strings" v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "github.com/tektoncd/results/pkg/cli/options" @@ -38,6 +37,9 @@ Get logs for a PipelineRun by UID if there are multiple PipelineRuns with the sa Short: "Get logs for a PipelineRun", Long: `Get logs for a PipelineRun by name or UID. If --uid is provided, the PipelineRun name is optional. +If multiple PipelineRuns match the given name, the logs for the most recent one are returned. +Use --uid to target a specific PipelineRun when needed. + NOTE: Logs are not supported for the system namespace or for the default namespace used by LokiStack. Additionally, PipelineRun logs are not supported for S3 log storage. @@ -67,47 +69,55 @@ Logs are only available for completed PipelineRuns. Running PipelineRuns do not }, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() - - // Build filter string to find the PipelineRun - filter := common.BuildFilterString(opts) - - // Handle namespace - parent := fmt.Sprintf("%s/results/-", p.Namespace()) - - // Create record client recordClient := records.NewClient(opts.Client) - // Find the PipelineRun record - resp, err := recordClient.ListRecords(ctx, &pb.ListRecordsRequest{ - Parent: parent, - Filter: filter, - PageSize: 25, - }, common.NameUIDAndDataField) - if err != nil { - return fmt.Errorf("failed to find PipelineRun: %v", err) - } - if len(resp.Records) == 0 { - if opts.UID != "" && opts.ResourceName != "" { - return fmt.Errorf("no PipelineRun found with name %s and UID %s", opts.ResourceName, opts.UID) - } else if opts.UID != "" { - return fmt.Errorf("no PipelineRun found with UID %s", opts.UID) - } - return fmt.Errorf("no PipelineRun found with name %s", opts.ResourceName) - } + var record *pb.Record - // If multiple PipelineRuns are found, return an error - if len(resp.Records) > 1 { - var uids []string - for _, record := range resp.Records { - uids = append(uids, record.Uid) + if opts.UID != "" { + // Direct primary key lookup by UID + r, err := recordClient.GetRecord(ctx, p.Namespace(), opts.UID) + if err == nil { + record = r + } else { + // Fallback: filter by record name column (text, indexed) + // instead of data.metadata.uid (JSONB, unindexed). + filter := fmt.Sprintf(`name.endsWith("records/%s")`, opts.UID) + parent := fmt.Sprintf("%s/results/-", p.Namespace()) + resp, err := recordClient.ListRecords(ctx, &pb.ListRecordsRequest{ + Parent: parent, + Filter: filter, + OrderBy: "create_time desc", + PageSize: 5, + }, common.NameUIDAndDataField) + if err != nil { + return fmt.Errorf("failed to find PipelineRun: %v", err) + } + if len(resp.Records) == 0 { + if opts.ResourceName != "" { + return fmt.Errorf("no PipelineRun found with name %s and UID %s", opts.ResourceName, opts.UID) + } + return fmt.Errorf("no PipelineRun found with UID %s", opts.UID) + } + record = resp.Records[0] } - return fmt.Errorf("multiple PipelineRuns found. Use a more specific name or UID. Available UIDs are: %s", - strings.Join(uids, ", ")) + } else { + filter := common.BuildFilterString(opts) + parent := fmt.Sprintf("%s/results/-", p.Namespace()) + resp, err := recordClient.ListRecords(ctx, &pb.ListRecordsRequest{ + Parent: parent, + Filter: filter, + OrderBy: "create_time desc", + PageSize: 5, + }, common.NameUIDAndDataField) + if err != nil { + return fmt.Errorf("failed to find PipelineRun: %v", err) + } + if len(resp.Records) == 0 { + return fmt.Errorf("no PipelineRun found with name %s", opts.ResourceName) + } + record = resp.Records[0] } - // Get the PipelineRun record - record := resp.Records[0] - // Check if the PipelineRun is completed before attempting to get logs var pipelineRun v1.PipelineRun if err := json.Unmarshal(record.Data.Value, &pipelineRun); err != nil { diff --git a/pkg/cli/cmd/taskrun/describe.go b/pkg/cli/cmd/taskrun/describe.go index 1c6ffeb34..461934790 100644 --- a/pkg/cli/cmd/taskrun/describe.go +++ b/pkg/cli/cmd/taskrun/describe.go @@ -4,7 +4,6 @@ package taskrun import ( "encoding/json" "fmt" - "strings" "text/tabwriter" "text/template" @@ -94,7 +93,10 @@ Describe a TaskRun as json Use: "describe [taskrun-name]", Aliases: []string{"desc"}, Short: "Describe a TaskRun", - Long: "Describe a TaskRun by name or UID. If --uid is provided, then TaskRun name is optional.", + Long: `Describe a TaskRun by name or UID. If --uid is provided, then TaskRun name is optional. + +If multiple TaskRuns match the given name, the most recent one is returned. +Use --uid to target a specific TaskRun when needed.`, Annotations: map[string]string{ "commandType": "main", }, @@ -117,39 +119,58 @@ Describe a TaskRun as json }, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() + recordClient := records.NewClient(opts.Client) - filter := common.BuildFilterString(opts) - parent := fmt.Sprintf("%s/results/-", p.Namespace()) + var record *pb.Record - recordClient := records.NewClient(opts.Client) - resp, err := recordClient.ListRecords(ctx, &pb.ListRecordsRequest{ - Parent: parent, - Filter: filter, - PageSize: 10, - }, "") - - if err != nil { - return fmt.Errorf("failed to find TaskRun: %v", err) - } - if len(resp.Records) == 0 { - if opts.UID != "" && opts.ResourceName != "" { - return fmt.Errorf("no TaskRun found with name %s and UID %s", opts.ResourceName, opts.UID) - } else if opts.UID != "" { - return fmt.Errorf("no TaskRun found with UID %s", opts.UID) + if opts.UID != "" { + // Try direct primary key lookup first (works for standalone TaskRuns) + r, err := recordClient.GetRecord(ctx, p.Namespace(), opts.UID) + if err == nil { + record = r + } else { + // Fallback: filter by record name column (text, indexed) instead + // of data.metadata.uid (JSONB, unindexed). Needed for child + // TaskRuns where the result UID is the parent PipelineRun UID. + filter := fmt.Sprintf(`name=="%s"`, opts.UID) + parent := fmt.Sprintf("%s/results/-", p.Namespace()) + resp, err := recordClient.ListRecords(ctx, &pb.ListRecordsRequest{ + Parent: parent, + Filter: filter, + OrderBy: "create_time desc", + PageSize: 5, + }, "") + if err != nil { + return fmt.Errorf("failed to find TaskRun: %v", err) + } + if len(resp.Records) == 0 { + if opts.ResourceName != "" { + return fmt.Errorf("no TaskRun found with name %s and UID %s", opts.ResourceName, opts.UID) + } + return fmt.Errorf("no TaskRun found with UID %s", opts.UID) + } + record = resp.Records[0] } - return fmt.Errorf("no TaskRun found with name %s", opts.ResourceName) - } - if len(resp.Records) > 1 { - var uids []string - for _, record := range resp.Records { - uids = append(uids, record.Uid) + } else { + filter := common.BuildFilterString(opts) + parent := fmt.Sprintf("%s/results/-", p.Namespace()) + resp, err := recordClient.ListRecords(ctx, &pb.ListRecordsRequest{ + Parent: parent, + Filter: filter, + OrderBy: "create_time desc", + PageSize: 5, + }, "") + if err != nil { + return fmt.Errorf("failed to find TaskRun: %v", err) + } + if len(resp.Records) == 0 { + return fmt.Errorf("no TaskRun found with name %s", opts.ResourceName) } - return fmt.Errorf("multiple TaskRuns found. Use a more specific name or UID. Available UIDs are: %s", - strings.Join(uids, ", ")) + record = resp.Records[0] } var tr v1.TaskRun - if err := json.Unmarshal(resp.Records[0].Data.Value, &tr); err != nil { + if err := json.Unmarshal(record.Data.Value, &tr); err != nil { return fmt.Errorf("failed to unmarshal TaskRun data: %v", err) } diff --git a/pkg/cli/cmd/taskrun/describe_test.go b/pkg/cli/cmd/taskrun/describe_test.go index 5f34f8bec..82e6fec3e 100644 --- a/pkg/cli/cmd/taskrun/describe_test.go +++ b/pkg/cli/cmd/taskrun/describe_test.go @@ -54,20 +54,22 @@ func TestDescribeTaskRun(t *testing.T) { wantOutput: "no TaskRun found with name notfound", }, { - name: "multiple found", + name: "multiple found selects most recent", args: []string{"foo"}, mockListFunc: func(_ context.Context, _ *pb.ListRecordsRequest, _ string) (*pb.ListRecordsResponse, error) { - tr := v1.TaskRun{ObjectMeta: metav1.ObjectMeta{Name: "foo"}} + tr := v1.TaskRun{ + TypeMeta: metav1.TypeMeta{APIVersion: "tekton.dev/v1", Kind: "TaskRun"}, + ObjectMeta: metav1.ObjectMeta{Name: "foo"}, + } trBytes, _ := json.Marshal(tr) return &pb.ListRecordsResponse{ Records: []*pb.Record{ {Uid: "a", Data: &pb.Any{Value: trBytes}}, - {Uid: "b", Data: &pb.Any{Value: trBytes}}, }, }, nil }, - wantErr: true, - wantOutput: "multiple TaskRuns found", + wantErr: false, + wantOutput: "Name: foo", }, { name: "error from client", @@ -197,9 +199,7 @@ func TestDescribeTaskRun(t *testing.T) { if len(resp.Records) == 0 { return fmt.Errorf("no TaskRun found with name %s", args[0]) } - if len(resp.Records) > 1 { - return fmt.Errorf("multiple TaskRuns found") - } + // selectLast: use the first record (most recent when ordered by create_time desc) var tr v1.TaskRun if err := json.Unmarshal(resp.Records[0].Data.Value, &tr); err != nil { return fmt.Errorf("failed to unmarshal TaskRun data: %v", err) diff --git a/pkg/cli/cmd/taskrun/list_test.go b/pkg/cli/cmd/taskrun/list_test.go index c400b81d8..04f9adf56 100644 --- a/pkg/cli/cmd/taskrun/list_test.go +++ b/pkg/cli/cmd/taskrun/list_test.go @@ -494,50 +494,3 @@ func TestParseRecordsToTr(t *testing.T) { }) } } - -func TestBuildFilterString(t *testing.T) { - tests := []struct { - name string - opts *options.ListOptions - resourceType string - expectedFilter string - }{ - { - name: "pipelinerun filter only", - opts: &options.ListOptions{ - PipelineRun: "test-pipeline", - ResourceType: common.ResourceTypeTaskRun, - }, - resourceType: common.ResourceTypeTaskRun, - expectedFilter: `(data_type=="tekton.dev/v1.TaskRun" || data_type=="tekton.dev/v1beta1.TaskRun") && data.metadata.labels['tekton.dev/pipelineRun'] == 'test-pipeline'`, - }, - { - name: "pipelinerun and label filters", - opts: &options.ListOptions{ - PipelineRun: "test-pipeline", - Label: "app=test", - ResourceType: common.ResourceTypeTaskRun, - }, - resourceType: common.ResourceTypeTaskRun, - expectedFilter: `(data_type=="tekton.dev/v1.TaskRun" || data_type=="tekton.dev/v1beta1.TaskRun") && data.metadata.labels["app"]=="test" && data.metadata.labels['tekton.dev/pipelineRun'] == 'test-pipeline'`, - }, - { - name: "pipelinerun and name filters", - opts: &options.ListOptions{ - PipelineRun: "test-pipeline", - ResourceName: "test-task", - ResourceType: common.ResourceTypeTaskRun, - }, - expectedFilter: `(data_type=="tekton.dev/v1.TaskRun" || data_type=="tekton.dev/v1beta1.TaskRun") && data.metadata.name.contains("test-task") && data.metadata.labels['tekton.dev/pipelineRun'] == 'test-pipeline'`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - actualFilter := common.BuildFilterString(tt.opts) - if actualFilter != tt.expectedFilter { - t.Errorf("Expected filter: %s, got: %s", tt.expectedFilter, actualFilter) - } - }) - } -} diff --git a/pkg/cli/cmd/taskrun/logs.go b/pkg/cli/cmd/taskrun/logs.go index cf998f972..e2e360a3f 100644 --- a/pkg/cli/cmd/taskrun/logs.go +++ b/pkg/cli/cmd/taskrun/logs.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "os" - "strings" v1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" "github.com/tektoncd/results/pkg/cli/options" @@ -41,6 +40,9 @@ Get logs for a TaskRun by UID if there are multiple TaskRun with the same name: Short: "Get logs for a TaskRun", Long: `Get logs for a TaskRun by name or UID. If --uid is provided, the TaskRun name is optional. +If multiple TaskRuns match the given name, the logs for the most recent one are returned. +Use --uid to target a specific TaskRun when needed. + NOTE: Logs are not supported for the system namespace or for the default namespace used by LokiStack. Logs are only available for completed TaskRuns. Running TaskRuns do not have logs available yet.`, @@ -69,47 +71,56 @@ Logs are only available for completed TaskRuns. Running TaskRuns do not have log }, RunE: func(cmd *cobra.Command, _ []string) error { ctx := cmd.Context() - - // Build filter string to find the TaskRun - filter := common.BuildFilterString(opts) - - // Handle namespace - parent := fmt.Sprintf("%s/results/-", p.Namespace()) - - // Create record client recordClient := records.NewClient(opts.Client) - // Find the TaskRun record - resp, err := recordClient.ListRecords(ctx, &pb.ListRecordsRequest{ - Parent: parent, - Filter: filter, - PageSize: 25, - }, common.NameUIDAndDataField) - if err != nil { - return fmt.Errorf("failed to find TaskRun: %v", err) - } - if len(resp.Records) == 0 { - if opts.UID != "" && opts.ResourceName != "" { - return fmt.Errorf("no TaskRun found with name %s and UID %s", opts.ResourceName, opts.UID) - } else if opts.UID != "" { - return fmt.Errorf("no TaskRun found with UID %s", opts.UID) - } - return fmt.Errorf("no TaskRun found with name %s", opts.ResourceName) - } + var record *pb.Record - // If multiple TaskRuns are found, return an error - if len(resp.Records) > 1 { - var uids []string - for _, record := range resp.Records { - uids = append(uids, record.Uid) + if opts.UID != "" { + // Try direct primary key lookup first (works for standalone TaskRuns) + r, err := recordClient.GetRecord(ctx, p.Namespace(), opts.UID) + if err == nil { + record = r + } else { + // Fallback: filter by record name column (text, indexed) instead + // of data.metadata.uid (JSONB, unindexed). Needed for child + // TaskRuns where the result UID is the parent PipelineRun UID. + filter := fmt.Sprintf(`name=="%s"`, opts.UID) + parent := fmt.Sprintf("%s/results/-", p.Namespace()) + resp, err := recordClient.ListRecords(ctx, &pb.ListRecordsRequest{ + Parent: parent, + Filter: filter, + OrderBy: "create_time desc", + PageSize: 5, + }, common.NameUIDAndDataField) + if err != nil { + return fmt.Errorf("failed to find TaskRun: %v", err) + } + if len(resp.Records) == 0 { + if opts.ResourceName != "" { + return fmt.Errorf("no TaskRun found with name %s and UID %s", opts.ResourceName, opts.UID) + } + return fmt.Errorf("no TaskRun found with UID %s", opts.UID) + } + record = resp.Records[0] } - return fmt.Errorf("multiple TaskRuns found. Use a more specific name or UID. Available UIDs are: %s", - strings.Join(uids, ", ")) + } else { + filter := common.BuildFilterString(opts) + parent := fmt.Sprintf("%s/results/-", p.Namespace()) + resp, err := recordClient.ListRecords(ctx, &pb.ListRecordsRequest{ + Parent: parent, + Filter: filter, + OrderBy: "create_time desc", + PageSize: 5, + }, common.NameUIDAndDataField) + if err != nil { + return fmt.Errorf("failed to find TaskRun: %v", err) + } + if len(resp.Records) == 0 { + return fmt.Errorf("no TaskRun found with name %s", opts.ResourceName) + } + record = resp.Records[0] } - // Get the TaskRun record - record := resp.Records[0] - // Check if the TaskRun is completed before attempting to get logs var taskRun v1.TaskRun if err := json.Unmarshal(record.Data.Value, &taskRun); err != nil { diff --git a/pkg/cli/common/labels.go b/pkg/cli/common/labels.go index c1b390dfe..9a3cd55a6 100644 --- a/pkg/cli/common/labels.go +++ b/pkg/cli/common/labels.go @@ -42,14 +42,16 @@ type FilterOptions interface { GetPipelineRun() string GetResourceType() string GetUID() string + SelectsExactMatch() bool } // BuildFilterString constructs the filter string for the ListRecordsRequest func BuildFilterString(opts FilterOptions) string { const ( - contains = "data.metadata.%s.contains(\"%s\")" - equal = "data.metadata.%s[\"%s\"]==\"%s\"" - dataType = "data_type==\"%s\"" + contains = "data.metadata.%s.contains(\"%s\")" + mapEntryEquals = "data.metadata.%s[\"%s\"]==\"%s\"" + entryEquals = "data.metadata.%s==\"%s\"" + dataType = "data_type==\"%s\"" ) var filters []string @@ -77,19 +79,27 @@ func BuildFilterString(opts FilterOptions) string { if len(parts) == 2 { key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) - filters = append(filters, fmt.Sprintf(equal, "labels", key, value)) + filters = append(filters, fmt.Sprintf(mapEntryEquals, "labels", key, value)) } } } // Handle pipeline name filter if opts.GetResourceName() != "" { - filters = append(filters, fmt.Sprintf(contains, "name", opts.GetResourceName())) + if opts.SelectsExactMatch() { + filters = append(filters, fmt.Sprintf(entryEquals, "name", opts.GetResourceName())) + } else { + filters = append(filters, fmt.Sprintf(contains, "name", opts.GetResourceName())) + } } // Handle UID filter if opts.GetUID() != "" { - filters = append(filters, fmt.Sprintf(contains, "uid", opts.GetUID())) + if opts.SelectsExactMatch() { + filters = append(filters, fmt.Sprintf(entryEquals, "uid", opts.GetUID())) + } else { + filters = append(filters, fmt.Sprintf(contains, "uid", opts.GetUID())) + } } // Add PipelineRun filter if provided diff --git a/pkg/cli/common/labels_test.go b/pkg/cli/common/labels_test.go new file mode 100644 index 000000000..1b674ab52 --- /dev/null +++ b/pkg/cli/common/labels_test.go @@ -0,0 +1,164 @@ +package common_test + +import ( + "testing" + + "github.com/tektoncd/results/pkg/cli/common" + "github.com/tektoncd/results/pkg/cli/options" +) + +func TestBuildFilterString(t *testing.T) { + tests := []struct { + name string + opts common.FilterOptions + expectedFilter string + }{ + // TaskRun List Tests + { + name: "list: taskrun with pipelinerun filter only", + opts: &options.ListOptions{ + PipelineRun: "test-pipeline", + ResourceType: common.ResourceTypeTaskRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.TaskRun" || data_type=="tekton.dev/v1beta1.TaskRun") && data.metadata.labels['tekton.dev/pipelineRun'] == 'test-pipeline'`, + }, + { + name: "list: taskrun with pipelinerun and label filters", + opts: &options.ListOptions{ + PipelineRun: "test-pipeline", + Label: "app=test", + ResourceType: common.ResourceTypeTaskRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.TaskRun" || data_type=="tekton.dev/v1beta1.TaskRun") && data.metadata.labels["app"]=="test" && data.metadata.labels['tekton.dev/pipelineRun'] == 'test-pipeline'`, + }, + { + name: "list: taskrun with pipelinerun and name filters use contains", + opts: &options.ListOptions{ + PipelineRun: "test-pipeline", + ResourceName: "test-task", + ResourceType: common.ResourceTypeTaskRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.TaskRun" || data_type=="tekton.dev/v1beta1.TaskRun") && data.metadata.name.contains("test-task") && data.metadata.labels['tekton.dev/pipelineRun'] == 'test-pipeline'`, + }, + { + name: "list: taskrun name filter uses contains", + opts: &options.ListOptions{ + ResourceName: "my-taskrun", + ResourceType: common.ResourceTypeTaskRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.TaskRun" || data_type=="tekton.dev/v1beta1.TaskRun") && data.metadata.name.contains("my-taskrun")`, + }, + // TaskRun Describe Tests + { + name: "describe: taskrun name filter uses exact match", + opts: &options.DescribeOptions{ + ResourceName: "my-taskrun", + ResourceType: common.ResourceTypeTaskRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.TaskRun" || data_type=="tekton.dev/v1beta1.TaskRun") && data.metadata.name=="my-taskrun"`, + }, + { + name: "describe: taskrun name and UID filters use exact match", + opts: &options.DescribeOptions{ + ResourceName: "my-taskrun", + UID: "abc-123", + ResourceType: common.ResourceTypeTaskRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.TaskRun" || data_type=="tekton.dev/v1beta1.TaskRun") && data.metadata.name=="my-taskrun" && data.metadata.uid=="abc-123"`, + }, + { + name: "describe: taskrun UID-only filter uses exact match", + opts: &options.DescribeOptions{ + UID: "abc-123", + ResourceType: common.ResourceTypeTaskRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.TaskRun" || data_type=="tekton.dev/v1beta1.TaskRun") && data.metadata.uid=="abc-123"`, + }, + // PipelineRun List Tests + { + name: "list: pipelinerun name filter uses contains", + opts: &options.ListOptions{ + ResourceName: "my-pipeline", + ResourceType: common.ResourceTypePipelineRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.PipelineRun" || data_type=="tekton.dev/v1beta1.PipelineRun") && data.metadata.name.contains("my-pipeline")`, + }, + { + name: "list: pipelinerun label filter only", + opts: &options.ListOptions{ + Label: "app=test", + ResourceType: common.ResourceTypePipelineRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.PipelineRun" || data_type=="tekton.dev/v1beta1.PipelineRun") && data.metadata.labels["app"]=="test"`, + }, + { + name: "list: pipelinerun name and label filters use contains", + opts: &options.ListOptions{ + ResourceName: "my-pipeline", + Label: "app=test", + ResourceType: common.ResourceTypePipelineRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.PipelineRun" || data_type=="tekton.dev/v1beta1.PipelineRun") && data.metadata.labels["app"]=="test" && data.metadata.name.contains("my-pipeline")`, + }, + // PipelineRun Describe Tests + { + name: "describe: pipelinerun name filter uses exact match", + opts: &options.DescribeOptions{ + ResourceName: "my-pipeline", + ResourceType: common.ResourceTypePipelineRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.PipelineRun" || data_type=="tekton.dev/v1beta1.PipelineRun") && data.metadata.name=="my-pipeline"`, + }, + { + name: "describe: pipelinerun name and UID filters use exact match", + opts: &options.DescribeOptions{ + ResourceName: "my-pipeline", + UID: "abc-123", + ResourceType: common.ResourceTypePipelineRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.PipelineRun" || data_type=="tekton.dev/v1beta1.PipelineRun") && data.metadata.name=="my-pipeline" && data.metadata.uid=="abc-123"`, + }, + { + name: "describe: pipelinerun UID-only filter uses exact match", + opts: &options.DescribeOptions{ + UID: "abc-123", + ResourceType: common.ResourceTypePipelineRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.PipelineRun" || data_type=="tekton.dev/v1beta1.PipelineRun") && data.metadata.uid=="abc-123"`, + }, + // Logs Tests + { + name: "logs: taskrun name filter uses exact match", + opts: &options.LogsOptions{ + ResourceName: "my-taskrun", + ResourceType: common.ResourceTypeTaskRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.TaskRun" || data_type=="tekton.dev/v1beta1.TaskRun") && data.metadata.name=="my-taskrun"`, + }, + { + name: "logs: pipelinerun name filter uses exact match", + opts: &options.LogsOptions{ + ResourceName: "my-pipeline", + ResourceType: common.ResourceTypePipelineRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.PipelineRun" || data_type=="tekton.dev/v1beta1.PipelineRun") && data.metadata.name=="my-pipeline"`, + }, + { + name: "logs: UID-only filter uses exact match", + opts: &options.LogsOptions{ + UID: "abc-123", + ResourceType: common.ResourceTypePipelineRun, + }, + expectedFilter: `(data_type=="tekton.dev/v1.PipelineRun" || data_type=="tekton.dev/v1beta1.PipelineRun") && data.metadata.uid=="abc-123"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actualFilter := common.BuildFilterString(tt.opts) + if actualFilter != tt.expectedFilter { + t.Errorf("Expected filter: %s, got: %s", tt.expectedFilter, actualFilter) + } + }) + } +} diff --git a/pkg/cli/options/describe.go b/pkg/cli/options/describe.go index 54973c9f5..b74d03c43 100644 --- a/pkg/cli/options/describe.go +++ b/pkg/cli/options/describe.go @@ -1,7 +1,12 @@ // Package options provides shared option structs for CLI commands. package options -import "github.com/tektoncd/results/pkg/cli/client" +import ( + "github.com/tektoncd/results/pkg/cli/client" + "github.com/tektoncd/results/pkg/cli/common" +) + +var _ common.FilterOptions = (*DescribeOptions)(nil) // DescribeOptions contains options for describing a resource. type DescribeOptions struct { @@ -35,3 +40,9 @@ func (o *DescribeOptions) GetResourceType() string { func (o *DescribeOptions) GetUID() string { return o.UID } + +// SelectsExactMatch implements FilterOptions interface. +// Describe always uses exact match for faster server-side filtering. +func (o *DescribeOptions) SelectsExactMatch() bool { + return true +} diff --git a/pkg/cli/options/list.go b/pkg/cli/options/list.go index dfc4a8d13..bfdcbda8b 100644 --- a/pkg/cli/options/list.go +++ b/pkg/cli/options/list.go @@ -2,8 +2,11 @@ package options import ( "github.com/tektoncd/results/pkg/cli/client" + "github.com/tektoncd/results/pkg/cli/common" ) +var _ common.FilterOptions = (*ListOptions)(nil) + // ListOptions holds the options for listing resources type ListOptions struct { Client *client.RESTClient @@ -40,3 +43,9 @@ func (o *ListOptions) GetResourceType() string { func (o *ListOptions) GetUID() string { return "" } + +// SelectsExactMatch implements FilterOptions interface. +// List uses substring matching to support partial name searches. +func (o *ListOptions) SelectsExactMatch() bool { + return false +} diff --git a/pkg/cli/options/logs.go b/pkg/cli/options/logs.go index 753adf3f2..38d0ca7f7 100644 --- a/pkg/cli/options/logs.go +++ b/pkg/cli/options/logs.go @@ -1,6 +1,11 @@ package options -import "github.com/tektoncd/results/pkg/cli/client" +import ( + "github.com/tektoncd/results/pkg/cli/client" + "github.com/tektoncd/results/pkg/cli/common" +) + +var _ common.FilterOptions = (*LogsOptions)(nil) // LogsOptions contains options for fetching logs for a resource. type LogsOptions struct { @@ -34,3 +39,9 @@ func (o *LogsOptions) GetResourceType() string { func (o *LogsOptions) GetUID() string { return o.UID } + +// SelectsExactMatch implements FilterOptions interface. +// Logs uses exact match for faster server-side filtering. +func (o *LogsOptions) SelectsExactMatch() bool { + return true +}