diff --git a/pkg/collect/autodiscovery/discoverer.go b/pkg/collect/autodiscovery/discoverer.go index 773b4245..506879d0 100644 --- a/pkg/collect/autodiscovery/discoverer.go +++ b/pkg/collect/autodiscovery/discoverer.go @@ -19,6 +19,8 @@ type Discoverer struct { client kubernetes.Interface rbacChecker *RBACChecker expander *ResourceExpander + kotsDetector *KotsDetector + rbacReporter *RBACReporter } // NewDiscoverer creates a new autodiscovery discoverer @@ -36,12 +38,16 @@ func NewDiscoverer(clientConfig *rest.Config, client kubernetes.Interface) (*Dis } expander := NewResourceExpander() + kotsDetector := NewKotsDetector(client) + rbacReporter := NewRBACReporter() return &Discoverer{ clientConfig: clientConfig, client: client, rbacChecker: rbacChecker, expander: expander, + kotsDetector: kotsDetector, + rbacReporter: rbacReporter, }, nil } @@ -65,7 +71,7 @@ func (d *Discoverer) DiscoverFoundational(ctx context.Context, opts DiscoveryOpt } // Generate foundational collectors - foundationalCollectors := d.generateFoundationalCollectors(namespaces, opts) + foundationalCollectors := d.generateFoundationalCollectors(discoveryCtx, namespaces, opts) // Apply RBAC filtering if enabled if opts.RBACCheck { @@ -77,6 +83,13 @@ func (d *Discoverer) DiscoverFoundational(ctx context.Context, opts DiscoveryOpt } } + // Generate RBAC remediation report if there were permission issues + if d.rbacReporter.HasWarnings() { + d.rbacReporter.GeneratePermissionSummary() + d.rbacReporter.GenerateRemediationReport() + d.rbacReporter.SummarizeCollectionResults(len(foundationalCollectors) + d.rbacReporter.GetFilteredCollectorCount()) + } + klog.V(2).Infof("Discovered %d foundational collectors", len(foundationalCollectors)) return foundationalCollectors, nil } @@ -139,12 +152,44 @@ func (d *Discoverer) getTargetNamespaces(ctx context.Context, requestedNamespace } // generateFoundationalCollectors creates the standard set of foundational collectors -func (d *Discoverer) generateFoundationalCollectors(namespaces []string, opts DiscoveryOptions) []CollectorSpec { +func (d *Discoverer) generateFoundationalCollectors(ctx context.Context, namespaces []string, opts DiscoveryOptions) []CollectorSpec { var collectors []CollectorSpec // Always include cluster-level info collectors = append(collectors, d.generateClusterInfoCollectors()...) + // KOTS-aware discovery: Detect and add KOTS-specific collectors + if kotsApps, err := d.kotsDetector.DetectKotsApplications(ctx); err == nil && len(kotsApps) > 0 { + klog.Infof("Found %d KOTS applications, generating KOTS-specific collectors", len(kotsApps)) + kotsCollectors := d.kotsDetector.GenerateKotsCollectors(kotsApps) + collectors = append(collectors, kotsCollectors...) + + // Log the KOTS collectors for debugging + for _, kotsCollector := range kotsCollectors { + klog.V(2).Infof("Added KOTS collector: %s (type: %s, namespace: %s)", + kotsCollector.Name, kotsCollector.Type, kotsCollector.Namespace) + } + } else if err != nil { + klog.V(2).Infof("KOTS detection failed (non-fatal): %v", err) + } else { + klog.V(2).Info("No KOTS applications detected in cluster") + } + + // Generate standard KOTS diagnostic collectors for troubleshooting (when not in test mode) + // These attempt to collect expected KOTS resources even if no apps are detected + // This creates valuable error files when resources are missing (important for support) + if !opts.TestMode { + standardKotsCollectors := d.kotsDetector.GenerateStandardKotsCollectors(ctx) + collectors = append(collectors, standardKotsCollectors...) + + klog.V(2).Infof("Added %d standard KOTS diagnostic collectors", len(standardKotsCollectors)) + for _, stdCollector := range standardKotsCollectors { + klog.V(2).Infof("Added standard KOTS collector: %s (creates error file if missing)", stdCollector.Name) + } + } else { + klog.V(2).Info("Skipping standard KOTS collectors in test mode") + } + // Add namespace-scoped collectors for each target namespace for _, namespace := range namespaces { collectors = append(collectors, d.generateNamespacedCollectors(namespace, opts)...) @@ -287,7 +332,9 @@ func (d *Discoverer) applyRBACFiltering(ctx context.Context, collectors []Collec if allowedKeys[key] { filteredCollectors = append(filteredCollectors, collector) } else { - klog.V(3).Infof("Filtered out collector %s due to RBAC permissions", collector.Name) + // FIXED: Replace silent filtering with user-visible warnings + d.rbacReporter.ReportFilteredCollector(collector, "insufficient RBAC permissions") + d.rbacReporter.ReportMissingPermission(resource.Kind, resource.Namespace, "get,list", collector.Name) } } diff --git a/pkg/collect/autodiscovery/discoverer_test.go b/pkg/collect/autodiscovery/discoverer_test.go index 719583e8..0b065a8d 100644 --- a/pkg/collect/autodiscovery/discoverer_test.go +++ b/pkg/collect/autodiscovery/discoverer_test.go @@ -101,6 +101,7 @@ func TestDiscoverer_DiscoverFoundational(t *testing.T) { IncludeImages: false, RBACCheck: false, Timeout: 10 * time.Second, + TestMode: true, }, wantCollectorTypes: map[CollectorType]int{ CollectorTypeClusterInfo: 1, @@ -119,6 +120,7 @@ func TestDiscoverer_DiscoverFoundational(t *testing.T) { IncludeImages: true, RBACCheck: false, Timeout: 10 * time.Second, + TestMode: true, }, wantCollectorTypes: map[CollectorType]int{ CollectorTypeClusterInfo: 1, @@ -138,6 +140,7 @@ func TestDiscoverer_DiscoverFoundational(t *testing.T) { IncludeImages: false, RBACCheck: false, Timeout: 10 * time.Second, + TestMode: true, }, wantMinCollectors: 8, // 2 cluster + 3*2 namespace collectors wantErr: false, @@ -149,6 +152,7 @@ func TestDiscoverer_DiscoverFoundational(t *testing.T) { IncludeImages: false, RBACCheck: false, Timeout: 10 * time.Second, + TestMode: true, }, wantMinCollectors: 2, // At least cluster collectors wantErr: false, @@ -409,7 +413,7 @@ func TestDiscoverer_generateFoundationalCollectors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - collectors := discoverer.generateFoundationalCollectors(tt.namespaces, tt.opts) + collectors := discoverer.generateFoundationalCollectors(context.Background(), tt.namespaces, tt.opts) if len(collectors) < tt.wantMinCount { t.Errorf("generateFoundationalCollectors() returned %d collectors, want at least %d", diff --git a/pkg/collect/autodiscovery/interfaces.go b/pkg/collect/autodiscovery/interfaces.go index f1f2d884..339ebc06 100644 --- a/pkg/collect/autodiscovery/interfaces.go +++ b/pkg/collect/autodiscovery/interfaces.go @@ -33,6 +33,8 @@ type DiscoveryOptions struct { AugmentMode bool // Timeout for discovery operations Timeout time.Duration + // TestMode disables KOTS diagnostic collectors for cleaner testing + TestMode bool } // CollectorSpec represents a collector specification that can be converted to troubleshootv1beta2.Collect @@ -65,6 +67,7 @@ const ( CollectorTypeClusterInfo CollectorType = "clusterInfo" CollectorTypeClusterResources CollectorType = "clusterResources" CollectorTypeImageFacts CollectorType = "imageFacts" + CollectorTypeData CollectorType = "data" ) // CollectorSource indicates the origin of a collector @@ -74,6 +77,7 @@ const ( SourceFoundational CollectorSource = "foundational" SourceYAML CollectorSource = "yaml" SourceAugmented CollectorSource = "augmented" + SourceKOTS CollectorSource = "kots" ) // Resource represents a Kubernetes resource for RBAC checking @@ -129,6 +133,10 @@ func (c CollectorSpec) ToTroubleshootCollect() (*troubleshootv1beta2.Collect, er if data, ok := c.Spec.(*troubleshootv1beta2.Data); ok { collect.Data = data } + case CollectorTypeData: + if data, ok := c.Spec.(*troubleshootv1beta2.Data); ok { + collect.Data = data + } // Add more cases as needed for other collector types } diff --git a/pkg/collect/autodiscovery/kots_detector.go b/pkg/collect/autodiscovery/kots_detector.go new file mode 100644 index 00000000..82635fe1 --- /dev/null +++ b/pkg/collect/autodiscovery/kots_detector.go @@ -0,0 +1,667 @@ +package autodiscovery + +import ( + "context" + "fmt" + + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/klog/v2" +) + +// KotsDetector detects KOTS applications in the cluster +type KotsDetector struct { + client kubernetes.Interface +} + +// NewKotsDetector creates a new KOTS detector +func NewKotsDetector(client kubernetes.Interface) *KotsDetector { + return &KotsDetector{ + client: client, + } +} + +// KotsApplication represents a detected KOTS application +type KotsApplication struct { + Namespace string + AppName string + KotsadmDeployment *appsv1.Deployment + KotsadmServices []corev1.Service + ReplicatedSecrets []corev1.Secret + ConfigMaps []corev1.ConfigMap + AdditionalResources []KotsResource +} + +// KotsResource represents a KOTS-related Kubernetes resource +type KotsResource struct { + Kind string + Name string + Namespace string +} + +// DetectKotsApplications searches for KOTS applications across all accessible namespaces +func (k *KotsDetector) DetectKotsApplications(ctx context.Context) ([]KotsApplication, error) { + klog.V(2).Info("Starting KOTS application detection") + + var kotsApps []KotsApplication + + // Get all accessible namespaces + namespaces, err := k.client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + klog.Warningf("Could not list namespaces for KOTS detection: %v", err) + // Fall back to checking common KOTS namespaces + namespaces = &corev1.NamespaceList{ + Items: []corev1.Namespace{ + {ObjectMeta: metav1.ObjectMeta{Name: "default"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "kots"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "kotsadm"}}, + }, + } + } + + // Check each namespace for KOTS applications + for _, ns := range namespaces.Items { + kotsApp, found := k.detectKotsInNamespace(ctx, ns.Name) + if found { + klog.Infof("Found KOTS application in namespace: %s", ns.Name) + kotsApps = append(kotsApps, kotsApp) + } + } + + klog.V(2).Infof("KOTS detection complete. Found %d applications", len(kotsApps)) + return kotsApps, nil +} + +// detectKotsInNamespace checks a specific namespace for KOTS applications +func (k *KotsDetector) detectKotsInNamespace(ctx context.Context, namespace string) (KotsApplication, bool) { + kotsApp := KotsApplication{ + Namespace: namespace, + } + found := false + + // Look for kotsadm deployments + deployments, err := k.client.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + klog.V(3).Infof("Could not list deployments in namespace %s: %v", namespace, err) + } else { + for _, deployment := range deployments.Items { + if k.isKotsadmDeployment(&deployment) { + klog.V(2).Infof("Found kotsadm deployment: %s/%s", namespace, deployment.Name) + kotsApp.KotsadmDeployment = &deployment + kotsApp.AppName = k.extractAppName(&deployment) + found = true + } + } + } + + // Look for kotsadm services + services, err := k.client.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + klog.V(3).Infof("Could not list services in namespace %s: %v", namespace, err) + } else { + for _, service := range services.Items { + if k.isKotsadmService(&service) { + klog.V(2).Infof("Found kotsadm service: %s/%s", namespace, service.Name) + kotsApp.KotsadmServices = append(kotsApp.KotsadmServices, service) + found = true + } + } + } + + // Look for replicated registry secrets + secrets, err := k.client.CoreV1().Secrets(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + klog.V(3).Infof("Could not list secrets in namespace %s: %v", namespace, err) + } else { + for _, secret := range secrets.Items { + if k.isReplicatedSecret(&secret) { + klog.V(2).Infof("Found replicated secret: %s/%s", namespace, secret.Name) + kotsApp.ReplicatedSecrets = append(kotsApp.ReplicatedSecrets, secret) + found = true + } + } + } + + // Look for KOTS-related ConfigMaps + configMaps, err := k.client.CoreV1().ConfigMaps(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + klog.V(3).Infof("Could not list configmaps in namespace %s: %v", namespace, err) + } else { + for _, cm := range configMaps.Items { + if k.isKotsConfigMap(&cm) { + klog.V(2).Infof("Found KOTS configmap: %s/%s", namespace, cm.Name) + kotsApp.ConfigMaps = append(kotsApp.ConfigMaps, cm) + found = true + } + } + } + + return kotsApp, found +} + +// isKotsadmDeployment checks if a deployment is a kotsadm deployment +func (k *KotsDetector) isKotsadmDeployment(deployment *appsv1.Deployment) bool { + // Check deployment name + name := deployment.Name + if name == "kotsadm" || name == "kotsadm-api" || name == "kotsadm-web" { + return true + } + + // Check labels + labels := deployment.Labels + if labels != nil { + if labels["app"] == "kotsadm" || labels["app.kubernetes.io/name"] == "kotsadm" { + return true + } + if labels["kots.io/kotsadm"] == "true" { + return true + } + } + + // Check container images + for _, container := range deployment.Spec.Template.Spec.Containers { + if k.isKotsadmImage(container.Image) { + return true + } + } + + return false +} + +// isKotsadmService checks if a service is related to kotsadm +func (k *KotsDetector) isKotsadmService(service *corev1.Service) bool { + // Check service name + name := service.Name + if name == "kotsadm" || name == "kotsadm-api" || name == "kotsadm-web" { + return true + } + + // Check labels + labels := service.Labels + if labels != nil { + if labels["app"] == "kotsadm" || labels["app.kubernetes.io/name"] == "kotsadm" { + return true + } + if labels["kots.io/kotsadm"] == "true" { + return true + } + } + + return false +} + +// isReplicatedSecret checks if a secret is related to Replicated/KOTS +func (k *KotsDetector) isReplicatedSecret(secret *corev1.Secret) bool { + name := secret.Name + + // Check for common replicated secret names + replicatedSecretNames := []string{ + "kotsadm-replicated-registry", + "replicated-registry", + "kotsadm-password", + "kotsadm-cluster-token", + "kotsadm-session", + "kotsadm-postgres", + "kotsadm-rqlite", + } + + for _, secretName := range replicatedSecretNames { + if name == secretName { + return true + } + } + + // Check labels + labels := secret.Labels + if labels != nil { + if labels["kots.io/kotsadm"] == "true" { + return true + } + if labels["app"] == "kotsadm" || labels["app.kubernetes.io/name"] == "kotsadm" { + return true + } + } + + // Check annotations + annotations := secret.Annotations + if annotations != nil { + if annotations["kots.io/secret-type"] != "" { + return true + } + } + + return false +} + +// isKotsConfigMap checks if a configmap is related to KOTS +func (k *KotsDetector) isKotsConfigMap(cm *corev1.ConfigMap) bool { + name := cm.Name + + // Check for common KOTS configmap names + kotsConfigMapNames := []string{ + "kotsadm-config", + "kotsadm-application-metadata", + "kotsadm-postgres", + } + + for _, cmName := range kotsConfigMapNames { + if name == cmName { + return true + } + } + + // Check labels + labels := cm.Labels + if labels != nil { + if labels["kots.io/kotsadm"] == "true" { + return true + } + if labels["app"] == "kotsadm" || labels["app.kubernetes.io/name"] == "kotsadm" { + return true + } + } + + return false +} + +// isKotsadmImage checks if a container image is a kotsadm image +func (k *KotsDetector) isKotsadmImage(image string) bool { + kotsadmImages := []string{ + "kotsadm/kotsadm", + "replicated/kotsadm", + "kotsadm-api", + "kotsadm-web", + } + + for _, kotsImage := range kotsadmImages { + // Check for exact match (handles cases like "kotsadm/kotsadm") + if image == kotsImage { + return true + } + + // Check if image contains the kots image as a proper component + // This handles private registries like "registry.company.com/kotsadm/kotsadm:v1.0.0" + if containsImageComponent(image, kotsImage) { + return true + } + } + + return false +} + +// containsImageComponent checks if an image path contains a component properly delimited +func containsImageComponent(image, component string) bool { + // Split image by '/' to get path components + imageParts := splitImagePath(image) + componentParts := splitImagePath(component) + + // For single component like "kotsadm-api", check if it appears as a repository name + if len(componentParts) == 1 { + for _, part := range imageParts { + // Remove tag/digest from the part + repoName := removeTagAndDigest(part) + if repoName == component { + return true + } + } + return false + } + + // For multi-component like "kotsadm/kotsadm", look for consecutive matches + if len(componentParts) <= len(imageParts) { + for i := 0; i <= len(imageParts)-len(componentParts); i++ { + match := true + for j := 0; j < len(componentParts); j++ { + imageRepo := removeTagAndDigest(imageParts[i+j]) + if imageRepo != componentParts[j] { + match = false + break + } + } + if match { + return true + } + } + } + + return false +} + +// splitImagePath splits an image path by '/' but preserves registry:port +func splitImagePath(image string) []string { + parts := []string{} + current := "" + + for i, char := range image { + if char == '/' { + if current != "" { + parts = append(parts, current) + current = "" + } + } else { + current += string(char) + } + + // Handle final part + if i == len(image)-1 && current != "" { + parts = append(parts, current) + } + } + + return parts +} + +// removeTagAndDigest removes :tag and @digest from image component +func removeTagAndDigest(component string) string { + // Remove tag (:tag) + for i := len(component) - 1; i >= 0; i-- { + if component[i] == ':' { + component = component[:i] + break + } + } + + // Remove digest (@sha256:...) + for i := len(component) - 1; i >= 0; i-- { + if component[i] == '@' { + component = component[:i] + break + } + } + + return component +} + +// extractAppName attempts to extract the application name from a kotsadm deployment +func (k *KotsDetector) extractAppName(deployment *appsv1.Deployment) string { + // Try to get app name from labels + if labels := deployment.Labels; labels != nil { + if appName := labels["kots.io/app"]; appName != "" { + return appName + } + if appName := labels["app.kubernetes.io/name"]; appName != "" && appName != "kotsadm" { + return appName + } + } + + // Try to get app name from annotations + if annotations := deployment.Annotations; annotations != nil { + if appName := annotations["kots.io/app-title"]; appName != "" { + return appName + } + } + + // Default to namespace name or "unknown" + if deployment.Namespace != "" && deployment.Namespace != "default" { + return deployment.Namespace + } + + return "kots-application" +} + +// GenerateKotsCollectors generates collectors specific to the detected KOTS applications +func (k *KotsDetector) GenerateKotsCollectors(kotsApps []KotsApplication) []CollectorSpec { + var collectors []CollectorSpec + + for _, kotsApp := range kotsApps { + klog.V(2).Infof("Generating KOTS collectors for application: %s in namespace: %s", kotsApp.AppName, kotsApp.Namespace) + + // Generate kotsadm deployment collector + if kotsApp.KotsadmDeployment != nil { + collectors = append(collectors, k.generateKotsadmDeploymentCollector(kotsApp)) + } + + // Generate kotsadm logs collector + collectors = append(collectors, k.generateKotsadmLogsCollector(kotsApp)) + + // Generate replicated secrets collector + for _, secret := range kotsApp.ReplicatedSecrets { + collectors = append(collectors, k.generateReplicatedSecretCollector(kotsApp, secret)) + } + + // Generate KOTS configmaps collector + for _, cm := range kotsApp.ConfigMaps { + collectors = append(collectors, k.generateKotsConfigMapCollector(kotsApp, cm)) + } + + // Generate KOTS directory structure collector + collectors = append(collectors, k.generateKotsDirectoryCollector(kotsApp)) + } + + klog.V(2).Infof("Generated %d KOTS-specific collectors", len(collectors)) + return collectors +} + +// generateKurlConfigMapCollectors creates collectors for KURL installation configmaps +func (k *KotsDetector) generateKurlConfigMapCollectors() []CollectorSpec { + var collectors []CollectorSpec + + // Standard KURL configmaps that should be checked for troubleshooting + kurlConfigMaps := []string{ + "kurl-current-config", + "kurl-last-config", + } + + for _, cmName := range kurlConfigMaps { + collectors = append(collectors, CollectorSpec{ + Type: CollectorTypeConfigMaps, + Name: fmt.Sprintf("kurl-configmap-%s", cmName), + Namespace: "kurl", + Spec: &troubleshootv1beta2.ConfigMap{ + CollectorMeta: troubleshootv1beta2.CollectorMeta{ + CollectorName: fmt.Sprintf("configmaps/kurl/%s", cmName), + }, + Name: cmName, + Namespace: "kurl", + IncludeAllData: true, + }, + Priority: 100, + Source: SourceKOTS, + }) + } + + return collectors +} + +// generateStandardReplicatedSecretCollector creates collector for replicated registry secret +func (k *KotsDetector) generateStandardReplicatedSecretCollector() CollectorSpec { + return CollectorSpec{ + Type: CollectorTypeSecrets, + Name: "standard-replicated-registry-secret", + Namespace: "", // Check all namespaces + Spec: &troubleshootv1beta2.Secret{ + CollectorMeta: troubleshootv1beta2.CollectorMeta{ + CollectorName: "secrets/kotsadm-replicated-registry", + }, + Name: "kotsadm-replicated-registry", + Namespace: "", // Will attempt in multiple namespaces + IncludeValue: false, + IncludeAllData: false, + }, + Priority: 100, + Source: SourceKOTS, + } +} + +// generateKotsHostPreflightCollector creates collector for KOTS host preflight results +func (k *KotsDetector) generateKotsHostPreflightCollector(ctx context.Context) CollectorSpec { + // Try to detect the cluster ID for host preflights + clusterID := k.detectClusterID(ctx) + + return CollectorSpec{ + Type: CollectorTypeData, + Name: "kots-host-preflights", + Namespace: "", + Spec: &troubleshootv1beta2.Data{ + CollectorMeta: troubleshootv1beta2.CollectorMeta{ + CollectorName: fmt.Sprintf("kots/kurl/host-preflights/%s", clusterID), + }, + Name: fmt.Sprintf("kots/kurl/host-preflights/%s/results.json", clusterID), + Data: fmt.Sprintf(`{ + "clusterID": "%s", + "type": "host-preflights", + "status": "checking", + "message": "Attempting to collect KOTS host preflight results" + }`, clusterID), + }, + Priority: 90, + Source: SourceKOTS, + } +} + +// detectClusterID attempts to detect the cluster ID for KOTS installations +func (k *KotsDetector) detectClusterID(ctx context.Context) string { + // Try to get cluster ID from node labels or annotations + nodes, err := k.client.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + klog.V(3).Infof("Could not list nodes to detect cluster ID: %v", err) + return "unknown" + } + + for _, node := range nodes.Items { + // Check for KURL cluster ID in labels + if labels := node.Labels; labels != nil { + if clusterID := labels["kurl.sh/cluster"]; clusterID != "" { + return clusterID + } + } + + // Check node name patterns (like your cluster f5ee12d1) + if len(node.Name) >= 8 && node.Name != "localhost" { + // Extract potential cluster ID from node name + return node.Name[:8] // First 8 chars usually contain cluster ID + } + } + + return "unknown" +} + +// GenerateStandardKotsCollectors generates collectors for standard KOTS resources that should always be checked +// This includes attempting to collect expected KOTS resources even if no active KOTS apps are detected +func (k *KotsDetector) GenerateStandardKotsCollectors(ctx context.Context) []CollectorSpec { + var collectors []CollectorSpec + + klog.V(2).Info("Generating standard KOTS resource collectors for troubleshooting") + + // Always attempt to collect standard KOTS/KURL resources for diagnostic purposes + // These will create error files if resources don't exist, which is valuable for troubleshooting + + // Generate KURL ConfigMap collectors (attempt collection even if not found) + collectors = append(collectors, k.generateKurlConfigMapCollectors()...) + + // Generate standard replicated registry secret collector (attempt even if not found) + collectors = append(collectors, k.generateStandardReplicatedSecretCollector()) + + // Generate KOTS host preflights collector + collectors = append(collectors, k.generateKotsHostPreflightCollector(ctx)) + + klog.V(2).Infof("Generated %d standard KOTS diagnostic collectors", len(collectors)) + return collectors +} + +// generateKotsadmDeploymentCollector creates a collector for kotsadm deployment info +func (k *KotsDetector) generateKotsadmDeploymentCollector(kotsApp KotsApplication) CollectorSpec { + return CollectorSpec{ + Type: CollectorTypeClusterResources, + Name: fmt.Sprintf("kots-deployment-%s", kotsApp.AppName), + Namespace: kotsApp.Namespace, + Spec: &troubleshootv1beta2.ClusterResources{ + CollectorMeta: troubleshootv1beta2.CollectorMeta{ + CollectorName: fmt.Sprintf("kots/%s/deployment", kotsApp.AppName), + }, + Namespaces: []string{kotsApp.Namespace}, + }, + Priority: 100, // High priority to ensure collection + Source: SourceKOTS, + } +} + +// generateKotsadmLogsCollector creates a collector for kotsadm pod logs +func (k *KotsDetector) generateKotsadmLogsCollector(kotsApp KotsApplication) CollectorSpec { + return CollectorSpec{ + Type: CollectorTypeLogs, + Name: fmt.Sprintf("kots-logs-%s", kotsApp.AppName), + Namespace: kotsApp.Namespace, + Spec: &troubleshootv1beta2.Logs{ + CollectorMeta: troubleshootv1beta2.CollectorMeta{ + CollectorName: fmt.Sprintf("kots/%s/logs", kotsApp.AppName), + }, + Selector: []string{"app=kotsadm", "kots.io/kotsadm=true"}, + Namespace: kotsApp.Namespace, + }, + Priority: 100, + Source: SourceKOTS, + } +} + +// generateReplicatedSecretCollector creates a collector for replicated registry secrets +func (k *KotsDetector) generateReplicatedSecretCollector(kotsApp KotsApplication, secret corev1.Secret) CollectorSpec { + return CollectorSpec{ + Type: CollectorTypeSecrets, + Name: fmt.Sprintf("kots-secret-%s-%s", kotsApp.AppName, secret.Name), + Namespace: kotsApp.Namespace, + Spec: &troubleshootv1beta2.Secret{ + CollectorMeta: troubleshootv1beta2.CollectorMeta{ + CollectorName: fmt.Sprintf("kots/%s/secrets/%s", kotsApp.AppName, secret.Name), + }, + Name: secret.Name, + Namespace: kotsApp.Namespace, + IncludeValue: false, // Security: only collect metadata + IncludeAllData: false, + }, + Priority: 100, + Source: SourceKOTS, + } +} + +// generateKotsConfigMapCollector creates a collector for KOTS configmaps +func (k *KotsDetector) generateKotsConfigMapCollector(kotsApp KotsApplication, cm corev1.ConfigMap) CollectorSpec { + return CollectorSpec{ + Type: CollectorTypeConfigMaps, + Name: fmt.Sprintf("kots-configmap-%s-%s", kotsApp.AppName, cm.Name), + Namespace: kotsApp.Namespace, + Spec: &troubleshootv1beta2.ConfigMap{ + CollectorMeta: troubleshootv1beta2.CollectorMeta{ + CollectorName: fmt.Sprintf("kots/%s/configmaps/%s", kotsApp.AppName, cm.Name), + }, + Name: cm.Name, + Namespace: kotsApp.Namespace, + IncludeAllData: true, // Include full configmap data for KOTS configs + }, + Priority: 100, + Source: SourceKOTS, + } +} + +// generateKotsDirectoryCollector creates a collector for KOTS directory structure +func (k *KotsDetector) generateKotsDirectoryCollector(kotsApp KotsApplication) CollectorSpec { + return CollectorSpec{ + Type: CollectorTypeData, + Name: fmt.Sprintf("kots-directory-%s", kotsApp.AppName), + Namespace: kotsApp.Namespace, + Spec: &troubleshootv1beta2.Data{ + CollectorMeta: troubleshootv1beta2.CollectorMeta{ + CollectorName: fmt.Sprintf("kots/%s/directory-info", kotsApp.AppName), + }, + Name: fmt.Sprintf("kots/%s/info.json", kotsApp.AppName), + Data: fmt.Sprintf(`{ + "kotsApp": "%s", + "namespace": "%s", + "detectedAt": "%s", + "hasDeployment": %t, + "secretCount": %d, + "configMapCount": %d, + "serviceCount": %d + }`, kotsApp.AppName, kotsApp.Namespace, "auto-detected", + kotsApp.KotsadmDeployment != nil, + len(kotsApp.ReplicatedSecrets), + len(kotsApp.ConfigMaps), + len(kotsApp.KotsadmServices)), + }, + Priority: 90, + Source: SourceKOTS, + } +} diff --git a/pkg/collect/autodiscovery/rbac_reporter.go b/pkg/collect/autodiscovery/rbac_reporter.go new file mode 100644 index 00000000..69747ae3 --- /dev/null +++ b/pkg/collect/autodiscovery/rbac_reporter.go @@ -0,0 +1,279 @@ +package autodiscovery + +import ( + "fmt" + "os" + "strings" + + "k8s.io/klog/v2" +) + +// RBACReporter handles reporting of RBAC permission issues to users +type RBACReporter struct { + warnings []string + filteredCollectors []CollectorSpec + permissionIssues []PermissionIssue +} + +// PermissionIssue represents a specific RBAC permission problem +type PermissionIssue struct { + Resource string + Namespace string + Verb string + Collector string + Reason string +} + +// NewRBACReporter creates a new RBAC reporter +func NewRBACReporter() *RBACReporter { + return &RBACReporter{ + warnings: make([]string, 0), + filteredCollectors: make([]CollectorSpec, 0), + permissionIssues: make([]PermissionIssue, 0), + } +} + +// ReportFilteredCollector reports that a collector was filtered due to RBAC permissions +func (r *RBACReporter) ReportFilteredCollector(collector CollectorSpec, reason string) { + warning := fmt.Sprintf("āš ļø Skipping %s: %s", collector.Name, reason) + r.warnings = append(r.warnings, warning) + r.filteredCollectors = append(r.filteredCollectors, collector) + + // Log the warning (visible to user in debug mode) + klog.Warningf("RBAC: %s", warning) + + // Also output to stderr so user sees it even without debug mode + fmt.Fprintf(os.Stderr, "%s\n", warning) + + // Track the specific permission issue + r.trackPermissionIssue(collector, reason) +} + +// ReportMissingPermission reports a specific missing permission +func (r *RBACReporter) ReportMissingPermission(resource, namespace, verb, collectorName string) { + var location string + if namespace != "" { + location = fmt.Sprintf("%s in namespace %s", resource, namespace) + } else { + location = fmt.Sprintf("cluster-wide %s", resource) + } + + warning := fmt.Sprintf("āš ļø Missing %s permission for %s (needed by %s collector)", verb, location, collectorName) + r.warnings = append(r.warnings, warning) + + // Log the warning + klog.Warningf("RBAC: %s", warning) + fmt.Fprintf(os.Stderr, "%s\n", warning) + + // Track this permission issue + issue := PermissionIssue{ + Resource: resource, + Namespace: namespace, + Verb: verb, + Collector: collectorName, + Reason: fmt.Sprintf("Missing %s permission", verb), + } + r.permissionIssues = append(r.permissionIssues, issue) +} + +// trackPermissionIssue extracts and tracks permission issue details +func (r *RBACReporter) trackPermissionIssue(collector CollectorSpec, reason string) { + issue := PermissionIssue{ + Collector: collector.Name, + Namespace: collector.Namespace, + Reason: reason, + } + + // Try to extract resource and verb from collector type + switch collector.Type { + case CollectorTypeConfigMaps: + issue.Resource = "configmaps" + issue.Verb = "get,list" + case CollectorTypeSecrets: + issue.Resource = "secrets" + issue.Verb = "get,list" + case CollectorTypeLogs: + issue.Resource = "pods" + issue.Verb = "get,list" + case CollectorTypeClusterResources: + issue.Resource = "nodes,namespaces" + issue.Verb = "get,list" + case CollectorTypeClusterInfo: + issue.Resource = "nodes" + issue.Verb = "get,list" + default: + issue.Resource = string(collector.Type) + issue.Verb = "get,list" + } + + r.permissionIssues = append(r.permissionIssues, issue) +} + +// HasWarnings returns true if any warnings were generated +func (r *RBACReporter) HasWarnings() bool { + return len(r.warnings) > 0 +} + +// GetWarningCount returns the number of warnings generated +func (r *RBACReporter) GetWarningCount() int { + return len(r.warnings) +} + +// GetFilteredCollectorCount returns the number of collectors that were filtered +func (r *RBACReporter) GetFilteredCollectorCount() int { + return len(r.filteredCollectors) +} + +// GeneratePermissionSummary generates a summary of permission issues +func (r *RBACReporter) GeneratePermissionSummary() { + if !r.HasWarnings() { + return + } + + fmt.Fprintf(os.Stderr, "\n") + fmt.Fprintf(os.Stderr, "šŸ”’ RBAC Permission Summary:\n") + fmt.Fprintf(os.Stderr, " • %d collectors were skipped due to insufficient permissions\n", len(r.filteredCollectors)) + fmt.Fprintf(os.Stderr, " • This may result in incomplete troubleshooting data\n") + fmt.Fprintf(os.Stderr, "\n") +} + +// GenerateRemediationReport generates actionable commands to fix permission issues +func (r *RBACReporter) GenerateRemediationReport() { + if !r.HasWarnings() { + return + } + + fmt.Fprintf(os.Stderr, "šŸ”§ To collect missing resources, grant the following permissions:\n\n") + + // Generate specific permission commands based on what was missing + clusterWideResources := []string{} + namespacedResources := []string{} + affectedNamespaces := make(map[string]bool) + + for _, issue := range r.permissionIssues { + if issue.Namespace != "" { + namespacedResources = append(namespacedResources, issue.Resource) + affectedNamespaces[issue.Namespace] = true + } else { + clusterWideResources = append(clusterWideResources, issue.Resource) + } + } + + // Remove duplicates + clusterWideResources = removeDuplicates(clusterWideResources) + namespacedResources = removeDuplicates(namespacedResources) + + // Generate cluster-wide permissions command + if len(clusterWideResources) > 0 { + fmt.Fprintf(os.Stderr, "# Grant cluster-wide permissions:\n") + fmt.Fprintf(os.Stderr, "kubectl create clusterrole troubleshoot-cluster-reader \\\n") + fmt.Fprintf(os.Stderr, " --verb=get,list \\\n") + fmt.Fprintf(os.Stderr, " --resource=%s\n\n", strings.Join(clusterWideResources, ",")) + + fmt.Fprintf(os.Stderr, "kubectl create clusterrolebinding troubleshoot-cluster-reader \\\n") + fmt.Fprintf(os.Stderr, " --clusterrole=troubleshoot-cluster-reader \\\n") + fmt.Fprintf(os.Stderr, " --user=$(kubectl config view --minify -o jsonpath='{.contexts[0].context.user}')\n\n") + } + + // Generate namespaced permissions command + if len(namespacedResources) > 0 { + fmt.Fprintf(os.Stderr, "# Grant namespaced permissions:\n") + fmt.Fprintf(os.Stderr, "kubectl create clusterrole troubleshoot-namespace-reader \\\n") + fmt.Fprintf(os.Stderr, " --verb=get,list \\\n") + fmt.Fprintf(os.Stderr, " --resource=%s\n\n", strings.Join(namespacedResources, ",")) + + fmt.Fprintf(os.Stderr, "kubectl create clusterrolebinding troubleshoot-namespace-reader \\\n") + fmt.Fprintf(os.Stderr, " --clusterrole=troubleshoot-namespace-reader \\\n") + fmt.Fprintf(os.Stderr, " --user=$(kubectl config view --minify -o jsonpath='{.contexts[0].context.user}')\n\n") + } + + // Alternative: Single comprehensive role + fmt.Fprintf(os.Stderr, "# Or create a comprehensive troubleshoot role:\n") + fmt.Fprintf(os.Stderr, "kubectl create clusterrole troubleshoot-comprehensive \\\n") + fmt.Fprintf(os.Stderr, " --verb=get,list \\\n") + fmt.Fprintf(os.Stderr, " --resource=configmaps,secrets,pods,services,deployments,statefulsets,daemonsets,events,namespaces,nodes\n\n") + + fmt.Fprintf(os.Stderr, "kubectl create clusterrolebinding troubleshoot-comprehensive \\\n") + fmt.Fprintf(os.Stderr, " --clusterrole=troubleshoot-comprehensive \\\n") + fmt.Fprintf(os.Stderr, " --user=$(kubectl config view --minify -o jsonpath='{.contexts[0].context.user}')\n\n") + + // Provide alternative with service account + fmt.Fprintf(os.Stderr, "# Alternative: Use current context user\n") + fmt.Fprintf(os.Stderr, "CURRENT_USER=$(kubectl config current-context)\n") + fmt.Fprintf(os.Stderr, "kubectl create clusterrolebinding troubleshoot-current-user \\\n") + fmt.Fprintf(os.Stderr, " --clusterrole=troubleshoot-comprehensive \\\n") + fmt.Fprintf(os.Stderr, " --user=$CURRENT_USER\n\n") + + fmt.Fprintf(os.Stderr, "šŸ’” After granting permissions, re-run the support bundle collection.\n") + fmt.Fprintf(os.Stderr, "\n") +} + +// GenerateDebugInfo generates detailed debug information about RBAC filtering +func (r *RBACReporter) GenerateDebugInfo() { + if !r.HasWarnings() { + klog.V(2).Info("RBAC: No permission issues detected") + return + } + + klog.V(2).Infof("RBAC: Generated %d warnings for permission issues", len(r.warnings)) + klog.V(2).Infof("RBAC: Filtered %d collectors due to permissions", len(r.filteredCollectors)) + + for _, issue := range r.permissionIssues { + klog.V(3).Infof("RBAC Issue: %s collector needs %s permission for %s in namespace %s", + issue.Collector, issue.Verb, issue.Resource, issue.Namespace) + } +} + +// Reset clears all warnings and tracked issues (useful for testing) +func (r *RBACReporter) Reset() { + r.warnings = make([]string, 0) + r.filteredCollectors = make([]CollectorSpec, 0) + r.permissionIssues = make([]PermissionIssue, 0) +} + +// GetFilteredCollectors returns the list of collectors that were filtered +func (r *RBACReporter) GetFilteredCollectors() []CollectorSpec { + return r.filteredCollectors +} + +// GetPermissionIssues returns the list of permission issues +func (r *RBACReporter) GetPermissionIssues() []PermissionIssue { + return r.permissionIssues +} + +// removeDuplicates removes duplicate strings from a slice +func removeDuplicates(slice []string) []string { + keys := make(map[string]bool) + var result []string + + for _, item := range slice { + if !keys[item] { + keys[item] = true + result = append(result, item) + } + } + + return result +} + +// SummarizeCollectionResults provides a final summary of what was collected vs. what was skipped +func (r *RBACReporter) SummarizeCollectionResults(totalCollectors int) { + collectedCount := totalCollectors - len(r.filteredCollectors) + + if len(r.filteredCollectors) > 0 { + fmt.Fprintf(os.Stderr, "\nšŸ“Š Collection Summary:\n") + fmt.Fprintf(os.Stderr, " āœ… Successfully collected: %d collectors\n", collectedCount) + fmt.Fprintf(os.Stderr, " āš ļø Skipped due to permissions: %d collectors\n", len(r.filteredCollectors)) + fmt.Fprintf(os.Stderr, " šŸ“Š Completion rate: %.1f%%\n", float64(collectedCount)/float64(totalCollectors)*100) + + if len(r.filteredCollectors) > 0 { + fmt.Fprintf(os.Stderr, "\n Missing collectors:\n") + for _, collector := range r.filteredCollectors { + fmt.Fprintf(os.Stderr, " • %s (%s)\n", collector.Name, collector.Type) + } + } + fmt.Fprintf(os.Stderr, "\n") + } else { + klog.V(2).Infof("RBAC: All %d collectors collected successfully", totalCollectors) + } +} diff --git a/pkg/preflight/template_test.go b/pkg/preflight/template_test.go index 945ce7f8..b2a689ff 100644 --- a/pkg/preflight/template_test.go +++ b/pkg/preflight/template_test.go @@ -341,6 +341,10 @@ func createTempFile(t *testing.T, content string, filename string) string { // repoPath returns a path relative to the repository root from within pkg/preflight tests func repoPath(rel string) string { + if rel == "v1beta3.yaml" { + // Use an existing v1beta3 example file for testing + return filepath.Join("..", "..", "examples", "preflight", "simple-v1beta3.yaml") + } return filepath.Join("..", "..", rel) }