Repository: cloudnative-pg/cloudnative-pg

Issue:
[Bug]: kubectl cnpg report cluster --logs fails to gather
### Is there an existing issue already for this bug?

- [x] I have searched for an existing issue, and could not find anything. I believe this is a new bug.

### I have read the troubleshooting guide

- [x] I have read the troubleshooting guide and I think this is a new bug.

### I am running a supported version of CloudNativePG

- [x] I have read the troubleshooting guide and I think this is a new bug.

### Contact Details

_No response_

### Version

1.27 (latest patch), 1.26 (latest patch), 1.25 (latest patch), trunk (main)

### What version of Kubernetes are you using?

1.34

### What is your Kubernetes environment?

Self-managed: kind (evaluation)

### How did you install the operator?

YAML manifest

### What happened?

When running `kubectl cnpg report cluster --logs cluster-example`, the logs gathering always fails with the error:

```
Error: could not write report: when opening the log stream: previous terminated container "postgres" in pod "cluster-example-1" not found
```

This occurs in a perfectly healthy cluster (see the status output below), where I have cluster admin permissions. The issue occurs on all supported plugin versions (1.25.x, 1.26.x, 1.27.x) as well as main branch builds.

The resulting report contains no logs.

### Cluster resource

```shell

```

### Relevant log output

```shell
$ kubectl cnpg report cluster --logs cluster-example
Error: could not write report: when opening the log stream: previous terminated container "postgres" in pod "cluster-example-1" not found

$ kubectl cnpg status cluster-example
Cluster Summary
Name                     default/cluster-example
System ID:               7566247159144882211
PostgreSQL Image:        ghcr.io/cloudnative-pg/postgresql:18.0-system-trixie
Primary instance:        cluster-example-1
Primary promotion time:  2025-10-28 12:23:45 +0000 UTC (4h20m38s)
Status:                  Cluster in a healthy state 
Instances:               3
Ready instances:         3
Size:                    144M
Current Write LSN:       0/8000000 (Timeline: 1 - WAL File: 000000010000000000000008)

Continuous Backup status (Barman Cloud Plugin)
ObjectStore / Server name:      minio-store/cluster-example
First Point of Recoverability:  -
Last Successful Backup:         -
Last Failed Backup:             -
Working WAL archiving:          OK
WALs waiting to be archived:    0
Last Archived WAL:              000000010000000000000007   @   2025-10-28T12:35:19.649263Z
Last Failed WAL:                -

Streaming Replication status
Replication Slots Enabled
Name               Sent LSN   Write LSN  Flush LSN  Replay LSN  Write Lag  Flush Lag  Replay Lag  State      Sync State  Sync Priority  Replication Slot
----               --------   ---------  ---------  ----------  ---------  ---------  ----------  -----      ----------  -------------  ----------------
cluster-example-2  0/8000000  0/8000000  0/8000000  0/8000000   00:00:00   00:00:00   00:00:00    streaming  async       0              active
cluster-example-3  0/8000000  0/8000000  0/8000000  0/8000000   00:00:00   00:00:00   00:00:00    streaming  async       0              active

Instances status
Name               Current LSN  Replication role  Status  QoS         Manager Version  Node
----               -----------  ----------------  ------  ---         ---------------  ----
cluster-example-1  0/8000000    Primary           OK      BestEffort  1.27.1           pg-operator-e2e-v1-34-0-worker
cluster-example-2  0/8000000    Standby (async)   OK      BestEffort  1.27.1           pg-operator-e2e-v1-34-0-worker2
cluster-example-3  0/8000000    Standby (async)   OK      BestEffort  1.27.1           pg-operator-e2e-v1-34-0-worker3

Plugins status
Name                            Version  Status  Reported Operator Capabilities
----                            -------  ------  ------------------------------
barman-cloud.cloudnative-pg.io  0.8.0    N/A     Reconciler Hooks, Lifecycle Service

$ unzip report_cluster_cluster-example_2025-10-28T16\:54\:33Z.zip 
Archive:  report_cluster_cluster-example_2025-10-28T16:54:33Z.zip
   creating: report_cluster_cluster-example_2025-10-28T16:54:33Z/
   creating: report_cluster_cluster-example_2025-10-28T16:54:33Z/manifests/
  inflating: report_cluster_cluster-example_2025-10-28T16:54:33Z/manifests/cluster.yaml  
  inflating: report_cluster_cluster-example_2025-10-28T16:54:33Z/manifests/cluster-pods.yaml  
  inflating: report_cluster_cluster-example_2025-10-28T16:54:33Z/manifests/cluster-jobs.yaml  
  inflating: report_cluster_cluster-example_2025-10-28T16:54:33Z/manifests/events.yaml  
  inflating: report_cluster_cluster-example_2025-10-28T16:54:33Z/manifests/cluster-pvcs.yaml  
   creating: report_cluster_cluster-example_2025-10-28T16:54:33Z/logs/
  inflating: report_cluster_cluster-example_2025-10-28T16:54:33Z/logs/cluster-example-1-postgres.jsonl  

$ cat report_cluster_cluster-example_2025-10-28T16:54:33Z/logs/cluster-example-1-postgres.jsonl
"====== Beginning of Previous Log ====="
"Error fetching previous logs: when opening the log stream: previous terminated container \"postgres\" in pod \"cluster-example-1\" not found"
"====== End of Previous Log ====="
```

### Code of Conduct

- [x] I agree to follow this project's Code of Conduct

Hints from issue comments:
@dosubot: <!-- Answer -->
This error is expected when the plugin tries to fetch "previous" logs for a container that hasn't restarted—Kubernetes only provides previous logs if the container has terminated and restarted at least once. In a healthy cluster with no restarts, you'll see this message and the logs section will be empty, but this does not indicate a failure in the cluster or the plugin's logic. The documentation confirms this behavior and does not list it as a bug or provide a workaround; it's a limitation of how Kubernetes exposes logs and how the plugin retrieves them ([docs](https://cloudnative-pg.io/documentation/current/kubectl-plugin/)).

There are related issues about log gathering in multi-container pods (e.g., with sidecars), where the plugin may fail to retrieve logs if it doesn't enforce the container name or handle multiple containers properly ([issue #6632](https://github.com/cloudnative-pg/cloudnative-pg/issues/6632), [issue #5905](https://github.com/cloudnative-pg/cloudnative-pg/issues/5905)). However, for your scenario with a single healthy container, the absence of previous logs is normal.

If you need to investigate logs after a pod restart, you'll see previous logs only if the container has actually terminated and restarted. Otherwise, the report will show the error message you encountered, which is safe to ignore for healthy clusters.


<!-- Dosu Comment Footer -->
*To reply, just mention [@dosu](https://go.dosu.dev/dosubot).*

---
<sup>How did I do? [Good](https://app.dosu.dev/response-feedback/db2c1e2c-d924-47b8-84ba-8a38df01860b?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/db2c1e2c-d924-47b8-84ba-8a38df01860b?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/db2c1e2c-d924-47b8-84ba-8a38df01860b?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/db2c1e2c-d924-47b8-84ba-8a38df01860b?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/db2c1e2c-d924-47b8-84ba-8a38df01860b?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/db2c1e2c-d924-47b8-84ba-8a38df01860b?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/db2c1e2c-d924-47b8-84ba-8a38df01860b?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/89b818f3-8b5e-449b-bfb3-64a9360d9665/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/cloudnative-pg/cloudnative-pg/issues/8985)

Solution patch:
diff --git a/docs/src/kubectl-plugin.md b/docs/src/kubectl-plugin.md
index a06db070..c3166775 100644
--- a/docs/src/kubectl-plugin.md
+++ b/docs/src/kubectl-plugin.md
@@ -143,20 +143,20 @@ brew upgrade kubectl-cnpg
 CloudNativePG Plugin is currently built for the following
 operating system and architectures:
 
-* Linux
-    * amd64
-    * arm 5/6/7
-    * arm64
-    * s390x
-    * ppc64le
-* macOS
-    * amd64
-    * arm64
-* Windows
-    * 386
-    * amd64
-    * arm 5/6/7
-    * arm64
+- Linux
+  - amd64
+  - arm 5/6/7
+  - arm64
+  - s390x
+  - ppc64le
+- macOS
+  - amd64
+  - arm64
+- Windows
+  - 386
+  - amd64
+  - arm 5/6/7
+  - arm64
 
 ### Configuring auto-completion
 
@@ -245,14 +245,14 @@ The flags in the above command have the following meaning:
 The `status` command provides an overview of the current status of your
 cluster, including:
 
-* **general information**: name of the cluster, PostgreSQL's system ID, number of
+- **general information**: name of the cluster, PostgreSQL's system ID, number of
   instances, current timeline and position in the WAL
-* **backup**: point of recoverability, and WAL archiving status as returned by
+- **backup**: point of recoverability, and WAL archiving status as returned by
   the `pg_stat_archiver` view from the primary - or designated primary in the
   case of a replica cluster
-* **streaming replication**: information taken directly from the `pg_stat_replication`
+- **streaming replication**: information taken directly from the `pg_stat_replication`
   view on the primary instance
-* **instances**: information about each Postgres instance, taken directly by each
+- **instances**: information about each Postgres instance, taken directly by each
   instance manager; in the case of a standby, the `Current LSN` field corresponds
   to the latest write-ahead log location that has been replayed during recovery
   (replay LSN).
@@ -446,8 +446,8 @@ The `kubectl cnpg maintenance` command helps to modify one or more clusters
 across namespaces and set the maintenance window values, it will change
 the following fields:
 
-* .spec.nodeMaintenanceWindow.inProgress
-* .spec.nodeMaintenanceWindow.reusePVC
+- `.spec.nodeMaintenanceWindow.inProgress`
+- `.spec.nodeMaintenanceWindow.reusePVC`
 
 Accepts as argument `set` and `unset` using this to set the
 `inProgress` to `true` in case `set`and to `false` in case of `unset`.
@@ -501,23 +501,59 @@ regarding the operator deployment, configuration and events.
     By default, operator logs are not collected, but you can enable operator
     log collection with the `--logs` flag
 
-* **deployment information**: the operator Deployment and operator Pod
-* **configuration**: the Secrets and ConfigMaps in the operator namespace
-* **events**: the Events in the operator namespace
-* **webhook configuration**: the mutating and validating webhook configurations
-* **webhook service**: the webhook service
-* **logs**: logs for the operator Pod (optional, off by default) in JSON-lines format
+!!! Important "Least-Privilege Support"
+    The `report operator` command works with minimal permissions. Only the operator
+    deployment is **required** - all other resources are optional and collected on a
+    best-effort basis. If you lack permissions for certain resources (e.g., webhooks,
+    OLM resources), warnings are logged and report generation continues with available
+    data.
+
+The report includes:
+
+- **deployment information** (required): the operator `Deployment`
+- **operator pods** (optional): the operator `Pod` information
+- **configuration** (optional): `Secrets` and `ConfigMaps` in the operator
+  namespace
+- **events** (optional): `Events` in the operator namespace
+- **webhook configuration** (optional): mutating and validating webhook
+  configurations (cluster-scoped)
+- **webhook service** (optional): the webhook service
+- **OLM resources** (optional): subscriptions, cluster service versions,
+  install plans (if OLM is installed)
+- **logs** (optional): operator `Pod` logs in JSON-lines format (requires
+  `--logs` flag)
+
+!!! Warning "Minimal permissions"
+    Read access (`get`) to the operator deployment in the operator namespace.
+    This allows namespace-scoped users to generate basic troubleshooting reports.
+
+!!! Important "Recommended permissions for full report"
+    Add `list` on pods, events; `get` on secrets, configmaps, services
+    (namespace-scoped); and `list` on webhook configurations (cluster-scoped).
+
+A user with only namespace-scoped permissions can still generate useful
+reports:
+
+```sh
+# With only deployment read access
+kubectl cnpg report operator -n cnpg-system -f report.zip
+```
+
+!!! Info
+    The command will log warnings for inaccessible resources but successfully
+    generate a report with the deployment manifest, which is often sufficient for
+    basic troubleshooting.
 
 The command will generate a ZIP file containing various manifest in YAML format
-(by default, but settable to JSON with the `-o` flag).
-Use the `-f` flag to name a result file explicitly. If the `-f` flag is not used, a
-default time-stamped filename is created for the zip file.
+(by default, but settable to JSON with the `-o` flag). Use the `-f` flag to
+name a result file explicitly. If the `-f` flag is not used, a default
+time-stamped filename is created for the zip file.
 
 !!! Note
-    The report plugin obeys `kubectl` conventions, and will look for objects constrained
-    by namespace. The CNPG Operator will generally not be installed in the same
-    namespace as the clusters.
-    E.g. the default installation namespace is cnpg-system
+    The report plugin obeys `kubectl` conventions, and will look for objects
+    constrained by namespace. The CNPG Operator will generally not be installed in
+    the same namespace as the clusters. E.g. the default installation namespace is
+    cnpg-system.
 
 ```sh
 kubectl cnpg report operator -n cnpg-system
@@ -639,12 +675,12 @@ metadata:
 
 The `cluster` sub-command gathers the following:
 
-* **cluster resources**: the cluster information, same as `kubectl get cluster -o yaml`
-* **cluster pods**: pods in the cluster namespace matching the cluster name
-* **cluster jobs**: jobs, if any, in the cluster namespace matching the cluster name
-* **events**: events in the cluster namespace
-* **pod logs**: logs for the cluster Pods (optional, off by default) in JSON-lines format
-* **job logs**: logs for the Pods created by jobs (optional, off by default) in JSON-lines format
+- **cluster resources**: the cluster information, same as `kubectl get cluster -o yaml`
+- **cluster pods**: pods in the cluster namespace matching the cluster name
+- **cluster jobs**: jobs, if any, in the cluster namespace matching the cluster name
+- **events**: events in the cluster namespace
+- **pod logs**: logs for the cluster Pods (optional, off by default) in JSON-lines format
+- **job logs**: logs for the Pods created by jobs (optional, off by default) in JSON-lines format
 
 The `cluster` sub-command accepts the `-f` and `-o` flags, as the `operator` does.
 If the `-f` flag is not used, a default timestamped report name will be used.
@@ -1397,7 +1433,7 @@ table contains the full details:
 | publication     | clusters: get<br/>pods: get,list<br/>pods/exec: create                                                                                                                                                                                                                                                                                                |
 | reload          | clusters: get,patch                                                                                                                                                                                                                                                                                                                                   |
 | report cluster  | clusters: get<br/>pods: list<br/>pods/log: get<br/>jobs: list<br/>events: list<br/>PVCs: list                                                                                                                                                                                                                                                         |
-| report operator | configmaps: get<br/>deployments: get<br/>events: list<br/>pods: list<br/>pods/log: get<br/>secrets: get<br/>services: get<br/>mutatingwebhookconfigurations: list[^1]<br/> validatingwebhookconfigurations: list[^1]<br/> If OLM is present on the K8s cluster, also:<br/>clusterserviceversions: list<br/>installplans: list<br/>subscriptions: list |
+| report operator | **Required:**<br/>deployments: get<br/>**Optional (for full report):**<br/>configmaps: get<br/>events: list<br/>pods: list<br/>pods/log: get<br/>secrets: get<br/>services: get<br/>mutatingwebhookconfigurations: list[^1]<br/>validatingwebhookconfigurations: list[^1]<br/>**If OLM is present:**<br/>clusterserviceversions: list[^1]<br/>installplans: list[^1]<br/>subscriptions: list[^1] |
 | restart         | clusters: get,patch<br/>pods: get,delete                                                                                                                                                                                                                                                                                                              |
 | status          | clusters: get<br/>pods: list<br/>pods/exec: create<br/>pods/proxy: create<br/>PDBs: list<br/>objectstores.barmancloud.cnpg.io: get                                                                                                                                                                                                                    |
 | subscription    | clusters: get<br/>pods: get,list<br/>pods/exec: create                                                                                                                                                                                                                                                                                                |
diff --git a/internal/cmd/plugin/report/operator_report.go b/internal/cmd/plugin/report/operator_report.go
index 4c684ac3..a6a7480b 100644
--- a/internal/cmd/plugin/report/operator_report.go
+++ b/internal/cmd/plugin/report/operator_report.go
@@ -52,38 +52,69 @@ type operatorReport struct {
 // writeToZip makes a new section in the ZIP file, and adds in it various
 // Kubernetes object manifests
 func (or operatorReport) writeToZip(zipper *zip.Writer, format plugin.OutputFormat, folder string) error {
-	singleObjects := []struct {
-		content interface{}
-		name    string
-	}{
-		{content: or.deployment, name: "deployment"},
-		{content: or.operatorPods, name: "operator-pods"},
-		{content: or.events, name: "events"},
-		{content: or.validatingWebhookConfig, name: "validating-webhook-configuration"},
-		{content: or.mutatingWebhookConfig, name: "mutating-webhook-configuration"},
-		{content: or.webhookService, name: "webhook-service"},
-	}
-
 	newFolder := filepath.Join(folder, "manifests")
 	_, err := zipper.Create(newFolder + "/")
 	if err != nil {
 		return err
 	}
 
-	for _, object := range singleObjects {
-		err := addContentToZip(object.content, object.name, newFolder, format, zipper)
+	// Always include deployment (required resource)
+	if err := addContentToZip(or.deployment, "deployment", newFolder, format, zipper); err != nil {
+		return err
+	}
+
+	// Include pods only if collected
+	if len(or.operatorPods) > 0 {
+		if err := addContentToZip(or.operatorPods, "operator-pods", newFolder, format, zipper); err != nil {
+			return err
+		}
+	}
+
+	// Include events only if collected
+	if len(or.events.Items) > 0 {
+		if err := addContentToZip(or.events, "events", newFolder, format, zipper); err != nil {
+			return err
+		}
+	}
+
+	// Include validating webhook config only if collected
+	if or.validatingWebhookConfig != nil && len(or.validatingWebhookConfig.Items) > 0 {
+		err := addContentToZip(or.validatingWebhookConfig, "validating-webhook-configuration",
+			newFolder, format, zipper)
 		if err != nil {
 			return err
 		}
 	}
 
-	multiObjects := [][]namedObject{or.configs, or.secrets}
-	for _, obj := range multiObjects {
-		err := addObjectsToZip(obj, newFolder, format, zipper)
+	// Include mutating webhook config only if collected
+	if or.mutatingWebhookConfig != nil && len(or.mutatingWebhookConfig.Items) > 0 {
+		err := addContentToZip(or.mutatingWebhookConfig, "mutating-webhook-configuration",
+			newFolder, format, zipper)
 		if err != nil {
 			return err
 		}
 	}
+
+	// Include webhook service only if collected (check for non-empty name)
+	if or.webhookService.Name != "" {
+		if err := addContentToZip(or.webhookService, "webhook-service", newFolder, format, zipper); err != nil {
+			return err
+		}
+	}
+
+	// Include configs and secrets only if collected
+	if len(or.configs) > 0 {
+		if err := addObjectsToZip(or.configs, newFolder, format, zipper); err != nil {
+			return err
+		}
+	}
+
+	if len(or.secrets) > 0 {
+		if err := addObjectsToZip(or.secrets, newFolder, format, zipper); err != nil {
+			return err
+		}
+	}
+
 	return nil
 }
 
@@ -95,117 +126,232 @@ func (or operatorReport) writeToZip(zipper *zip.Writer, format plugin.OutputForm
 //   - events in the operator namespace
 //   - operator's Validating/MutatingWebhookConfiguration and their associated services
 //   - operator pod's logs (if `includeLogs` is true)
+//
+// operator implements the "report operator" subcommand
+// Produces a zip file containing
+//   - operator deployment (required - cannot generate report without identifying the operator)
+//   - operator pod definition (optional - gracefully skipped if no permissions)
+//   - operator configuration Configmap and Secret key (optional - gracefully skipped if no permissions)
+//   - events in the operator namespace (optional - gracefully skipped if no permissions)
+//   - operator's Validating/MutatingWebhookConfiguration and their associated services (optional)
+//   - operator pod's logs (optional, if `includeLogs` is true)
 func operator(ctx context.Context, format plugin.OutputFormat,
 	file string, stopRedaction, includeLogs, logTimeStamp bool, now time.Time,
 ) error {
-	secretRedactor := redactSecret
-	configMapRedactor := redactConfigMap
-	if stopRedaction {
-		secretRedactor = passSecret
-		configMapRedactor = passConfigMap
-		fmt.Println("WARNING: secret Redaction is OFF. Use it with caution")
-	}
+	// Configure redactors
+	secretRedactor, configMapRedactor := configureRedactors(stopRedaction)
 
-	operatorDeployment, err := getOperatorDeployment(ctx)
+	// Collect deployment (the only truly required resource)
+	deployment, err := getOperatorDeployment(ctx)
 	if errors.Is(err, errNoOperatorDeployment) {
-		// Try to be helpful to the user
 		return fmt.Errorf("%w\n"+
 			"HINT: Operator might be installed in another namespace."+
 			"Specify a namespace using the '-n' option", err)
-	} else if err != nil {
+	}
+	if err != nil {
 		return fmt.Errorf("could not get operator deployment: %w", err)
 	}
 
-	operatorPods, err := getOperatorPods(ctx)
-	if err != nil {
-		return fmt.Errorf("could not get operator pod: %w", err)
+	// Collect all optional resources (all can fail gracefully)
+	pods := tryCollectPods(ctx)
+	secrets, configs := tryCollectConfigurations(ctx, deployment, secretRedactor, configMapRedactor)
+	events := tryCollectEvents(ctx, deployment.Namespace)
+	mutatingWebhook, validatingWebhook := tryCollectWebhooks(ctx, stopRedaction)
+	webhookService := tryCollectWebhookService(ctx, mutatingWebhook)
+
+	// Build the report structure
+	rep := operatorReport{
+		deployment:              deployment,
+		operatorPods:            pods,
+		secrets:                 secrets,
+		configs:                 configs,
+		events:                  events,
+		mutatingWebhookConfig:   mutatingWebhook,
+		validatingWebhookConfig: validatingWebhook,
+		webhookService:          webhookService,
 	}
 
-	operatorSecrets, err := getOperatorSecrets(ctx, operatorDeployment)
-	if err != nil {
-		return fmt.Errorf("could not get operator secrets: %w", err)
+	// Assemble all sections for the ZIP file
+	sections := assembleSections(ctx, rep, pods, format, includeLogs, logTimeStamp)
+
+	// Write the final report
+	if err := writeZippedReport(sections, file, reportName("operator", now)); err != nil {
+		return fmt.Errorf("could not write report: %w", err)
 	}
-	secrets := make([]namedObject, 0, len(operatorSecrets))
-	for _, ss := range operatorSecrets {
-		secrets = append(secrets, namedObject{Name: ss.Name + "(secret)", Object: secretRedactor(ss)})
+
+	fmt.Printf("Successfully written report to \"%s\" (format: \"%s\")\n", file, format)
+	return nil
+}
+
+// configureRedactors sets up the appropriate redaction functions based on user preference
+func configureRedactors(stopRedaction bool) (
+	func(corev1.Secret) corev1.Secret, func(corev1.ConfigMap) corev1.ConfigMap,
+) {
+	if stopRedaction {
+		fmt.Println("WARNING: secret Redaction is OFF. Use it with caution")
+		return passSecret, passConfigMap
 	}
+	return redactSecret, redactConfigMap
+}
 
-	operatorConfigMaps, err := getOperatorConfigMaps(ctx, operatorDeployment)
+// tryCollectPods attempts to collect operator pods, logging warnings on failure
+func tryCollectPods(ctx context.Context) []corev1.Pod {
+	pods, err := getOperatorPods(ctx)
 	if err != nil {
-		return fmt.Errorf("could not get operator configmap: %w", err)
+		logWarning("could not get operator pods", err,
+			"Continuing without pod information. This is expected if you don't have permissions to list pods.")
+		return nil
 	}
-	configs := make([]namedObject, 0, len(operatorConfigMaps))
-	for _, cm := range operatorConfigMaps {
-		configs = append(configs, namedObject{Name: cm.Name, Object: configMapRedactor(cm)})
+	return pods
+}
+
+// tryCollectConfigurations attempts to collect secrets and configmaps, logging warnings on failure
+func tryCollectConfigurations(
+	ctx context.Context,
+	deployment appsv1.Deployment,
+	secretRedactor func(corev1.Secret) corev1.Secret,
+	configMapRedactor func(corev1.ConfigMap) corev1.ConfigMap,
+) ([]namedObject, []namedObject) {
+	var secrets []namedObject
+	var configs []namedObject
+
+	// Try to collect secrets
+	operatorSecrets, err := getOperatorSecrets(ctx, deployment)
+	if err != nil {
+		logWarning("could not get operator secrets", err,
+			"Continuing without secrets information. This is expected if you don't have permissions to list secrets.")
+	} else {
+		secrets = make([]namedObject, 0, len(operatorSecrets))
+		for _, ss := range operatorSecrets {
+			secrets = append(secrets, namedObject{
+				Name:   ss.Name + "(secret)",
+				Object: secretRedactor(ss),
+			})
+		}
 	}
 
+	// Try to collect configmaps
+	operatorConfigMaps, err := getOperatorConfigMaps(ctx, deployment)
+	if err != nil {
+		logWarning("could not get operator configmaps", err,
+			"Continuing without configmap information. This is expected if you don't have permissions to list configmaps.")
+	} else {
+		configs = make([]namedObject, 0, len(operatorConfigMaps))
+		for _, cm := range operatorConfigMaps {
+			configs = append(configs, namedObject{
+				Name:   cm.Name,
+				Object: configMapRedactor(cm),
+			})
+		}
+	}
+
+	return secrets, configs
+}
+
+// tryCollectEvents attempts to collect events, logging warnings on failure
+func tryCollectEvents(ctx context.Context, namespace string) corev1.EventList {
 	var events corev1.EventList
-	err = plugin.Client.List(ctx, &events, client.InNamespace(operatorPods[0].Namespace))
+	err := plugin.Client.List(ctx, &events, client.InNamespace(namespace))
 	if err != nil {
-		return fmt.Errorf("could not get events: %w", err)
+		logWarning("could not get events", err,
+			"Continuing without events information. This is expected if you don't have permissions to list events.")
+		return corev1.EventList{}
 	}
+	return events
+}
 
+// tryCollectWebhooks attempts to collect webhook configurations, logging warnings on failure
+func tryCollectWebhooks(
+	ctx context.Context,
+	stopRedaction bool,
+) (*admissionregistrationv1.MutatingWebhookConfigurationList,
+	*admissionregistrationv1.ValidatingWebhookConfigurationList,
+) {
 	mutatingWebhook, validatingWebhook, err := getWebhooks(ctx, stopRedaction)
 	if err != nil {
-		return fmt.Errorf("could not get webhooks: %w", err)
+		logWarning("could not get webhooks", err,
+			"Continuing without webhook information. This is expected if you don't have cluster-level permissions.")
 	}
+	return mutatingWebhook, validatingWebhook
+}
 
+// tryCollectWebhookService attempts to collect the webhook service, logging warnings on failure
+func tryCollectWebhookService(
+	ctx context.Context,
+	mutatingWebhook *admissionregistrationv1.MutatingWebhookConfigurationList,
+) corev1.Service {
 	webhookService, err := getWebhookService(ctx, mutatingWebhook)
 	if err != nil {
-		return fmt.Errorf("could not get webhook service: %w", err)
+		logWarning("could not get webhook service", err,
+			"Continuing without webhook service information.")
 	}
+	return webhookService
+}
 
-	rep := operatorReport{
-		deployment:              operatorDeployment,
-		operatorPods:            operatorPods,
-		secrets:                 secrets,
-		configs:                 configs,
-		events:                  events,
-		mutatingWebhookConfig:   mutatingWebhook,
-		validatingWebhookConfig: validatingWebhook,
-		webhookService:          webhookService,
+// assembleSections creates all ZIP file sections including manifests, logs, and OLM data
+func assembleSections(
+	ctx context.Context,
+	rep operatorReport,
+	pods []corev1.Pod,
+	format plugin.OutputFormat,
+	includeLogs bool,
+	logTimeStamp bool,
+) []zipFileWriter {
+	// Main report section
+	sections := []zipFileWriter{
+		func(zipper *zip.Writer, dirname string) error {
+			return rep.writeToZip(zipper, format, dirname)
+		},
 	}
 
-	reportZipper := func(zipper *zip.Writer, dirname string) error {
-		return rep.writeToZip(zipper, format, dirname)
+	// Optional logs section
+	if includeLogs {
+		sections = append(sections, func(zipper *zip.Writer, dirname string) error {
+			return streamOperatorLogsToZip(ctx, pods, dirname, "operator-logs", logTimeStamp, zipper)
+		})
 	}
 
-	sections := []zipFileWriter{reportZipper}
-
-	if includeLogs {
-		logZipper := func(zipper *zip.Writer, dirname string) error {
-			return streamOperatorLogsToZip(ctx, operatorPods, dirname, "operator-logs", logTimeStamp, zipper)
-		}
-		sections = append(sections, logZipper)
+	// Optional OLM section
+	if olmZipper := tryGetOLMReport(ctx, format); olmZipper != nil {
+		sections = append(sections, olmZipper)
 	}
 
-	// Detect if we are running in an OLM cluster
+	return sections
+}
+
+// logWarning prints a formatted warning message with additional context
+func logWarning(message string, err error, additionalInfo string) {
+	fmt.Printf("WARNING: %s: %v\n", message, err)
+	fmt.Printf("         %s\n", additionalInfo)
+}
+
+// tryGetOLMReport attempts to detect and collect OLM information,
+// returning a zipper function if successful, or nil if OLM is not available or permissions are insufficient
+func tryGetOLMReport(ctx context.Context, format plugin.OutputFormat) zipFileWriter {
 	discoveryClient, err := utils.GetDiscoveryClient()
 	if err != nil {
-		return err
+		logWarning("could not get discovery client", err, "Continuing without OLM detection.")
+		return nil
 	}
 
 	if err = utils.DetectOLM(discoveryClient); err != nil {
-		return fmt.Errorf("unable too look for OLM resources: %w", err)
+		logWarning("unable to look for OLM resources", err,
+			"Continuing without OLM information. This is expected if you don't have cluster-level permissions.")
+		return nil
 	}
 
-	if utils.RunningOnOLM() {
-		olmReport, err := getOlmReport(ctx, plugin.Namespace)
-		if err != nil {
-			return fmt.Errorf("could not get openshift operator report: %w", err)
-		}
-		olmZipper := func(zipper *zip.Writer, dirname string) error {
-			return olmReport.writeToZip(zipper, format, dirname)
-		}
-		sections = append(sections, olmZipper)
+	if !utils.RunningOnOLM() {
+		return nil
 	}
 
-	err = writeZippedReport(sections, file, reportName("operator", now))
+	olmReport, err := getOlmReport(ctx, plugin.Namespace)
 	if err != nil {
-		return fmt.Errorf("could not write report: %w", err)
+		logWarning("could not get openshift operator report", err,
+			"Continuing without OLM report. This is expected if you don't have cluster-level permissions.")
+		return nil
 	}
 
-	fmt.Printf("Successfully written report to \"%s\" (format: \"%s\")\n", file, format)
-
-	return nil
+	return func(zipper *zip.Writer, dirname string) error {
+		return olmReport.writeToZip(zipper, format, dirname)
+	}
 }
diff --git a/internal/cmd/plugin/report/operator_utils.go b/internal/cmd/plugin/report/operator_utils.go
index 3ae86151..33d01a0e 100644
--- a/internal/cmd/plugin/report/operator_utils.go
+++ b/internal/cmd/plugin/report/operator_utils.go
@@ -47,7 +47,7 @@ func getWebhooks(
 	)
 
 	if err := plugin.Client.List(ctx, &mutatingWebhookConfigList); err != nil {
-		return nil, nil, err
+		return nil, nil, fmt.Errorf("insufficient permissions to list mutating webhooks: %w", err)
 	}
 
 	for _, item := range mutatingWebhookConfigList.Items {
@@ -66,7 +66,7 @@ func getWebhooks(
 	}
 
 	if err := plugin.Client.List(ctx, &validatingWebhookConfigList); err != nil {
-		return nil, nil, err
+		return nil, nil, fmt.Errorf("insufficient permissions to list validating webhooks: %w", err)
 	}
 
 	for _, item := range validatingWebhookConfigList.Items {
@@ -84,7 +84,7 @@ func getWebhooks(
 		}
 	}
 
-	if len(mWebhookConfig.Items) == 0 || len(vWebhookConfig.Items) == 0 {
+	if len(mWebhookConfig.Items) == 0 && len(vWebhookConfig.Items) == 0 {
 		return nil, nil, fmt.Errorf(
 			"can't find the webhooks that targeting resources within the group %s",
 			apiv1.SchemeGroupVersion.Group,
@@ -98,7 +98,8 @@ func getWebhookService(
 	ctx context.Context,
 	mutatingWebhookList *admissionregistrationv1.MutatingWebhookConfigurationList,
 ) (corev1.Service, error) {
-	if len(mutatingWebhookList.Items) == 0 ||
+	if mutatingWebhookList == nil ||
+		len(mutatingWebhookList.Items) == 0 ||
 		len(mutatingWebhookList.Items[0].Webhooks) == 0 {
 		return corev1.Service{}, nil
 	}



Test patch:
diff --git a/internal/cmd/plugin/report/operator_report_test.go b/internal/cmd/plugin/report/operator_report_test.go
new file mode 100644
index 00000000..31eb3d4f
--- /dev/null
+++ b/internal/cmd/plugin/report/operator_report_test.go
@@ -0,0 +1,139 @@
+/*
+Copyright © contributors to CloudNativePG, established as
+CloudNativePG a Series of LF Projects, LLC.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+*/
+
+package report
+
+import (
+	"context"
+	"io"
+	"os"
+
+	appsv1 "k8s.io/api/apps/v1"
+	corev1 "k8s.io/api/core/v1"
+
+	. "github.com/onsi/ginkgo/v2"
+	. "github.com/onsi/gomega"
+)
+
+var _ = Describe("configureRedactors", func() {
+	It("should return redact functions when stopRedaction is false", func() {
+		secretRedactor, configMapRedactor := configureRedactors(false)
+
+		// Verify they are actual redactors by testing behavior
+		secret := corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}
+		redacted := secretRedactor(secret)
+		Expect(redacted.Data["key"]).To(Equal([]byte("")))
+
+		configMap := corev1.ConfigMap{Data: map[string]string{"key": "value"}}
+		redactedCM := configMapRedactor(configMap)
+		Expect(redactedCM.Data["key"]).To(Equal(""))
+	})
+
+	It("should return pass-through functions when stopRedaction is true", func() {
+		// Capture stdout to avoid polluting test output
+		oldStdout := os.Stdout
+		r, w, _ := os.Pipe()
+		os.Stdout = w
+
+		secretRedactor, configMapRedactor := configureRedactors(true)
+
+		// Restore stdout
+		_ = w.Close()
+		os.Stdout = oldStdout
+		_, _ = io.Copy(io.Discard, r)
+
+		// Verify they are pass-through functions
+		secret := corev1.Secret{Data: map[string][]byte{"key": []byte("value")}}
+		passed := secretRedactor(secret)
+		Expect(passed.Data["key"]).To(Equal([]byte("value")))
+
+		configMap := corev1.ConfigMap{Data: map[string]string{"key": "value"}}
+		passedCM := configMapRedactor(configMap)
+		Expect(passedCM.Data["key"]).To(Equal("value"))
+	})
+})
+
+var _ = Describe("tryCollectConfigurations", func() {
+	It("should handle errors gracefully", func() {
+		// This is a demonstration test - in production these would use mocked clients
+		// The function is designed to handle errors without panicking
+		deployment := appsv1.Deployment{}
+
+		// Capture warnings to avoid polluting test output
+		oldStdout := os.Stdout
+		r, w, _ := os.Pipe()
+		os.Stdout = w
+
+		secrets, configs := tryCollectConfigurations(
+			context.Background(),
+			deployment,
+			redactSecret,
+			redactConfigMap,
+		)
+
+		// Restore stdout
+		_ = w.Close()
+		os.Stdout = oldStdout
+		_, _ = io.Copy(io.Discard, r)
+
+		// When errors occur, function returns nil slices (not empty slices)
+		// This is acceptable as writeToZip checks for nil/empty
+		_ = secrets
+		_ = configs
+	})
+})
+
+var _ = Describe("assembleSections", func() {
+	It("should create sections array with at least the main report", func() {
+		rep := operatorReport{
+			deployment: appsv1.Deployment{},
+		}
+
+		sections := assembleSections(
+			context.Background(),
+			rep,
+			nil,
+			"yaml",
+			false,
+			false,
+		)
+
+		// Should always have at least the main report section
+		Expect(sections).ToNot(BeEmpty())
+	})
+
+	It("should add logs section when includeLogs is true", func() {
+		rep := operatorReport{
+			deployment: appsv1.Deployment{},
+		}
+		pods := []corev1.Pod{{}}
+
+		sections := assembleSections(
+			context.Background(),
+			rep,
+			pods,
+			"yaml",
+			true, // includeLogs
+			false,
+		)
+
+		// Should have main report + logs section
+		Expect(len(sections)).To(BeNumerically(">=", 2))
+	})
+})