Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions config/302-pac-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,18 @@ data:
tekton-dashboard-url: ""

# Enable or disable the feature to show a log snippet of the failed task when there is
# an error in a Pipeline
# an error in a Pipeline. The number of lines shown is controlled by the
# error-log-snippet-number-of-lines setting below.
#
# It will show the last 3 lines of the first container of the first task
# that has error in the pipeline.
#
# you may want to disable this if you think your pipeline may leak some value
# You may want to disable this if you think your pipeline may leak sensitive values.
error-log-snippet: "true"

# The number of lines to display in error log snippets, when `error-log-snippet` is
# set to "true".
# The GitHub Check interface (via the GitHub App) has a 65,535 character limit,
# so consider using a conservative value for this setting.
error-log-snippet-number-of-lines: "3"

# Enable or disable the inspection of container logs to detect error message
# and expose them as annotations on Pull Request. Only Github apps is supported
error-detection-from-container-logs: "true"
Expand Down
17 changes: 13 additions & 4 deletions docs/content/docs/install/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,13 +254,22 @@ A few settings are available to configure this feature:
Enable or disable the feature to show a log snippet of the failed task when
there is an error in a PipelineRun.

Due to the constraints of the different GIT provider APIs, it will show the last
3 lines of the first container from the first task that has exited with an
error in the PipelineRun.
Due to the constraints of the different GIT provider APIs, it will show a
configurable number of lines of the first container from the first task that
has exited with an error in the PipelineRun. The number of lines is controlled
by the `error-log-snippet-number-of-lines` setting (see below).

If it find any strings matching the values of secrets attached to the
If it finds any strings matching the values of secrets attached to the
PipelineRun it will replace it with the placeholder `******`

* `error-log-snippet-number-of-lines`

default: `3`

How many lines to show in the error log snippets when `error-log-snippet` is set to `"true"`.
When using GitHub APP the GitHub Check interface [has a limit of 65535 characters](https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#create-a-check-run),
so you may want to be conservative with this setting.

* `error-detection-from-container-logs`

Enable or disable the inspection of the container logs to detect error message
Expand Down
17 changes: 15 additions & 2 deletions pkg/kubeinteraction/status/task_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"regexp"
"strings"
"unicode/utf8"

pacv1alpha1 "github.com/openshift-pipelines/pipelines-as-code/pkg/apis/pipelinesascode/v1alpha1"
"github.com/openshift-pipelines/pipelines-as-code/pkg/kubeinteraction"
Expand All @@ -17,6 +18,8 @@ import (

var reasonMessageReplacementRegexp = regexp.MustCompile(`\(image: .*`)

const maxErrorSnippetCharacterLimit = 65535 // This is the maximum size allowed by Github check run logs and may apply to all other providers

// GetTaskRunStatusForPipelineTask takes a minimal embedded status child reference and returns the actual TaskRunStatus
// for the PipelineTask. It returns an error if the child reference's kind isn't TaskRun.
func GetTaskRunStatusForPipelineTask(ctx context.Context, client versioned.Interface, ns string, childRef tektonv1.ChildStatusReference) (*tektonv1.TaskRunStatus, error) {
Expand Down Expand Up @@ -113,8 +116,18 @@ func CollectFailedTasksLogSnippet(ctx context.Context, cs *params.Run, kinteract
if strings.HasSuffix(trimmed, " Skipping step because a previous step failed") {
continue
}
// see if a pattern match from errRe
ti.LogSnippet = strings.TrimSpace(trimmed)
// GitHub's character limit is actually in bytes, not unicode characters
// Truncate to maxErrorSnippetCharacterLimit bytes, then trim to last valid UTF-8 boundary
if len(trimmed) > maxErrorSnippetCharacterLimit {
trimmed = trimmed[:maxErrorSnippetCharacterLimit]
// Trim further to last valid rune boundary to ensure valid UTF-8
r, size := utf8.DecodeLastRuneInString(trimmed)
for r == utf8.RuneError && size > 0 {
trimmed = trimmed[:len(trimmed)-size]
r, size = utf8.DecodeLastRuneInString(trimmed)
}
}
ti.LogSnippet = trimmed
}
}
}
Expand Down
136 changes: 136 additions & 0 deletions pkg/kubeinteraction/status/task_status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package status

import (
"testing"
"unicode/utf8"

"github.com/jonboulle/clockwork"
"github.com/openshift-pipelines/pipelines-as-code/pkg/params"
Expand Down Expand Up @@ -116,6 +117,141 @@ func TestCollectFailedTasksLogSnippet(t *testing.T) {
}
}

func TestCollectFailedTasksLogSnippetUTF8SafeTruncation(t *testing.T) {
clock := clockwork.NewFakeClock()

tests := []struct {
name string
podOutput string
expectedTruncation bool
expectedLengthRunes int // Expected rune count for non-truncated strings
expectValidUTF8 bool // Should result in valid UTF-8
}{
{
name: "short ascii text",
podOutput: "Error: simple failure message",
expectedTruncation: false,
expectedLengthRunes: 29,
expectValidUTF8: true,
},
{
name: "long ascii text over limit",
podOutput: string(make([]byte, maxErrorSnippetCharacterLimit+100)), // Fill with null bytes which are 1 byte each
expectedTruncation: true,
expectValidUTF8: true,
},
{
name: "utf8 text under limit",
podOutput: "πŸš€ Error: deployment failed with Γ©mojis and spΓ©cial chars",
expectedTruncation: false,
expectedLengthRunes: len([]rune("πŸš€ Error: deployment failed with Γ©mojis and spΓ©cial chars")),
expectValidUTF8: true,
},
{
name: "utf8 text over limit",
podOutput: "πŸš€ " + string(make([]rune, maxErrorSnippetCharacterLimit)), // Create string with unicode chars (will be >65535 bytes)
expectedTruncation: true,
expectValidUTF8: true,
},
{
name: "mixed utf8 at boundary",
podOutput: string(make([]rune, maxErrorSnippetCharacterLimit+1)) + "πŸš€πŸ”₯πŸ’₯",
expectedTruncation: true,
expectValidUTF8: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pr := tektontest.MakePRCompletion(clock, "pipeline-newest", "ns", tektonv1.PipelineRunReasonSuccessful.String(), nil, make(map[string]string), 10)
pr.Status.ChildReferences = []tektonv1.ChildStatusReference{
{
TypeMeta: runtime.TypeMeta{
Kind: "TaskRun",
},
Name: "task1",
PipelineTaskName: "task1",
},
}

taskStatus := tektonv1.TaskRunStatusFields{
PodName: "task1",
Steps: []tektonv1.StepState{
{
Name: "step1",
ContainerState: corev1.ContainerState{
Terminated: &corev1.ContainerStateTerminated{
ExitCode: 1,
},
},
},
},
}

tdata := testclient.Data{
TaskRuns: []*tektonv1.TaskRun{
tektontest.MakeTaskRunCompletion(clock, "task1", "ns", "pipeline-newest",
map[string]string{}, taskStatus, knativeduckv1.Conditions{
{
Type: knativeapi.ConditionSucceeded,
Status: corev1.ConditionFalse,
Reason: "Failed",
Message: "task failed",
},
},
10),
},
}

ctx, _ := rtesting.SetupFakeContext(t)
stdata, _ := testclient.SeedTestData(t, ctx, tdata)
cs := &params.Run{Clients: paramclients.Clients{
Tekton: stdata.Pipeline,
}}

intf := &kubernetestint.KinterfaceTest{
GetPodLogsOutput: map[string]string{
"task1": tt.podOutput,
},
}

got := CollectFailedTasksLogSnippet(ctx, cs, intf, pr, 1)
assert.Equal(t, 1, len(got))

snippet := got["task1"].LogSnippet
byteCount := len(snippet)
runeCount := len([]rune(snippet))

if tt.expectedTruncation {
// Should be truncated to at most maxErrorSnippetCharacterLimit bytes
if byteCount > maxErrorSnippetCharacterLimit {
t.Errorf("Expected truncated string to be at most %d bytes, got %d",
maxErrorSnippetCharacterLimit, byteCount)
}

// Verify the string is valid UTF-8 after truncation
assert.True(t, utf8.ValidString(snippet), "Truncated string should be valid UTF-8")

// Should be shorter than original (in bytes)
assert.Less(t, byteCount, len(tt.podOutput),
"Truncated string should be shorter than original")
} else {
// Should match expected length exactly (in runes for non-truncated)
assert.Equal(t, tt.expectedLengthRunes, runeCount,
"Expected string length %d runes, got %d", tt.expectedLengthRunes, runeCount)

// Should match original (no truncation)
assert.Equal(t, tt.podOutput, snippet, "String should not be truncated")
}

// Always verify valid UTF-8
if tt.expectValidUTF8 {
assert.True(t, utf8.ValidString(snippet), "String should be valid UTF-8")
}
})
}
}

func TestGetStatusFromTaskStatusOrFromAsking(t *testing.T) {
testNS := "test"
tests := []struct {
Expand Down
9 changes: 5 additions & 4 deletions pkg/params/settings/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,11 @@ type Settings struct {
SecretGHAppRepoScoped bool `default:"true" json:"secret-github-app-token-scoped"`
SecretGhAppTokenScopedExtraRepos string `json:"secret-github-app-scope-extra-repos"`

ErrorLogSnippet bool `default:"true" json:"error-log-snippet"`
ErrorDetection bool `default:"true" json:"error-detection-from-container-logs"`
ErrorDetectionNumberOfLines int `default:"50" json:"error-detection-max-number-of-lines"`
ErrorDetectionSimpleRegexp string `default:"^(?P<filename>[^:]*):(?P<line>[0-9]+):(?P<column>[0-9]+)?([ ]*)?(?P<error>.*)" json:"error-detection-simple-regexp"`
ErrorLogSnippet bool `default:"true" json:"error-log-snippet"`
ErrorLogSnippetNumberOfLines int `default:"3" json:"error-log-snippet-number-of-lines"`
ErrorDetection bool `default:"true" json:"error-detection-from-container-logs"`
ErrorDetectionNumberOfLines int `default:"50" json:"error-detection-max-number-of-lines"`
ErrorDetectionSimpleRegexp string `default:"^(?P<filename>[^:]*):(?P<line>[0-9]+):(?P<column>[0-9]+)?([ ]*)?(?P<error>.*)" json:"error-detection-simple-regexp"`

EnableCancelInProgressOnPullRequests bool `json:"enable-cancel-in-progress-on-pull-requests"`
EnableCancelInProgressOnPush bool `json:"enable-cancel-in-progress-on-push"`
Expand Down
54 changes: 29 additions & 25 deletions pkg/params/settings/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func TestSyncConfig(t *testing.T) {
SecretGHAppRepoScoped: true,
SecretGhAppTokenScopedExtraRepos: "",
ErrorLogSnippet: true,
ErrorLogSnippetNumberOfLines: 3,
ErrorDetection: true,
ErrorDetectionNumberOfLines: 50,
ErrorDetectionSimpleRegexp: "^(?P<filename>[^:]*):(?P<line>[0-9]+):(?P<column>[0-9]+)?([ ]*)?(?P<error>.*)",
Expand Down Expand Up @@ -78,31 +79,34 @@ func TestSyncConfig(t *testing.T) {
"skip-push-event-for-pr-commits": "true",
},
expectedStruct: Settings{
ApplicationName: "pac-pac",
HubCatalogs: nil,
RemoteTasks: false,
MaxKeepRunsUpperLimit: 10,
DefaultMaxKeepRuns: 5,
BitbucketCloudCheckSourceIP: false,
BitbucketCloudAdditionalSourceIP: "some-ip",
TektonDashboardURL: "https://tekton-dashboard",
AutoConfigureNewGitHubRepo: true,
AutoConfigureRepoNamespaceTemplate: "template",
AutoConfigureRepoRepositoryTemplate: "template",
SecretAutoCreation: false,
SecretGHAppRepoScoped: false,
SecretGhAppTokenScopedExtraRepos: "extra-repos",
ErrorLogSnippet: false,
ErrorDetection: false,
ErrorDetectionNumberOfLines: 100,
ErrorDetectionSimpleRegexp: "^(?P<filename>[^:]*):(?P<line>[0-9]+):(?P<column>[0-9]+)?([ ]*)?(?P<error>.*)",
CustomConsoleName: "custom-console",
CustomConsoleURL: "https://custom-console",
CustomConsolePRdetail: "https://custom-console-pr-details",
CustomConsolePRTaskLog: "https://custom-console-pr-tasklog",
CustomConsoleNamespaceURL: "https://custom-console-namespace",
RememberOKToTest: false,
SkipPushEventForPRCommits: true,
ApplicationName: "pac-pac",
HubCatalogs: nil,
RemoteTasks: false,
MaxKeepRunsUpperLimit: 10,
DefaultMaxKeepRuns: 5,
BitbucketCloudCheckSourceIP: false,
BitbucketCloudAdditionalSourceIP: "some-ip",
TektonDashboardURL: "https://tekton-dashboard",
AutoConfigureNewGitHubRepo: true,
AutoConfigureRepoNamespaceTemplate: "template",
AutoConfigureRepoRepositoryTemplate: "template",
SecretAutoCreation: false,
SecretGHAppRepoScoped: false,
SecretGhAppTokenScopedExtraRepos: "extra-repos",
ErrorLogSnippet: false,
ErrorLogSnippetNumberOfLines: 3,
ErrorDetection: false,
ErrorDetectionNumberOfLines: 100,
ErrorDetectionSimpleRegexp: "^(?P<filename>[^:]*):(?P<line>[0-9]+):(?P<column>[0-9]+)?([ ]*)?(?P<error>.*)",
EnableCancelInProgressOnPullRequests: false,
EnableCancelInProgressOnPush: false,
SkipPushEventForPRCommits: true,
CustomConsoleName: "custom-console",
CustomConsoleURL: "https://custom-console",
CustomConsolePRdetail: "https://custom-console-pr-details",
CustomConsolePRTaskLog: "https://custom-console-pr-tasklog",
CustomConsoleNamespaceURL: "https://custom-console-namespace",
RememberOKToTest: false,
},
},
{
Expand Down
7 changes: 5 additions & 2 deletions pkg/reconciler/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import (

const (
maxPipelineRunStatusRun = 5
logSnippetNumLines = 3
)

var backoffSchedule = []time.Duration{
Expand Down Expand Up @@ -81,7 +80,11 @@ func (r *Reconciler) updateRepoRunStatus(ctx context.Context, logger *zap.Sugare
}

func (r *Reconciler) getFailureSnippet(ctx context.Context, pr *tektonv1.PipelineRun) string {
taskinfos := kstatus.CollectFailedTasksLogSnippet(ctx, r.run, r.kinteract, pr, logSnippetNumLines)
lines := int64(settings.DefaultSettings().ErrorLogSnippetNumberOfLines)
if r.run.Info.Pac != nil {
lines = int64(r.run.Info.Pac.ErrorLogSnippetNumberOfLines)
}
taskinfos := kstatus.CollectFailedTasksLogSnippet(ctx, r.run, r.kinteract, pr, lines)
if len(taskinfos) == 0 {
return ""
}
Expand Down
Loading