fix: record skipped collectors in bundle and improve CLI warnings (#2019)

* fix: record skipped collectors in bundle and improve CLI warnings

- Write `skipped-collectors.json` to the support bundle and preflight bundle recording which collectors were skipped (RBAC permissions or spec exclusion), with reason, errors, and timestamp
- Surface RBAC errors and skipped-collector messages as `klog.Warningf` in non-interactive mode so they're visible without `-v` flags
- Exec collector file naming now falls back to the resolved container name (from the pod spec) when `collectorName` is not set, instead of only checking `containerName` from the spec

Signed-off-by: Evans Mungai <evans@replicated.com>

* Address comment

Signed-off-by: Evans Mungai <evans@replicated.com>

* fix bugbot comment

Signed-off-by: Evans Mungai <evans@replicated.com>

* Remove unused var

Signed-off-by: Evans Mungai <evans@replicated.com>

* Address comment

Signed-off-by: Evans Mungai <evans@replicated.com>

* Use results.SaveResult and add automated tests

Signed-off-by: Evans Mungai <evans@replicated.com>

* Improvements

Signed-off-by: Evans Mungai <evans@replicated.com>

* Fix bugbot

Signed-off-by: Evans Mungai <evans@replicated.com>

* Handle bugbot

Signed-off-by: Evans Mungai <evans@replicated.com>

---------

Signed-off-by: Evans Mungai <evans@replicated.com>
This commit is contained in:
Evans Mungai
2026-04-15 22:29:35 +01:00
committed by GitHub
parent 35d5ad17af
commit a3c453d1d6
9 changed files with 275 additions and 9 deletions
+12 -1
View File
@@ -166,7 +166,18 @@ func runTroubleshoot(v *viper.Viper, args []string) error {
go func() {
defer wg.Done()
for msg := range progressChan {
klog.Infof("Collecting support bundle: %v", msg)
switch msg := msg.(type) {
case error:
klog.Warningf("Collecting support bundle: %v", msg)
case string:
if strings.Contains(msg, "skipping collector") {
klog.Warningf("Collecting support bundle: %s", msg)
} else {
klog.Infof("Collecting support bundle: %s", msg)
}
default:
klog.Infof("Collecting support bundle: %v", msg)
}
}
}()
} else {
+32
View File
@@ -1,9 +1,11 @@
package collect
import (
"bytes"
"context"
"encoding/json"
"fmt"
"maps"
"strconv"
"strings"
"time"
@@ -13,6 +15,7 @@ import (
"github.com/replicatedhq/troubleshoot/pkg/multitype"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/klog/v2"
)
type Collector interface {
@@ -296,6 +299,35 @@ func DedupCollectors(allCollectors []*troubleshootv1beta2.Collect) []*troublesho
return finalCollectors
}
// SkippedCollector records information about a collector that was skipped during collection.
type SkippedCollector struct {
Collector string `json:"collector"`
Reason string `json:"reason"`
Errors []string `json:"errors"`
Timestamp string `json:"timestamp"`
}
// WriteSkippedCollectors marshals the skipped collectors list and saves it
// using SaveResult which handles both in-memory and on-disk storage.
func WriteSkippedCollectors(skipped []SkippedCollector, allCollectedData map[string][]byte, bundlePath string) {
if len(skipped) == 0 {
return
}
skippedJSON, err := json.Marshal(skipped)
if err != nil {
return
}
// Either write to bundle path or memory
c := CollectorResult{}
if err := c.SaveResult(bundlePath, "skipped-collectors.json", bytes.NewReader(skippedJSON)); err != nil {
klog.Errorf("Failed to save skipped collectors: %v", err)
} else {
// Write to collected data to return downstream
maps.Copy(allCollectedData, c)
}
}
// Ensure Copy collectors are last in the list
// This is because copy collectors are expected to copy files from other collectors such as Exec, RunPod, RunDaemonSet
func EnsureCopyLast(allCollectors []Collector) []Collector {
+88
View File
@@ -1,6 +1,9 @@
package collect
import (
"encoding/json"
"os"
"path/filepath"
"testing"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
@@ -519,3 +522,88 @@ func TestEnsureCopyLast(t *testing.T) {
})
}
}
func TestWriteSkippedCollectors(t *testing.T) {
tests := []struct {
name string
skipped []SkippedCollector
bundlePath string
useTempDir bool
wantInMap bool
wantOnDisk bool
wantEntries []SkippedCollector
}{
{
name: "empty skipped list does nothing",
skipped: nil,
bundlePath: "",
wantInMap: false,
},
{
name: "in-memory only when bundlePath is empty",
skipped: []SkippedCollector{
{Collector: "clusterResources", Reason: "excluded", Timestamp: "2026-01-01T00:00:00Z"},
},
bundlePath: "",
wantInMap: true,
wantOnDisk: false,
wantEntries: []SkippedCollector{
{Collector: "clusterResources", Reason: "excluded", Timestamp: "2026-01-01T00:00:00Z"},
},
},
{
name: "writes to disk when bundlePath is set",
skipped: []SkippedCollector{
{Collector: "clusterResources", Reason: "excluded", Timestamp: "2026-01-01T00:00:00Z"},
{Collector: "logs", Reason: "insufficient RBAC permissions", Errors: []string{"pods is forbidden"}, Timestamp: "2026-01-01T00:00:01Z"},
},
useTempDir: true,
wantInMap: true,
wantOnDisk: true,
wantEntries: []SkippedCollector{
{Collector: "clusterResources", Reason: "excluded", Timestamp: "2026-01-01T00:00:00Z"},
{Collector: "logs", Reason: "insufficient RBAC permissions", Errors: []string{"pods is forbidden"}, Timestamp: "2026-01-01T00:00:01Z"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bundlePath := tt.bundlePath
if tt.useTempDir {
bundlePath = t.TempDir()
}
result := CollectorResult{}
WriteSkippedCollectors(tt.skipped, result, bundlePath)
if !tt.wantInMap {
assert.Empty(t, result)
return
}
// Verify in-memory entry exists
if bundlePath == "" {
// In-memory mode: data is stored in the map
data, ok := result["skipped-collectors.json"]
require.True(t, ok, "skipped-collectors.json should be in result map")
require.NotNil(t, data)
var got []SkippedCollector
require.NoError(t, json.Unmarshal(data, &got))
assert.Equal(t, tt.wantEntries, got)
} else {
// On-disk mode: map entry exists with nil value, file is on disk
_, ok := result["skipped-collectors.json"]
require.True(t, ok, "skipped-collectors.json should be in result map")
diskData, err := os.ReadFile(filepath.Join(bundlePath, "skipped-collectors.json"))
require.NoError(t, err)
var got []SkippedCollector
require.NoError(t, json.Unmarshal(diskData, &got))
assert.Equal(t, tt.wantEntries, got)
}
})
}
}
+6 -1
View File
@@ -87,9 +87,14 @@ func execWithoutTimeout(clientConfig *rest.Config, bundlePath string, execCollec
pod := pods[0]
stdout, stderr, execErrors := getExecOutputs(ctx, clientConfig, client, pod, execCollector)
container := pod.Spec.Containers[0].Name
if execCollector.ContainerName != "" {
container = execCollector.ContainerName
}
filePrefix := execCollector.CollectorName
if filePrefix == "" {
filePrefix = execCollector.ContainerName
filePrefix = container
}
path := filepath.Join(execCollector.Name, pod.Namespace, pod.Name)
+2 -3
View File
@@ -6,6 +6,7 @@ import (
"compress/gzip"
"fmt"
"io"
"maps"
"os"
"path"
"path/filepath"
@@ -87,9 +88,7 @@ func (r CollectorResult) SymLinkResult(bundlePath, relativeLinkPath, relativeFil
// It also ensures that when operating on the results in memory (e.g preflights),
// all files are included.
func (r CollectorResult) AddResult(other CollectorResult) {
for k, v := range other {
r[k] = v
}
maps.Copy(r, other)
}
// SaveResult saves the collector result to relativePath file on disk. If bundlePath is
+25
View File
@@ -245,6 +245,8 @@ func CollectWithContext(ctx context.Context, opts CollectOpts, p *troubleshootv1
// move Copy Collectors if any to the end of the execution list
allCollectors = collect.EnsureCopyLast(allCollectors)
var skippedCollectors []collect.SkippedCollector
for i, collector := range allCollectors {
_, span := otel.Tracer(constants.LIB_TRACER_NAME).Start(ctx, collector.Title())
span.SetAttributes(attribute.String("type", reflect.TypeOf(collector).String()))
@@ -254,6 +256,13 @@ func CollectWithContext(ctx context.Context, opts CollectOpts, p *troubleshootv1
klog.V(1).Infof("excluding %q collector", collector.Title())
span.SetAttributes(attribute.Bool(constants.EXCLUDED, true))
span.End()
skippedCollectors = append(skippedCollectors, collect.SkippedCollector{
Collector: collector.Title(),
Reason: "excluded",
Timestamp: time.Now().Format(time.RFC3339),
})
continue
}
@@ -270,6 +279,19 @@ func CollectWithContext(ctx context.Context, opts CollectOpts, p *troubleshootv1
}
span.SetStatus(codes.Error, "skipping collector, insufficient RBAC permissions")
span.End()
rbacErrors := collector.GetRBACErrors()
errorMessages := make([]string, 0, len(rbacErrors))
for _, e := range rbacErrors {
errorMessages = append(errorMessages, e.Error())
}
skippedCollectors = append(skippedCollectors, collect.SkippedCollector{
Collector: collector.Title(),
Reason: "insufficient RBAC permissions",
Errors: errorMessages,
Timestamp: time.Now().Format(time.RFC3339),
})
continue
}
}
@@ -320,6 +342,9 @@ func CollectWithContext(ctx context.Context, opts CollectOpts, p *troubleshootv1
span.End()
}
// Write skipped collectors manifest so users can see what was missed
collect.WriteSkippedCollectors(skippedCollectors, allCollectedData, opts.BundlePath)
// The values of map entries will contain the collected data in bytes if the data was not stored to disk
collectResult.AllCollectedData = allCollectedData
+28 -4
View File
@@ -39,9 +39,8 @@ import (
)
const (
selectorLabelKey = "ds-selector-label"
selectorLabelValue = "remote-host-collector"
defaultTimeout = 30
selectorLabelKey = "ds-selector-label"
defaultTimeout = 30
)
func runHostCollectors(ctx context.Context, hostCollectors []*troubleshootv1beta2.HostCollect, additionalRedactors *troubleshootv1beta2.Redactor, bundlePath string, opts SupportBundleCreateOpts) (collect.CollectorResult, error) {
@@ -107,7 +106,7 @@ func runCollectors(ctx context.Context, collectors []*troubleshootv1beta2.Collec
allCollectorsMap := make(map[reflect.Type][]collect.Collector)
collectorTypeOrder := make([]reflect.Type, 0) // Preserve order of collector types
allCollectedData := make(map[string][]byte)
allCollectedData := map[string][]byte{}
for _, desiredCollector := range collectSpecs {
if collectorInterface, ok := collect.GetCollector(desiredCollector, bundlePath, opts.Namespace, opts.KubernetesRestConfig, k8sClient, opts.SinceTime); ok {
@@ -155,6 +154,8 @@ func runCollectors(ctx context.Context, collectors []*troubleshootv1beta2.Collec
// move Copy Collectors if any to the end of the execution list
allCollectors = collect.EnsureCopyLast(allCollectors)
var skippedCollectors []collect.SkippedCollector
for _, collector := range allCollectors {
_, span := otel.Tracer(constants.LIB_TRACER_NAME).Start(ctx, collector.Title())
span.SetAttributes(attribute.String("type", reflect.TypeOf(collector).String()))
@@ -165,6 +166,13 @@ func runCollectors(ctx context.Context, collectors []*troubleshootv1beta2.Collec
opts.CollectorProgressCallback(opts.ProgressChan, msg)
span.SetAttributes(attribute.Bool(constants.EXCLUDED, true))
span.End()
skippedCollectors = append(skippedCollectors, collect.SkippedCollector{
Collector: collector.Title(),
Reason: "excluded",
Timestamp: time.Now().Format(time.RFC3339),
})
continue
}
@@ -175,6 +183,19 @@ func runCollectors(ctx context.Context, collectors []*troubleshootv1beta2.Collec
opts.CollectorProgressCallback(opts.ProgressChan, msg)
span.SetStatus(codes.Error, "skipping collector, insufficient RBAC permissions")
span.End()
rbacErrors := collector.GetRBACErrors()
errorMessages := make([]string, 0, len(rbacErrors))
for _, e := range rbacErrors {
errorMessages = append(errorMessages, e.Error())
}
skippedCollectors = append(skippedCollectors, collect.SkippedCollector{
Collector: collector.Title(),
Reason: "insufficient RBAC permissions",
Errors: errorMessages,
Timestamp: time.Now().Format(time.RFC3339),
})
continue
}
}
@@ -207,6 +228,9 @@ func runCollectors(ctx context.Context, collectors []*troubleshootv1beta2.Collec
span.End()
}
// Write skipped collectors manifest to the bundle so users can see what was missed
collect.WriteSkippedCollectors(skippedCollectors, allCollectedData, bundlePath)
collectResult := allCollectedData
globalRedactors := []*troubleshootv1beta2.Redact{}
@@ -0,0 +1,72 @@
package e2e
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sigs.k8s.io/e2e-framework/pkg/envconf"
"sigs.k8s.io/e2e-framework/pkg/features"
)
func TestSkippedCollectors(t *testing.T) {
feature := features.New("Skipped Collectors").
Assess("bundle contains skipped-collectors.json for excluded collectors", func(ctx context.Context, t *testing.T, c *envconf.Config) context.Context {
var out bytes.Buffer
supportBundleName := "skipped-collectors-test"
tarPath := fmt.Sprintf("%s.tar.gz", supportBundleName)
cmd := exec.CommandContext(ctx, sbBinary(), "spec/skippedCollectors.yaml",
"--interactive=false",
fmt.Sprintf("-o=%s", supportBundleName),
)
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
if err != nil {
t.Fatalf("support-bundle command failed: %v\nOutput: %s", err, out.String())
}
defer func() {
err := os.Remove(tarPath)
if err != nil {
t.Fatal("Error removing file:", err)
}
}()
// Read skipped-collectors.json from the bundle
skippedJSON, err := readFileFromTar(tarPath, fmt.Sprintf("%s/skipped-collectors.json", supportBundleName))
require.NoError(t, err, "skipped-collectors.json should exist in the bundle")
var skipped []struct {
Collector string `json:"collector"`
Reason string `json:"reason"`
Errors []string `json:"errors"`
Timestamp string `json:"timestamp"`
}
err = json.Unmarshal(skippedJSON, &skipped)
require.NoError(t, err)
// Both excluded collectors should be recorded
assert.Len(t, skipped, 2)
collectors := map[string]string{}
for _, s := range skipped {
collectors[s.Collector] = s.Reason
assert.NotEmpty(t, s.Timestamp, "timestamp should be set")
}
assert.Equal(t, "excluded", collectors["cluster-resources"])
assert.Equal(t, "excluded", collectors["cluster-info"])
return ctx
}).Feature()
testenv.Test(t, feature)
}
@@ -0,0 +1,10 @@
apiVersion: troubleshoot.sh/v1beta2
kind: SupportBundle
metadata:
name: skipped-collectors-test
spec:
collectors:
- clusterResources:
exclude: true
- clusterInfo:
exclude: true