Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot]andadamancini 732dcf60a9 Final state
Co-authored-by: adamancini <292598+adamancini@users.noreply.github.com>
2026-02-27 17:17:20 +00:00
copilot-swe-agent[bot]andadamancini fbae305e73 Revert removal of v1beta3 from scheme register
Co-authored-by: adamancini <292598+adamancini@users.noreply.github.com>
2026-02-27 17:14:00 +00:00
copilot-swe-agent[bot] f5ea884021 Initial plan 2026-02-27 17:11:48 +00:00
ada mancini 08100072b3 Register IngressClass analyzer in dispatcher 2026-02-27 00:19:03 -05:00
ada mancini c44b75ad67 Implement IngressClass analyzer 2026-02-27 00:17:34 -05:00
ada mancini 0729fc2ba9 Add IngressClass analyzer tests 2026-02-26 20:31:19 -05:00
ada mancini 12650f5405 Update client-gen output from make generate 2026-02-26 20:30:22 -05:00
ada mancini fe52bef88e Regenerate deepcopy for IngressClass type 2026-02-26 20:30:03 -05:00
ada mancini 949e6b6d10 Add IngressClass analyzer API type 2026-02-26 16:31:09 -05:00
ada mancini 1556876923 Collect IngressClass resources in cluster resources 2026-02-26 16:30:51 -05:00
ada mancini 7f173bc779 Add CLUSTER_RESOURCES_INGRESS_CLASS constant 2026-02-26 16:29:53 -05:00
dependabot[bot]andlnx01 87169eeb4b chore(deps): bump filippo.io/edwards25519 from 1.1.0 to 1.1.1 (#1977)
Bumps [filippo.io/edwards25519](https://github.com/FiloSottile/edwards25519) from 1.1.0 to 1.1.1.
- [Commits](https://github.com/FiloSottile/edwards25519/compare/v1.1.0...v1.1.1)

---
updated-dependencies:
- dependency-name: filippo.io/edwards25519
  dependency-version: 1.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-19 09:09:49 -05:00
967c0ffc99 chore(deps): bump the security group with 2 updates (#1976)
* chore(deps): bump the security group with 2 updates

Bumps the security group with 2 updates: [github.com/containerd/cgroups/v3](https://github.com/containerd/cgroups) and [go.podman.io/image/v5](https://github.com/containers/container-libs).


Updates `github.com/containerd/cgroups/v3` from 3.1.2 to 3.1.3
- [Release notes](https://github.com/containerd/cgroups/releases)
- [Commits](https://github.com/containerd/cgroups/compare/v3.1.2...v3.1.3)

Updates `go.podman.io/image/v5` from 5.38.0 to 5.39.1
- [Release notes](https://github.com/containers/container-libs/releases)
- [Commits](https://github.com/containers/container-libs/compare/image/v5.38.0...image/v5.39.1)

---
updated-dependencies:
- dependency-name: github.com/containerd/cgroups/v3
  dependency-version: 3.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: go.podman.io/image/v5
  dependency-version: 5.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
...

Signed-off-by: dependabot[bot] <support@github.com>

* return to go.podman.io/storage@main

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrew Lavery <laverya@umich.edu>
2026-02-19 09:08:51 -05:00
replicated-ciandlnx01 f221e02c39 Bump Go from 1.25.7 to 1.26.0 (#1975)
Bump Go to version from 1.25.7 to 1.26.0

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-19 08:33:32 -05:00
26869d06f0 chore(deps): bump the security group across 1 directory with 16 updates (#1974)
* chore(deps): bump the security group across 1 directory with 16 updates

Bumps the security group with 11 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) | `4.25.12` | `4.26.1` |
| [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) | `1.39.0` | `1.40.0` |
| [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go) | `1.39.0` | `1.40.0` |
| [go.podman.io/image/v5](https://github.com/containers/container-libs) | `5.38.0` | `5.39.1` |
| [golang.org/x/mod](https://github.com/golang/mod) | `0.32.0` | `0.33.0` |
| [k8s.io/api](https://github.com/kubernetes/api) | `0.35.0` | `0.35.1` |
| [k8s.io/apiextensions-apiserver](https://github.com/kubernetes/apiextensions-apiserver) | `0.35.0` | `0.35.1` |
| [k8s.io/cli-runtime](https://github.com/kubernetes/cli-runtime) | `0.35.0` | `0.35.1` |
| [golang.org/x/net](https://github.com/golang/net) | `0.49.0` | `0.50.0` |
| [k8s.io/kubelet](https://github.com/kubernetes/kubelet) | `0.35.0` | `0.35.1` |
| [k8s.io/metrics](https://github.com/kubernetes/metrics) | `0.35.0` | `0.35.1` |



Updates `github.com/shirou/gopsutil/v4` from 4.25.12 to 4.26.1
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.25.12...v4.26.1)

Updates `go.opentelemetry.io/otel` from 1.39.0 to 1.40.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.39.0...v1.40.0)

Updates `go.opentelemetry.io/otel/sdk` from 1.39.0 to 1.40.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.39.0...v1.40.0)

Updates `go.podman.io/image/v5` from 5.38.0 to 5.39.1
- [Release notes](https://github.com/containers/container-libs/releases)
- [Commits](https://github.com/containers/container-libs/compare/image/v5.38.0...image/v5.39.1)

Updates `golang.org/x/mod` from 0.32.0 to 0.33.0
- [Commits](https://github.com/golang/mod/compare/v0.32.0...v0.33.0)

Updates `k8s.io/api` from 0.35.0 to 0.35.1
- [Commits](https://github.com/kubernetes/api/compare/v0.35.0...v0.35.1)

Updates `k8s.io/apiextensions-apiserver` from 0.35.0 to 0.35.1
- [Release notes](https://github.com/kubernetes/apiextensions-apiserver/releases)
- [Commits](https://github.com/kubernetes/apiextensions-apiserver/compare/v0.35.0...v0.35.1)

Updates `k8s.io/apimachinery` from 0.35.0 to 0.35.1
- [Commits](https://github.com/kubernetes/apimachinery/compare/v0.35.0...v0.35.1)

Updates `k8s.io/apiserver` from 0.35.0 to 0.35.1
- [Commits](https://github.com/kubernetes/apiserver/compare/v0.35.0...v0.35.1)

Updates `k8s.io/cli-runtime` from 0.35.0 to 0.35.1
- [Commits](https://github.com/kubernetes/cli-runtime/compare/v0.35.0...v0.35.1)

Updates `k8s.io/client-go` from 0.35.0 to 0.35.1
- [Changelog](https://github.com/kubernetes/client-go/blob/master/CHANGELOG.md)
- [Commits](https://github.com/kubernetes/client-go/compare/v0.35.0...v0.35.1)

Updates `golang.org/x/net` from 0.49.0 to 0.50.0
- [Commits](https://github.com/golang/net/compare/v0.49.0...v0.50.0)

Updates `golang.org/x/sys` from 0.40.0 to 0.41.0
- [Commits](https://github.com/golang/sys/compare/v0.40.0...v0.41.0)

Updates `golang.org/x/text` from 0.33.0 to 0.34.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.33.0...v0.34.0)

Updates `k8s.io/kubelet` from 0.35.0 to 0.35.1
- [Commits](https://github.com/kubernetes/kubelet/compare/v0.35.0...v0.35.1)

Updates `k8s.io/metrics` from 0.35.0 to 0.35.1
- [Commits](https://github.com/kubernetes/metrics/compare/v0.35.0...v0.35.1)

---
updated-dependencies:
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.26.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: go.opentelemetry.io/otel
  dependency-version: 1.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: go.opentelemetry.io/otel/sdk
  dependency-version: 1.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: go.podman.io/image/v5
  dependency-version: 5.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/mod
  dependency-version: 0.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: k8s.io/api
  dependency-version: 0.35.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: k8s.io/apiextensions-apiserver
  dependency-version: 0.35.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: k8s.io/apimachinery
  dependency-version: 0.35.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: k8s.io/apiserver
  dependency-version: 0.35.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: k8s.io/cli-runtime
  dependency-version: 0.35.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: k8s.io/client-go
  dependency-version: 0.35.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: golang.org/x/net
  dependency-version: 0.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/sys
  dependency-version: 0.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/text
  dependency-version: 0.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: k8s.io/kubelet
  dependency-version: 0.35.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: k8s.io/metrics
  dependency-version: 0.35.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
...

Signed-off-by: dependabot[bot] <support@github.com>

* revert update to go.podman.io/storage

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Xav Paice <xav@replicated.com>
2026-02-13 18:22:52 +13:00
replicated-ciandlnx01 01b6240e2e Bump Go from 1.25.5 to 1.25.7 (#1970)
Bump Go to version from 1.25.5 to 1.25.7

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-13 15:24:03 +13:00
dependabot[bot]andlnx01 1c576cd5d8 chore(deps): bump the security group across 1 directory with 5 updates (#1965)
Bumps the security group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github.com/microsoft/go-mssqldb](https://github.com/microsoft/go-mssqldb) | `1.9.5` | `1.9.6` |
| [github.com/miekg/dns](https://github.com/miekg/dns) | `1.1.70` | `1.1.72` |
| [github.com/vmware-tanzu/velero](https://github.com/vmware-tanzu/velero) | `1.17.1` | `1.17.2` |
| [sigs.k8s.io/controller-runtime](https://github.com/kubernetes-sigs/controller-runtime) | `0.22.4` | `0.23.1` |
| [helm.sh/helm/v3](https://github.com/helm/helm) | `3.19.4` | `3.20.0` |



Updates `github.com/microsoft/go-mssqldb` from 1.9.5 to 1.9.6
- [Release notes](https://github.com/microsoft/go-mssqldb/releases)
- [Changelog](https://github.com/microsoft/go-mssqldb/blob/main/CHANGELOG.md)
- [Commits](https://github.com/microsoft/go-mssqldb/compare/v1.9.5...v1.9.6)

Updates `github.com/miekg/dns` from 1.1.70 to 1.1.72
- [Commits](https://github.com/miekg/dns/compare/v1.1.70...v1.1.72)

Updates `github.com/vmware-tanzu/velero` from 1.17.1 to 1.17.2
- [Release notes](https://github.com/vmware-tanzu/velero/releases)
- [Changelog](https://github.com/vmware-tanzu/velero/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vmware-tanzu/velero/compare/v1.17.1...v1.17.2)

Updates `sigs.k8s.io/controller-runtime` from 0.22.4 to 0.23.1
- [Release notes](https://github.com/kubernetes-sigs/controller-runtime/releases)
- [Changelog](https://github.com/kubernetes-sigs/controller-runtime/blob/main/RELEASE.md)
- [Commits](https://github.com/kubernetes-sigs/controller-runtime/compare/v0.22.4...v0.23.1)

Updates `helm.sh/helm/v3` from 3.19.4 to 3.20.0
- [Release notes](https://github.com/helm/helm/releases)
- [Commits](https://github.com/helm/helm/compare/v3.19.4...v3.20.0)

---
updated-dependencies:
- dependency-name: github.com/microsoft/go-mssqldb
  dependency-version: 1.9.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: github.com/miekg/dns
  dependency-version: 1.1.72
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: github.com/vmware-tanzu/velero
  dependency-version: 1.17.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: sigs.k8s.io/controller-runtime
  dependency-version: 0.23.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: helm.sh/helm/v3
  dependency-version: 3.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-29 11:53:53 -05:00
dependabot[bot]andlnx01 203f3fc167 chore(deps): bump helm.sh/helm/v3 from 3.19.4 to 3.19.5 in /examples/sdk/helm-template in the security group (#1963)
chore(deps): bump helm.sh/helm/v3

Bumps the security group in /examples/sdk/helm-template with 1 update: [helm.sh/helm/v3](https://github.com/helm/helm).


Updates `helm.sh/helm/v3` from 3.19.4 to 3.19.5
- [Release notes](https://github.com/helm/helm/releases)
- [Commits](https://github.com/helm/helm/compare/v3.19.4...v3.19.5)

---
updated-dependencies:
- dependency-name: helm.sh/helm/v3
  dependency-version: 3.19.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-29 11:53:14 -05:00
ada mancini 73017ec48e feat: collect CertificateSigningRequests in clusterResources collector (#1964)
* Add .worktrees to .gitignore

Prevent worktree directories from being tracked in the repository.

* feat: collect CertificateSigningRequests in clusterResources collector

Add support for collecting CertificateSigningRequests (CSRs) from the
certificates.k8s.io/v1 API in the clusterResources collector.

Changes:
- Added certificateSigningRequests() helper function in cluster_resources.go
  following the existing pattern for other cluster-scoped resources
- Integrated CSR collection into the Collect() method between
  volumeAttachments and configMaps
- Added CLUSTER_RESOURCES_CERTIFICATE_SIGNING_REQUESTS constant
- Implemented fail-safe error handling for permission denied scenarios
  (e.g., managed clusters like EKS that may deny CSR access)

Testing:
- Added Test_CertificateSigningRequests() with table-driven tests for
  single and multiple CSR collection scenarios
- Added Test_CertificateSigningRequests_PermissionDenied() to verify
  fail-safe behavior when API access is forbidden
- All existing tests pass with no regressions

CSRs are saved to: cluster-resources/certificatesigningrequests.json
Errors are saved to: cluster-resources/certificatesigningrequests-errors.json

* style: run make fmt to align constant declarations

Formatting changes only - realigned constant declarations for
consistent spacing.

* fix: add .worktrees as separate line in .gitignore

The /support-bundle directory should remain ignored (for built
binaries), and /.worktrees/ should be added as a separate line.
2026-01-21 14:18:53 -05:00
06a8692de5 chore(deps): bump helm.sh/helm/v3 from 3.19.2 to 3.19.4 in /examples/sdk/helm-template in the security group (#1951)
chore(deps): bump helm.sh/helm/v3

Bumps the security group in /examples/sdk/helm-template with 1 update: [helm.sh/helm/v3](https://github.com/helm/helm).


Updates `helm.sh/helm/v3` from 3.19.2 to 3.19.4
- [Release notes](https://github.com/helm/helm/releases)
- [Commits](https://github.com/helm/helm/compare/v3.19.2...v3.19.4)

---
updated-dependencies:
- dependency-name: helm.sh/helm/v3
  dependency-version: 3.19.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrew Lavery <laverya@umich.edu>
2026-01-14 15:36:39 -05:00
Andrew Lavery a50bd612e8 use oras.land/oras-go/v2 (#1957) 2026-01-14 14:36:04 -06:00
dependabot[bot]andlnx01 d5b591d6f1 chore(deps): bump the security group across 1 directory with 3 updates (#1960)
Bumps the security group with 2 updates in the / directory: [golang.org/x/mod](https://github.com/golang/mod) and [golang.org/x/net](https://github.com/golang/net).


Updates `golang.org/x/mod` from 0.31.0 to 0.32.0
- [Commits](https://github.com/golang/mod/compare/v0.31.0...v0.32.0)

Updates `golang.org/x/net` from 0.48.0 to 0.49.0
- [Commits](https://github.com/golang/net/compare/v0.48.0...v0.49.0)

Updates `golang.org/x/text` from 0.32.0 to 0.33.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.32.0...v0.33.0)

---
updated-dependencies:
- dependency-name: golang.org/x/mod
  dependency-version: 0.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/net
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/text
  dependency-version: 0.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 15:15:17 -05:00
dependabot[bot]andlnx01 ad8ad1bf74 chore(deps): bump actions/download-artifact from 5 to 7 (#1950)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 15:08:07 -05:00
dependabot[bot]andlnx01 083ec78491 chore(deps): bump actions/upload-artifact from 5 to 6 (#1949)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 15:07:41 -05:00
Andrew Laveryandlnx01 bd102623eb Update modules (#1959)
* chore(deps): bump the security group across 1 directory with 27 updates

Bumps the security group with 16 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github.com/containerd/cgroups/v3](https://github.com/containerd/cgroups) | `3.1.1` | `3.1.2` |
| [github.com/godbus/dbus/v5](https://github.com/godbus/dbus) | `5.2.0` | `5.2.2` |
| [github.com/hashicorp/go-getter](https://github.com/hashicorp/go-getter) | `1.8.3` | `1.8.4` |
| [github.com/jackc/pgx/v5](https://github.com/jackc/pgx) | `5.7.6` | `5.8.0` |
| [github.com/microsoft/go-mssqldb](https://github.com/microsoft/go-mssqldb) | `1.9.4` | `1.9.5` |
| [github.com/miekg/dns](https://github.com/miekg/dns) | `1.1.68` | `1.1.69` |
| [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) | `4.25.10` | `4.25.12` |
| [github.com/spf13/cobra](https://github.com/spf13/cobra) | `1.10.1` | `1.10.2` |
| [golang.org/x/mod](https://github.com/golang/mod) | `0.30.0` | `0.31.0` |
| [k8s.io/api](https://github.com/kubernetes/api) | `0.34.2` | `0.35.0` |
| [k8s.io/apiextensions-apiserver](https://github.com/kubernetes/apiextensions-apiserver) | `0.34.2` | `0.35.0` |
| [k8s.io/cli-runtime](https://github.com/kubernetes/cli-runtime) | `0.34.2` | `0.35.0` |
| [k8s.io/kubernetes](https://github.com/kubernetes/kubernetes) | `1.34.2` | `1.35.0` |
| [helm.sh/helm/v3](https://github.com/helm/helm) | `3.19.0` | `3.19.4` |
| [k8s.io/kubelet](https://github.com/kubernetes/kubelet) | `0.34.2` | `0.35.0` |
| [k8s.io/metrics](https://github.com/kubernetes/metrics) | `0.34.2` | `0.35.0` |



Updates `github.com/containerd/cgroups/v3` from 3.1.1 to 3.1.2
- [Release notes](https://github.com/containerd/cgroups/releases)
- [Commits](https://github.com/containerd/cgroups/compare/v3.1.1...v3.1.2)

Updates `github.com/godbus/dbus/v5` from 5.2.0 to 5.2.2
- [Release notes](https://github.com/godbus/dbus/releases)
- [Commits](https://github.com/godbus/dbus/compare/v5.2.0...v5.2.2)

Updates `github.com/hashicorp/go-getter` from 1.8.3 to 1.8.4
- [Release notes](https://github.com/hashicorp/go-getter/releases)
- [Commits](https://github.com/hashicorp/go-getter/compare/v1.8.3...v1.8.4)

Updates `github.com/jackc/pgx/v5` from 5.7.6 to 5.8.0
- [Changelog](https://github.com/jackc/pgx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jackc/pgx/compare/v5.7.6...v5.8.0)

Updates `github.com/microsoft/go-mssqldb` from 1.9.4 to 1.9.5
- [Release notes](https://github.com/microsoft/go-mssqldb/releases)
- [Changelog](https://github.com/microsoft/go-mssqldb/blob/main/CHANGELOG.md)
- [Commits](https://github.com/microsoft/go-mssqldb/compare/v1.9.4...v1.9.5)

Updates `github.com/miekg/dns` from 1.1.68 to 1.1.69
- [Commits](https://github.com/miekg/dns/compare/v1.1.68...v1.1.69)

Updates `github.com/shirou/gopsutil/v4` from 4.25.10 to 4.25.12
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.25.10...v4.25.12)

Updates `github.com/spf13/cobra` from 1.10.1 to 1.10.2
- [Release notes](https://github.com/spf13/cobra/releases)
- [Commits](https://github.com/spf13/cobra/compare/v1.10.1...v1.10.2)

Updates `go.opentelemetry.io/otel` from 1.38.0 to 1.39.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.38.0...v1.39.0)

Updates `go.opentelemetry.io/otel/sdk` from 1.38.0 to 1.39.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.38.0...v1.39.0)

Updates `golang.org/x/mod` from 0.30.0 to 0.31.0
- [Commits](https://github.com/golang/mod/compare/v0.30.0...v0.31.0)

Updates `golang.org/x/sync` from 0.18.0 to 0.19.0
- [Commits](https://github.com/golang/sync/compare/v0.18.0...v0.19.0)

Updates `k8s.io/api` from 0.34.2 to 0.35.0
- [Commits](https://github.com/kubernetes/api/compare/v0.34.2...v0.35.0)

Updates `k8s.io/apiextensions-apiserver` from 0.34.2 to 0.35.0
- [Release notes](https://github.com/kubernetes/apiextensions-apiserver/releases)
- [Commits](https://github.com/kubernetes/apiextensions-apiserver/compare/v0.34.2...v0.35.0)

Updates `k8s.io/apimachinery` from 0.34.2 to 0.35.0
- [Commits](https://github.com/kubernetes/apimachinery/compare/v0.34.2...v0.35.0)

Updates `k8s.io/apiserver` from 0.34.2 to 0.35.0
- [Commits](https://github.com/kubernetes/apiserver/compare/v0.34.2...v0.35.0)

Updates `k8s.io/cli-runtime` from 0.34.2 to 0.35.0
- [Commits](https://github.com/kubernetes/cli-runtime/compare/v0.34.2...v0.35.0)

Updates `k8s.io/client-go` from 0.34.2 to 0.35.0
- [Changelog](https://github.com/kubernetes/client-go/blob/master/CHANGELOG.md)
- [Commits](https://github.com/kubernetes/client-go/compare/v0.34.2...v0.35.0)

Updates `k8s.io/kubernetes` from 1.34.2 to 1.35.0
- [Release notes](https://github.com/kubernetes/kubernetes/releases)
- [Commits](https://github.com/kubernetes/kubernetes/compare/v1.34.2...v1.35.0)

Updates `github.com/hashicorp/go-version` from 1.7.0 to 1.8.0
- [Release notes](https://github.com/hashicorp/go-version/releases)
- [Changelog](https://github.com/hashicorp/go-version/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hashicorp/go-version/compare/v1.7.0...v1.8.0)

Updates `golang.org/x/net` from 0.47.0 to 0.48.0
- [Commits](https://github.com/golang/net/compare/v0.47.0...v0.48.0)

Updates `golang.org/x/sys` from 0.38.0 to 0.39.0
- [Commits](https://github.com/golang/sys/compare/v0.38.0...v0.39.0)

Updates `golang.org/x/text` from 0.31.0 to 0.32.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.31.0...v0.32.0)

Updates `helm.sh/helm/v3` from 3.19.0 to 3.19.4
- [Release notes](https://github.com/helm/helm/releases)
- [Commits](https://github.com/helm/helm/compare/v3.19.0...v3.19.4)

Updates `k8s.io/kubelet` from 0.34.2 to 0.35.0
- [Commits](https://github.com/kubernetes/kubelet/compare/v0.34.2...v0.35.0)

Updates `k8s.io/metrics` from 0.34.2 to 0.35.0
- [Commits](https://github.com/kubernetes/metrics/compare/v0.34.2...v0.35.0)

Updates `k8s.io/utils` from 0.0.0-20250604170112-4c0f3b243397 to 0.0.0-20251002143259-bc988d571ff4
- [Commits](https://github.com/kubernetes/utils/commits)

---
updated-dependencies:
- dependency-name: github.com/containerd/cgroups/v3
  dependency-version: 3.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: github.com/godbus/dbus/v5
  dependency-version: 5.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: github.com/hashicorp/go-getter
  dependency-version: 1.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: github.com/jackc/pgx/v5
  dependency-version: 5.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: github.com/microsoft/go-mssqldb
  dependency-version: 1.9.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: github.com/miekg/dns
  dependency-version: 1.1.69
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.25.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: github.com/spf13/cobra
  dependency-version: 1.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: go.opentelemetry.io/otel
  dependency-version: 1.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: go.opentelemetry.io/otel/sdk
  dependency-version: 1.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/mod
  dependency-version: 0.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/sync
  dependency-version: 0.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: k8s.io/api
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: k8s.io/apiextensions-apiserver
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: k8s.io/apimachinery
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: k8s.io/apiserver
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: k8s.io/cli-runtime
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: k8s.io/client-go
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: k8s.io/kubernetes
  dependency-version: 1.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: github.com/hashicorp/go-version
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/net
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/sys
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/text
  dependency-version: 0.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: helm.sh/helm/v3
  dependency-version: 3.19.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: k8s.io/kubelet
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: k8s.io/metrics
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: k8s.io/utils
  dependency-version: 0.0.0-20251002143259-bc988d571ff4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
...

Signed-off-by: dependabot[bot] <support@github.com>

* go get go.podman.io/storage@main

* go mod tidy

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 13:59:56 -06:00
Adam Wolfe GordonandAndrew Lavery 985416f20c Copy TaintExists to pkg/k8sutil and stop importing k8s.io/kubernetes (#1952)
Importing k8s.io/kubernetes causes any go modules that depend on this one to
have some issues. For example, the following happens in a module that depends on
troubleshoot:

```shell
$ go list -modfile=./go.mod -m -json -mod=mod all
go: k8s.io/cloud-provider@v0.0.0: invalid version: unknown revision v0.0.0
go: k8s.io/cluster-bootstrap@v0.0.0: invalid version: unknown revision v0.0.0
go: k8s.io/controller-manager@v0.0.0: invalid version: unknown revision v0.0.0
go: k8s.io/cri-client@v0.0.0: invalid version: unknown revision v0.0.0
go: k8s.io/csi-translation-lib@v0.0.0: invalid version: unknown revision v0.0.0
go: k8s.io/dynamic-resource-allocation@v0.0.0: invalid version: unknown revision v0.0.0
go: k8s.io/endpointslice@v0.0.0: invalid version: unknown revision v0.0.0
go: k8s.io/externaljwt@v0.0.0: invalid version: unknown revision v0.0.0
go: k8s.io/kube-controller-manager@v0.0.0: invalid version: unknown revision v0.0.0
go: k8s.io/kube-proxy@v0.0.0: invalid version: unknown revision v0.0.0
go: k8s.io/kube-scheduler@v0.0.0: invalid version: unknown revision v0.0.0
go: k8s.io/mount-utils@v0.0.0: invalid version: unknown revision v0.0.0
go: k8s.io/pod-security-admission@v0.0.0: invalid version: unknown revision v0.0.0
go: k8s.io/sample-apiserver@v0.0.0: invalid version: unknown revision v0.0.0
```

The only thing being used from k8s.io/kubernetes is a simple utility function,
`TaintExists`. Copy it into pkg/k8sutil to eliminate the need for the import.

Signed-off-by: Adam Wolfe Gordon <awg@upbound.io>
Co-authored-by: Andrew Lavery <laverya@umich.edu>
2026-01-14 14:40:33 -05:00
Andrew Lavery 128f9311fe move to go.podman.io dependencies (#1956)
* move to go.podman.io dependencies

* go fmt
2026-01-09 10:40:47 -08:00
Benjamin Yangandhedge-sparrow a9d2180dd6 102 redactor newline corruption clean (#1947)
* fix: prevent redactors from corrupting binary files (#102)

Redactors were adding newlines to files without them, corrupting binary
files during support bundle collection (51 bytes → 53 bytes).

Created LineReader to track original newline state and only restore
newlines when they were present in the original file.

- Added pkg/redact/line_reader.go
- Refactored single_line.go, multi_line.go, literal.go
- Added 48 tests, all passing
- Verified: binary files now preserved byte-for-byte

Fixes #102


* fix: handle empty lines correctly in MultiLineRedactor

- Check line1 == nil instead of len(line1) == 0 for empty file detection
- Fixes edge case where file containing only '\n' would be dropped
- Addresses bugbot finding about empty line handling


* fix: handle empty lines correctly in MultiLineRedactor

- Check line1 != nil instead of len(line1) > 0 in both locations
- Fixes edge case where empty trailing lines would be dropped
- Fix test isolation in literal_test.go (move ResetRedactionList to parent)
- Addresses bugbot findings about empty line handling

* fmt

* chore: update regression baselines from run 20107431959

* adding defense

* fix: propagate non-EOF errors in all early return paths

Ensure non-EOF errors (like buffer overflow) are properly propagated
to caller in both pre-loop early returns. Addresses bugbot finding.

* fix: use unique test names to prevent redaction list pollution

Use t.Name() instead of hardcoded 'test' to ensure each test
has unique redactor name, preventing parallel test interference

---------

Co-authored-by: hedge-sparrow <sparrow@spooky.academy>
2025-12-10 16:55:54 -06:00
b69a8a9b8c Bump Go from 1.25.4 to 1.25.5 (#1943)
Bump Go to version from 1.25.4 to 1.25.5

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Andrew Lavery <laverya@umich.edu>
2025-12-10 17:15:39 -05:00
ada mancini cf816f8e26 fix(discovery): handle partial results from ServerGroupsAndResources (#1944) 2025-12-10 10:33:37 -05:00
Xav Paice 8ed6dbf581 update multiple packages (#1937) 2025-12-02 14:45:18 +13:00
Ethan Mosbaugh 8c0be8fd74 chore(ci): regression test cursor feedback (#1938) 2025-12-01 12:57:57 -08:00
dependabot[bot]andlnx01 d3655fa1ab chore(deps): bump actions/checkout from 5 to 6 (#1933)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-28 10:29:43 +13:00
Ethan Mosbaugh 9343b43e77 fix(collect): cluster resource errors json file has wrong name (#1936)
* fix(ci): regression test updates binary to latest release
* fix cluster resources collector
2025-11-28 10:17:03 +13:00
Xav Paice e45e2cadd3 Fix collector ordering: preserve order when grouping by type (#1935)
- Fix issue where EnsureClusterResourcesFirst ordering was lost when
  collectors were grouped by type into a map (Go maps have random
  iteration order)
- Preserve collector type order by tracking collectorTypeOrder slice
  as collectors are added to the map
- Apply fix to both pkg/preflight/collect.go and
  pkg/supportbundle/collect.go
- Add comprehensive tests to verify clusterResources runs first and
  relative order of other collectors is preserved
- Enhance EnsureClusterResourcesFirst tests with additional edge cases
2025-11-26 15:34:17 +13:00
da51c28767 chore(deps): bump github.com/opencontainers/selinux from 1.12.0 to 1.13.0 (#1919)
* chore(deps): bump github.com/opencontainers/selinux

Bumps [github.com/opencontainers/selinux](https://github.com/opencontainers/selinux) from 1.12.0 to 1.13.0.
- [Release notes](https://github.com/opencontainers/selinux/releases)
- [Commits](https://github.com/opencontainers/selinux/compare/v1.12.0...v1.13.0)

---
updated-dependencies:
- dependency-name: github.com/opencontainers/selinux
  dependency-version: 1.13.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fix go vet failure in CI on Linux

- Add go mod download before make vet in CI to ensure modules are available
- Remove vendor directory (not needed, was causing vendoring inconsistencies)
- Remove cache: false from all workflow files (not needed, enables caching)
- Add replace directive for filepath-securejoin to fix containers/storage build
- Clean up go.mod formatting and workflow improvements

* downgrade filepath-securejoin

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Xav Paice <xav@replicated.com>
2025-11-25 11:09:45 +13:00
Xav Paice c76b0ab333 update cluster creation action in regression-test.yaml (#1932) 2025-11-24 19:06:11 +13:00
dependabot[bot]andlnx01 5aa11c0e4b chore(deps): bump helm.sh/helm/v3 from 3.19.0 to 3.19.2 in /examples/sdk/helm-template in the security group (#1922)
chore(deps): bump helm.sh/helm/v3

Bumps the security group in /examples/sdk/helm-template with 1 update: [helm.sh/helm/v3](https://github.com/helm/helm).


Updates `helm.sh/helm/v3` from 3.19.0 to 3.19.2
- [Release notes](https://github.com/helm/helm/releases)
- [Commits](https://github.com/helm/helm/compare/v3.19.0...v3.19.2)

---
updated-dependencies:
- dependency-name: helm.sh/helm/v3
  dependency-version: 3.19.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 16:51:58 +13:00
dependabot[bot]andlnx01 af5bae315e chore(deps): bump golang.org/x/crypto from 0.42.0 to 0.45.0 in /examples/sdk/helm-template (#1926)
chore(deps): bump golang.org/x/crypto in /examples/sdk/helm-template

Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.42.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.42.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 16:19:54 +13:00
73ac499d3e Bump Go from 1.24.6 to 1.25.4 (#1930)
* Bump Go to version from 1.24.6 to 1.25.4

* fix: use net.JoinHostPort for IPv6 compatibility

Fix IPv6 address formatting in namespace-pinger.go by replacing
fmt.Sprintf with net.JoinHostPort, which correctly handles both
IPv4 and IPv6 addresses.

Changes:
- PingTCP: Use net.JoinHostPort for client connections
- startTCPEchoServer: Use net.JoinHostPort for server listener

This fixes go vet errors introduced by Go 1.25's stricter checks:
  address format "%s:%d" does not work with IPv6

IPv4 example: 192.168.1.1:8080
IPv6 example: [::1]:8080 (brackets added automatically)

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Nicholas Mullen <nwmullen@gmail.com>
2025-11-21 11:54:22 -06:00
Nicholas FernandesandAndrew Lavery aa13c2e31e chore(ci): add workflow to automate Go version updates (#1924)
* chore(ci): add workflow to automate Go version updates

Add GitHub Actions workflow using StefMa/Upgrade-Go-Action to
automatically check for new Go releases and create PRs to update
the go directive in go.mod.

This addresses the limitation that Dependabot cannot update the
Go version itself (only module dependencies), which means stdlib
CVEs that are fixed in newer Go patch releases are not automatically
detected.

Workflow runs:
- Weekly on Mondays at 8am UTC
- Manually via workflow_dispatch

When a new Go version is available, the action will:
1. Update the go directive in go.mod
2. Run go mod tidy
3. Create a pull request with the changes

Related: https://github.com/replicated-collab/git-guardian-kots/issues/287
Dependabot limitation: https://github.com/dependabot/dependabot-core/issues/9527

* test: add push trigger to test workflow

* chore: remove temporary push trigger

* test: add custom token and push trigger for testing

* test: trigger workflow again after cleaning up old branch

* chore: remove temporary push trigger

---------

Co-authored-by: Andrew Lavery <laverya@umich.edu>
2025-11-20 17:34:48 -06:00
dependabot[bot]andlnx01 52d910f615 chore(deps): bump golang.org/x/crypto from 0.43.0 to 0.45.0 (#1927)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.43.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.43.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-20 12:07:11 -05:00
dependabot[bot]andlnx01 e9111edd47 chore(deps): bump github.com/containerd/containerd from 1.7.28 to 1.7.29 (#1918)
Bumps [github.com/containerd/containerd](https://github.com/containerd/containerd) from 1.7.28 to 1.7.29.
- [Release notes](https://github.com/containerd/containerd/releases)
- [Changelog](https://github.com/containerd/containerd/blob/main/RELEASES.md)
- [Commits](https://github.com/containerd/containerd/compare/v1.7.28...v1.7.29)

---
updated-dependencies:
- dependency-name: github.com/containerd/containerd
  dependency-version: 1.7.29
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-12 14:28:58 -05:00
dependabot[bot]andlnx01 68f736b02c chore(deps): bump the security group across 1 directory with 10 updates (#1920)
Bumps the security group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github.com/cilium/ebpf](https://github.com/cilium/ebpf) | `0.19.0` | `0.20.0` |
| [github.com/containerd/cgroups/v3](https://github.com/containerd/cgroups) | `3.1.0` | `3.1.1` |
| [github.com/hashicorp/go-getter](https://github.com/hashicorp/go-getter) | `1.8.2` | `1.8.3` |
| [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) | `4.25.9` | `4.25.10` |
| [github.com/vmware-tanzu/velero](https://github.com/vmware-tanzu/velero) | `1.17.0` | `1.17.1` |
| [golang.org/x/sync](https://github.com/golang/sync) | `0.17.0` | `0.18.0` |
| [sigs.k8s.io/controller-runtime](https://github.com/kubernetes-sigs/controller-runtime) | `0.22.3` | `0.22.4` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.37.0` | `0.38.0` |



Updates `github.com/cilium/ebpf` from 0.19.0 to 0.20.0
- [Release notes](https://github.com/cilium/ebpf/releases)
- [Commits](https://github.com/cilium/ebpf/compare/v0.19.0...v0.20.0)

Updates `github.com/containerd/cgroups/v3` from 3.1.0 to 3.1.1
- [Release notes](https://github.com/containerd/cgroups/releases)
- [Commits](https://github.com/containerd/cgroups/compare/v3.1.0...v3.1.1)

Updates `github.com/hashicorp/go-getter` from 1.8.2 to 1.8.3
- [Release notes](https://github.com/hashicorp/go-getter/releases)
- [Changelog](https://github.com/hashicorp/go-getter/blob/main/.goreleaser.yml)
- [Commits](https://github.com/hashicorp/go-getter/compare/v1.8.2...v1.8.3)

Updates `github.com/shirou/gopsutil/v4` from 4.25.9 to 4.25.10
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.25.9...v4.25.10)

Updates `github.com/vmware-tanzu/velero` from 1.17.0 to 1.17.1
- [Release notes](https://github.com/vmware-tanzu/velero/releases)
- [Changelog](https://github.com/vmware-tanzu/velero/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vmware-tanzu/velero/compare/v1.17.0...v1.17.1)

Updates `golang.org/x/sync` from 0.17.0 to 0.18.0
- [Commits](https://github.com/golang/sync/compare/v0.17.0...v0.18.0)

Updates `sigs.k8s.io/controller-runtime` from 0.22.3 to 0.22.4
- [Release notes](https://github.com/kubernetes-sigs/controller-runtime/releases)
- [Changelog](https://github.com/kubernetes-sigs/controller-runtime/blob/main/RELEASE.md)
- [Commits](https://github.com/kubernetes-sigs/controller-runtime/compare/v0.22.3...v0.22.4)

Updates `github.com/opencontainers/runtime-spec` from 1.2.1 to 1.3.0
- [Release notes](https://github.com/opencontainers/runtime-spec/releases)
- [Changelog](https://github.com/opencontainers/runtime-spec/blob/main/ChangeLog)
- [Commits](https://github.com/opencontainers/runtime-spec/compare/v1.2.1...v1.3.0)

Updates `golang.org/x/net` from 0.45.0 to 0.46.0
- [Commits](https://github.com/golang/net/compare/v0.45.0...v0.46.0)

Updates `golang.org/x/sys` from 0.37.0 to 0.38.0
- [Commits](https://github.com/golang/sys/compare/v0.37.0...v0.38.0)

---
updated-dependencies:
- dependency-name: github.com/cilium/ebpf
  dependency-version: 0.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: github.com/containerd/cgroups/v3
  dependency-version: 3.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: github.com/hashicorp/go-getter
  dependency-version: 1.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.25.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: github.com/vmware-tanzu/velero
  dependency-version: 1.17.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: golang.org/x/sync
  dependency-version: 0.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: sigs.k8s.io/controller-runtime
  dependency-version: 0.22.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: github.com/opencontainers/runtime-spec
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/net
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/sys
  dependency-version: 0.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-12 14:14:19 -05:00
Nicholas Fernandes 40bb2e3a2a Fix incorrect field names in nodeResources example files (#1917)
The nodeResources filter examples incorrectly used `allocatableMemory`
instead of `memoryAllocatable`. This causes YAML parsing to silently
ignore the field, resulting in empty filter values and unexpected
analyzer behavior.

Changed in 3 files (5 instances total):
- examples/preflight/node-resources.yaml (3 instances)
- examples/preflight/e2e.yaml (1 instance)
- examples/support-bundle/e2e.yaml (1 instance)

The correct field names according to pkg/apis/troubleshoot/v1beta2/analyzer_shared.go
are:
- memoryAllocatable (not allocatableMemory)
- cpuAllocatable (not allocatableCPU)
- memoryCapacity (not capacityMemory)
- cpuCapacity (not capacityCPU)

This bug caused users copying from these examples to experience false
failures in preflight checks, as documented in the bug report where
a GKE cluster with 85Gi memory failed a check requiring only 8Gi.
2025-11-06 15:30:27 -06:00
Benjamin Yang cf2db49f86 applied native sidecar fix (#1914) 2025-11-04 11:30:42 -06:00
dependabot[bot]andlnx01 05a7a2092e chore(deps): bump actions/download-artifact from 4 to 6 (#1908)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-29 11:35:11 -07:00
dependabot[bot]andlnx01 e28dc8e080 chore(deps): bump the security group across 1 directory with 7 updates (#1912)
Bumps the security group with 4 updates in the / directory: [github.com/containerd/cgroups/v3](https://github.com/containerd/cgroups), [golang.org/x/mod](https://github.com/golang/mod), [oras.land/oras-go](https://github.com/oras-project/oras-go) and [sigs.k8s.io/controller-runtime](https://github.com/kubernetes-sigs/controller-runtime).


Updates `github.com/containerd/cgroups/v3` from 3.0.5 to 3.1.0
- [Release notes](https://github.com/containerd/cgroups/releases)
- [Commits](https://github.com/containerd/cgroups/compare/v3.0.5...v3.1.0)

Updates `golang.org/x/mod` from 0.28.0 to 0.29.0
- [Commits](https://github.com/golang/mod/compare/v0.28.0...v0.29.0)

Updates `oras.land/oras-go` from 1.2.6 to 1.2.7
- [Release notes](https://github.com/oras-project/oras-go/releases)
- [Commits](https://github.com/oras-project/oras-go/compare/v1.2.6...v1.2.7)

Updates `sigs.k8s.io/controller-runtime` from 0.22.2 to 0.22.3
- [Release notes](https://github.com/kubernetes-sigs/controller-runtime/releases)
- [Changelog](https://github.com/kubernetes-sigs/controller-runtime/blob/main/RELEASE.md)
- [Commits](https://github.com/kubernetes-sigs/controller-runtime/compare/v0.22.2...v0.22.3)

Updates `golang.org/x/net` from 0.44.0 to 0.45.0
- [Commits](https://github.com/golang/net/compare/v0.44.0...v0.45.0)

Updates `golang.org/x/sys` from 0.36.0 to 0.37.0
- [Commits](https://github.com/golang/sys/compare/v0.36.0...v0.37.0)

Updates `golang.org/x/text` from 0.29.0 to 0.30.0
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.29.0...v0.30.0)

---
updated-dependencies:
- dependency-name: github.com/containerd/cgroups/v3
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/mod
  dependency-version: 0.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: oras.land/oras-go
  dependency-version: 1.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: sigs.k8s.io/controller-runtime
  dependency-version: 0.22.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: security
- dependency-name: golang.org/x/net
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/sys
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
- dependency-name: golang.org/x/text
  dependency-version: 0.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-29 11:24:06 -07:00
dependabot[bot]andlnx01 b9da850d65 chore(deps): bump actions/setup-go from 5 to 6 (#1899)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-29 11:23:46 -07:00
dependabot[bot]andlnx01 0ecc72e86f chore(deps): bump actions/checkout from 4 to 5 (#1900)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-29 11:23:31 -07:00
dependabot[bot]andlnx01 6d3a1a004e chore(deps): bump actions/setup-python from 5 to 6 (#1901)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-29 11:23:10 -07:00
dependabot[bot]andlnx01 e90a18fe0f chore(deps): bump actions/upload-artifact from 4 to 5 (#1909)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-29 11:22:47 -07:00
Ethan Mosbaugh de1e3f4936 chore(ci): skip regression-test workflow if dependabot (#1911) 2025-10-29 10:53:21 -07:00
Noah Campbell 8197ddecfe added --values and --set flags to lint command (#1907)
* added --values and --set flags to lint command

* Update lint_test.go
2025-10-23 13:20:21 -05:00
Noah Campbell 1ff21d1e7a Add Windows build (#1905)
add windows to goreleaser
2025-10-16 12:04:23 -05:00
Noah Campbell 2cebe3d8f6 Support bundle upload functionality works for apps installed via Helm (#1904)
* Gets licenseid and app slug from cluster secrets

* Update upload.go

* Update cluster_resources.go
2025-10-15 13:12:36 -05:00
Noah Campbell 6ffc83dc43 Updated linter (#1903)
* moved linter to new branch

* reads each yaml file separately when given multiple

* split monolith lint file into more reasonably sized files

* github action linter fix

* lint error codes follow the rest of the codebase's standard
2025-10-14 16:25:50 -05:00
Benjamin Yang 21dc4e9b09 Fix ollama windows installer (#1894)
* Fix Windows filename issue in scheduled support bundles

* Fix: Close temp file before executing Ollama installer on Windows

Windows requires files to be closed before they can be executed. This fix
ensures the temporary installer file is properly closed before attempting
to run it, preventing file access errors on Windows systems.
2025-10-14 10:51:52 -05:00
Noah Campbell 5aa088b3b6 Revert unintended commits on main 2025-10-13 15:23:31 -05:00
Noah Campbell 3f5ab9c721 doesnt harcode apiVersion line when looking and figures out which apiVersion to give if none is there 2025-10-13 15:18:43 -05:00
Noah Campbell 0316bb2e12 improved --fix capabilities 2025-10-13 15:18:33 -05:00
Noah Campbell a5f4afb488 added lint subcommand 2025-10-13 15:18:04 -05:00
Noah Campbell b7f499c737 Arbitrary secret key refs and templating in collectors (#1895)
* Uses secrets from cluster

* updated gitignore to stop ignoring needed files

* Delete specs.go.bak

* make fmt

* added preflight to generic loader

* Tells user to run in cluster if using secretKeyRef

* Update loader.go

* Update loader.go
2025-10-13 12:19:37 -05:00
Noah Campbell deab5e49a5 readded auto upload flag for support bundle (#1893)
* readded auto upload flag for support bundle

* updated auto upload message
2025-10-10 10:12:10 -07:00
Benjamin Yang df40c661a2 Fix windows cronjob (#1891)
* Fix Windows filename issue in scheduled support bundles

* fix bugbot
2025-10-10 10:24:48 -05:00
113 changed files with 9474 additions and 1168 deletions
-39
View File
@@ -1,39 +0,0 @@
name: 'Setup Go Environment'
description: 'Setup Go with caching and common environment variables'
inputs:
go-version-file:
description: 'Path to go.mod file'
required: false
default: 'go.mod'
outputs:
go-version:
description: 'The Go version that was installed'
value: ${{ steps.setup-go.outputs.go-version }}
cache-hit:
description: 'Whether the Go cache was hit'
value: ${{ steps.setup-go.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- name: Setup Go
id: setup-go
uses: actions/setup-go@v5
with:
go-version-file: ${{ inputs.go-version-file }}
cache: true
- name: Set Go environment variables
shell: bash
run: |
echo "GOMAXPROCS=2" >> $GITHUB_ENV
echo "GOCACHE=$(go env GOCACHE)" >> $GITHUB_ENV
echo "GOMODCACHE=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Print Go environment
shell: bash
run: |
echo "Go version: $(go version)"
echo "GOOS: $(go env GOOS)"
echo "GOARCH: $(go env GOARCH)"
echo "Cache directory: $(go env GOCACHE)"
echo "Module cache: $(go env GOMODCACHE)"
+13 -13
View File
@@ -30,7 +30,7 @@ jobs:
tidy-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
@@ -39,7 +39,7 @@ jobs:
test-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
@@ -54,12 +54,12 @@ jobs:
compile-preflight:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- run: make generate preflight
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: preflight
path: bin/preflight
@@ -68,13 +68,13 @@ jobs:
runs-on: ubuntu-latest
needs: compile-preflight
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: replicatedhq/action-k3s@main
id: k3s
with:
version: v1.31.2-k3s1
- name: Download preflight binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: preflight
path: bin/
@@ -84,12 +84,12 @@ jobs:
compile-supportbundle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- run: make generate support-bundle
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: support-bundle
path: bin/support-bundle
@@ -98,13 +98,13 @@ jobs:
runs-on: ubuntu-latest
needs: compile-supportbundle
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: replicatedhq/action-k3s@main
id: k3s
with:
version: v1.31.2-k3s1
- name: Download support bundle binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: support-bundle
path: bin/
@@ -116,15 +116,15 @@ jobs:
runs-on: ubuntu-latest
needs: compile-supportbundle
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Download support bundle binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: support-bundle
path: bin/
- run: chmod +x bin/support-bundle
- name: Download preflight binary
uses: actions/download-artifact@v5
uses: actions/download-artifact@v7
with:
name: preflight
path: bin/
+16 -10
View File
@@ -21,7 +21,7 @@ jobs:
support-bundle: ${{ steps.filter.outputs.support-bundle }}
examples: ${{ steps.filter.outputs.examples }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
@@ -44,8 +44,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/setup-go
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Check go mod tidy
run: |
@@ -71,8 +73,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/setup-go
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Setup K3s
uses: replicatedhq/action-k3s@main
@@ -89,10 +93,12 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/setup-go
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- run: make build
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
with:
name: binaries
path: bin/
@@ -118,7 +124,7 @@ jobs:
target: support-bundle-e2e-go-test
needs-k3s: false
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Setup K3s
if: matrix.needs-k3s
@@ -126,7 +132,7 @@ jobs:
with:
version: v1.31.2-k3s1
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v7
with:
name: binaries
path: bin/
+338 -133
View File
@@ -13,44 +13,22 @@ on:
default: false
jobs:
regression-test:
# Build binaries once (shared by all test jobs)
build-binaries:
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-22.04
timeout-minutes: 25
timeout-minutes: 5
steps:
# 1. SETUP
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0 # Fetch all history for git describe to work
- name: Create output directory
run: mkdir -p test/output
- name: Create k3s cluster
id: create-cluster
uses: replicatedhq/compatibility-actions/create-cluster@v1
with:
api-token: ${{ secrets.REPLICATED_API_TOKEN }}
kubernetes-distribution: k3s
cluster-name: regression-${{ github.run_id }}-${{ github.run_attempt }}
ttl: 25m
timeout-minutes: 5
- name: Configure kubeconfig
run: |
echo "${{ steps.create-cluster.outputs.cluster-kubeconfig }}" > $GITHUB_WORKSPACE/kubeconfig.yaml
echo "KUBECONFIG=$GITHUB_WORKSPACE/kubeconfig.yaml" >> $GITHUB_ENV
- name: Verify cluster access
run: kubectl get nodes -o wide
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
cache-dependency-path: go.sum
go-version-file: 'go.mod'
- name: Build binaries
run: |
@@ -59,84 +37,83 @@ jobs:
./bin/preflight version
./bin/support-bundle version
- name: Upload binaries
uses: actions/upload-artifact@v6
with:
name: binaries-${{ github.run_id }}
path: |
bin/preflight
bin/support-bundle
retention-days: 1
# Preflight v1beta3 test (parallel job 1)
test-preflight-v1beta3:
needs: [build-binaries]
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Create output directory
run: mkdir -p test/output
- name: Create k3s cluster
uses: replicatedhq/action-k3s@main
with:
version: v1.31.2-k3s1
- name: Verify cluster access
run: kubectl get nodes -o wide
- name: Wait for all pods to be ready
run: |
echo "Waiting for all pods to be running..."
kubectl get pods --all-namespaces
kubectl wait --for=condition=Ready pods --all --all-namespaces --timeout=300s || true
kubectl get pods --all-namespaces
- name: Download binaries
uses: actions/download-artifact@v7
with:
name: binaries-${{ github.run_id }}
path: bin/
- name: Make binaries executable
run: |
chmod +x bin/preflight bin/support-bundle
./bin/preflight version
- name: Setup Python for comparison
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install Python dependencies
run: |
pip install pyyaml deepdiff
run: pip install pyyaml deepdiff
# 2. EXECUTE SPECS (in parallel)
- name: Run all specs in parallel
- name: Run preflight v1beta3
continue-on-error: true
run: |
echo "Running all 3 specs in parallel..."
echo "Running preflight v1beta3..."
./bin/preflight \
examples/preflight/complex-v1beta3.yaml \
--values examples/preflight/values-complex-full.yaml \
--interactive=false \
--format=json \
--auto-update=false \
--output=test/output/preflight-results-v1beta3.json 2>&1 | tee test/output/v1beta3.log || true
# Run v1beta3 in background
(
echo "Starting preflight v1beta3..."
./bin/preflight \
examples/preflight/complex-v1beta3.yaml \
--values examples/preflight/values-complex-full.yaml \
--interactive=false \
--format=json \
--output=test/output/preflight-results-v1beta3.json 2>&1 | tee test/output/v1beta3.log || true
BUNDLE=$(ls -t preflightbundle-*.tar.gz 2>/dev/null | head -1)
if [ -n "$BUNDLE" ]; then
mv "$BUNDLE" test/output/preflight-v1beta3-bundle.tar.gz
echo "✓ v1beta3 bundle saved"
fi
BUNDLE=$(ls -t preflightbundle-*.tar.gz 2>/dev/null | head -1)
if [ -n "$BUNDLE" ]; then
mv "$BUNDLE" test/output/preflight-v1beta3-bundle.tar.gz
echo "✓ v1beta3 bundle saved"
fi
) &
PID_V1BETA3=$!
# Run v1beta2 in background
(
echo "Starting preflight v1beta2..."
./bin/preflight \
examples/preflight/all-analyzers-v1beta2.yaml \
--interactive=false \
--format=json \
--output=test/output/preflight-results-v1beta2.json 2>&1 | tee test/output/v1beta2.log || true
BUNDLE=$(ls -t preflightbundle-*.tar.gz 2>/dev/null | head -1)
if [ -n "$BUNDLE" ]; then
mv "$BUNDLE" test/output/preflight-v1beta2-bundle.tar.gz
echo "✓ v1beta2 bundle saved"
fi
) &
PID_V1BETA2=$!
# Run support bundle in background
(
echo "Starting support bundle..."
./bin/support-bundle \
examples/collect/host/all-kubernetes-collectors.yaml \
--interactive=false \
--output=test/output/supportbundle.tar.gz 2>&1 | tee test/output/supportbundle.log || true
if [ -f test/output/supportbundle.tar.gz ]; then
echo "✓ Support bundle saved"
fi
) &
PID_SUPPORTBUNDLE=$!
# Wait for all to complete
echo "Waiting for all specs to complete..."
wait $PID_V1BETA3
wait $PID_V1BETA2
wait $PID_SUPPORTBUNDLE
echo "All specs completed!"
# Verify bundles exist
ls -lh test/output/*.tar.gz || echo "Warning: Some bundles may be missing"
# 3. COMPARE BUNDLES
- name: Compare preflight v1beta3 bundle
id: compare-v1beta3
id: compare
continue-on-error: true
run: |
echo "Comparing v1beta3 preflight bundle against baseline..."
@@ -153,8 +130,93 @@ jobs:
--report test/output/diff-report-v1beta3.json \
--spec-type preflight
- name: Upload test artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
with:
name: test-results-v1beta3-${{ github.run_id }}-${{ github.run_attempt }}
path: |
test/output/preflight-v1beta3-bundle.tar.gz
test/output/preflight-results-v1beta3.json
test/output/diff-report-v1beta3.json
test/output/v1beta3.log
retention-days: 30
- name: Set job outcome
if: ${{ !cancelled() }}
run: |
if [ "${{ steps.compare.outcome }}" == "failure" ] && [ "${{ steps.compare.outputs.baseline_missing }}" != "true" ]; then
echo "comparison_failed=true" >> $GITHUB_OUTPUT
exit 1
fi
# Preflight v1beta2 test (parallel job 2)
test-preflight-v1beta2:
needs: [build-binaries]
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Create output directory
run: mkdir -p test/output
- name: Create k3s cluster
uses: replicatedhq/action-k3s@main
with:
version: v1.31.2-k3s1
- name: Verify cluster access
run: kubectl get nodes -o wide
- name: Wait for all pods to be ready
run: |
echo "Waiting for all pods to be running..."
kubectl get pods --all-namespaces
kubectl wait --for=condition=Ready pods --all --all-namespaces --timeout=300s || true
kubectl get pods --all-namespaces
- name: Download binaries
uses: actions/download-artifact@v7
with:
name: binaries-${{ github.run_id }}
path: bin/
- name: Make binaries executable
run: |
chmod +x bin/preflight bin/support-bundle
./bin/preflight version
- name: Setup Python for comparison
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install Python dependencies
run: pip install pyyaml deepdiff
- name: Run preflight v1beta2
continue-on-error: true
run: |
echo "Running preflight v1beta2..."
./bin/preflight \
examples/preflight/all-analyzers-v1beta2.yaml \
--interactive=false \
--format=json \
--auto-update=false \
--output=test/output/preflight-results-v1beta2.json 2>&1 | tee test/output/v1beta2.log || true
BUNDLE=$(ls -t preflightbundle-*.tar.gz 2>/dev/null | head -1)
if [ -n "$BUNDLE" ]; then
mv "$BUNDLE" test/output/preflight-v1beta2-bundle.tar.gz
echo "✓ v1beta2 bundle saved"
fi
- name: Compare preflight v1beta2 bundle
id: compare-v1beta2
id: compare
continue-on-error: true
run: |
echo "Comparing v1beta2 preflight bundle against baseline..."
@@ -171,8 +233,90 @@ jobs:
--report test/output/diff-report-v1beta2.json \
--spec-type preflight
- name: Upload test artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
with:
name: test-results-v1beta2-${{ github.run_id }}-${{ github.run_attempt }}
path: |
test/output/preflight-v1beta2-bundle.tar.gz
test/output/preflight-results-v1beta2.json
test/output/diff-report-v1beta2.json
test/output/v1beta2.log
retention-days: 30
- name: Set job outcome
if: ${{ !cancelled() }}
run: |
if [ "${{ steps.compare.outcome }}" == "failure" ] && [ "${{ steps.compare.outputs.baseline_missing }}" != "true" ]; then
echo "comparison_failed=true" >> $GITHUB_OUTPUT
exit 1
fi
# Support bundle test (parallel job 3)
test-supportbundle:
needs: [build-binaries]
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-22.04
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Create output directory
run: mkdir -p test/output
- name: Create k3s cluster
uses: replicatedhq/action-k3s@main
with:
version: v1.31.2-k3s1
- name: Verify cluster access
run: kubectl get nodes -o wide
- name: Wait for all pods to be ready
run: |
echo "Waiting for all pods to be running..."
kubectl get pods --all-namespaces
kubectl wait --for=condition=Ready pods --all --all-namespaces --timeout=300s || true
kubectl get pods --all-namespaces
- name: Download binaries
uses: actions/download-artifact@v7
with:
name: binaries-${{ github.run_id }}
path: bin/
- name: Make binaries executable
run: |
chmod +x bin/preflight bin/support-bundle
./bin/support-bundle version
- name: Setup Python for comparison
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install Python dependencies
run: pip install pyyaml deepdiff
- name: Run support bundle
continue-on-error: true
run: |
echo "Running support bundle..."
./bin/support-bundle \
examples/collect/host/all-kubernetes-collectors.yaml \
--interactive=false \
--auto-update=false \
--output=test/output/supportbundle.tar.gz 2>&1 | tee test/output/supportbundle.log || true
if [ -f test/output/supportbundle.tar.gz ]; then
echo "✓ Support bundle saved"
fi
- name: Compare support bundle
id: compare-supportbundle
id: compare
continue-on-error: true
run: |
echo "Comparing support bundle against baseline..."
@@ -189,45 +333,116 @@ jobs:
--report test/output/diff-report-supportbundle.json \
--spec-type supportbundle
# 4. REPORT RESULTS
- name: Generate summary report
if: always()
run: |
python3 scripts/generate_summary.py \
--reports test/output/diff-report-*.json \
--output-file $GITHUB_STEP_SUMMARY \
--output-console
- name: Upload test artifacts
if: always()
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v6
with:
name: regression-test-results-${{ github.run_id }}-${{ github.run_attempt }}
name: test-results-supportbundle-${{ github.run_id }}-${{ github.run_attempt }}
path: |
test/output/*.tar.gz
test/output/*.json
test/output/supportbundle.tar.gz
test/output/diff-report-supportbundle.json
test/output/supportbundle.log
retention-days: 30
- name: Set job outcome
if: ${{ !cancelled() }}
run: |
if [ "${{ steps.compare.outcome }}" == "failure" ] && [ "${{ steps.compare.outputs.baseline_missing }}" != "true" ]; then
echo "comparison_failed=true" >> $GITHUB_OUTPUT
exit 1
fi
# Report results (runs after all tests complete)
report-results:
needs: [test-preflight-v1beta3, test-preflight-v1beta2, test-supportbundle]
if: ${{ !cancelled() && github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-22.04
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install Python dependencies
run: pip install pyyaml deepdiff
- name: Download all test artifacts
uses: actions/download-artifact@v7
with:
path: test/output
pattern: test-results-*
- name: Reorganize artifacts
run: |
# Move artifacts from nested directories to test/output
# Use shopt to handle glob patterns that don't match
shopt -s nullglob
for dir in test/output/test-results-*; do
if [ -d "$dir" ]; then
find "$dir" -type f -exec mv {} test/output/ \;
fi
done
# Clean up empty directories
for dir in test/output/test-results-*; do
if [ -d "$dir" ]; then
find "$dir" -type d -empty -delete || true
fi
done
shopt -u nullglob
- name: Generate summary report
run: |
# Handle case where no reports exist
shopt -s nullglob
REPORTS=(test/output/diff-report-*.json)
shopt -u nullglob
if [ ${#REPORTS[@]} -eq 0 ]; then
echo "⚠ No comparison reports found - test jobs may have failed before generating reports"
echo "## Summary Report" >> $GITHUB_STEP_SUMMARY
echo "No comparison reports available. Check individual test job results." >> $GITHUB_STEP_SUMMARY
else
python3 scripts/generate_summary.py \
--reports test/output/diff-report-*.json \
--output-file $GITHUB_STEP_SUMMARY \
--output-console
fi
- name: Check for regressions
if: always()
run: |
echo "Checking comparison results..."
# Check if any comparisons failed
FAILURES=0
if [ "${{ steps.compare-v1beta3.outcome }}" == "failure" ] && [ "${{ steps.compare-v1beta3.outputs.baseline_missing }}" != "true" ]; then
echo "❌ v1beta3 comparison failed"
if [ "${{ needs.test-preflight-v1beta3.result }}" == "failure" ] || [ "${{ needs.test-preflight-v1beta3.result }}" == "skipped" ]; then
if [ "${{ needs.test-preflight-v1beta3.result }}" == "skipped" ]; then
echo "❌ v1beta3 test was skipped (likely due to build failure)"
else
echo "❌ v1beta3 comparison failed"
fi
FAILURES=$((FAILURES + 1))
fi
if [ "${{ steps.compare-v1beta2.outcome }}" == "failure" ] && [ "${{ steps.compare-v1beta2.outputs.baseline_missing }}" != "true" ]; then
echo "❌ v1beta2 comparison failed"
if [ "${{ needs.test-preflight-v1beta2.result }}" == "failure" ] || [ "${{ needs.test-preflight-v1beta2.result }}" == "skipped" ]; then
if [ "${{ needs.test-preflight-v1beta2.result }}" == "skipped" ]; then
echo "❌ v1beta2 test was skipped (likely due to build failure)"
else
echo "❌ v1beta2 comparison failed"
fi
FAILURES=$((FAILURES + 1))
fi
if [ "${{ steps.compare-supportbundle.outcome }}" == "failure" ] && [ "${{ steps.compare-supportbundle.outputs.baseline_missing }}" != "true" ]; then
echo "❌ Support bundle comparison failed"
if [ "${{ needs.test-supportbundle.result }}" == "failure" ] || [ "${{ needs.test-supportbundle.result }}" == "skipped" ]; then
if [ "${{ needs.test-supportbundle.result }}" == "skipped" ]; then
echo "❌ Support bundle test was skipped (likely due to build failure)"
else
echo "❌ Support bundle comparison failed"
fi
FAILURES=$((FAILURES + 1))
fi
@@ -240,9 +455,8 @@ jobs:
echo "✅ All comparisons passed or skipped (no baseline)"
fi
# 5. UPDATE BASELINES (optional, manual trigger only)
- name: Update baselines
if: github.event.inputs.update_baselines == 'true' && github.event_name == 'workflow_dispatch'
if: ${{ !cancelled() && github.event.inputs.update_baselines == 'true' && github.event_name == 'workflow_dispatch' }}
run: |
echo "Updating baselines with current bundles..."
@@ -270,7 +484,7 @@ jobs:
{
"updated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"git_sha": "${{ github.sha }}",
"k8s_version": "v1.28.3",
"k8s_version": "v1.31.2-k3s1",
"workflow_run": "${{ github.run_id }}"
}
EOF
@@ -281,12 +495,3 @@ jobs:
git add test/baselines/
git commit -m "chore: update regression test baselines from run ${{ github.run_id }}"
git push
# 6. CLEANUP
- name: Remove cluster
if: always()
uses: replicatedhq/compatibility-actions/remove-cluster@v1
continue-on-error: true
with:
api-token: ${{ secrets.REPLICATED_API_TOKEN }}
cluster-id: ${{ steps.create-cluster.outputs.cluster-id }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: troubleshoot_release
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
+24
View File
@@ -0,0 +1,24 @@
name: Upgrade Go Version
on:
# Run manually when needed
workflow_dispatch:
# Run weekly on Mondays at 8am UTC
schedule:
- cron: "0 8 * * MON"
jobs:
upgrade-go:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Check for Go updates
uses: StefMa/Upgrade-Go-Action@v1
with:
base-branch: 'main'
gh-token: ${{ secrets.TROUBLESHOOT_GH_PAT }}
+5 -4
View File
@@ -50,9 +50,10 @@ sbom/
!testdata/supportbundle/*.tar.gz
!test/baselines/**/baseline.tar.gz
# Ignore built binaries
troubleshoot
troubleshoot-test
# Ignore built binaries (use / prefix to avoid catching source files)
/troubleshoot
/troubleshoot-test
cmd/troubleshoot/troubleshoot
cmd/*/troubleshoot
support-bundle
/support-bundle
/.worktrees/
+1 -20
View File
@@ -88,7 +88,7 @@ func extractDocs(templateFiles []string, valuesFiles []string, setValues []strin
if err != nil {
return errors.Wrapf(err, "failed to load values file %s", valuesFile)
}
values = mergeMaps(values, fileValues)
values = preflight.MergeMaps(values, fileValues)
}
// Normalize maps for Helm set merging
@@ -331,25 +331,6 @@ func setNestedValue(m map[string]interface{}, keys []string, value interface{})
}
}
func mergeMaps(base, overlay map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})
for k, v := range base {
result[k] = v
}
for k, v := range overlay {
if baseVal, exists := result[k]; exists {
if baseMap, ok := baseVal.(map[string]interface{}); ok {
if overlayMap, ok := v.(map[string]interface{}); ok {
result[k] = mergeMaps(baseMap, overlayMap)
continue
}
}
}
result[k] = v
}
return result
}
func renderTemplate(templateContent string, values map[string]interface{}) (string, error) {
tmpl := template.New("preflight").Funcs(sprig.FuncMap())
tmpl, err := tmpl.Parse(templateContent)
+100
View File
@@ -0,0 +1,100 @@
package cli
import (
"fmt"
"os"
"github.com/pkg/errors"
"github.com/replicatedhq/troubleshoot/pkg/constants"
"github.com/replicatedhq/troubleshoot/pkg/lint"
"github.com/replicatedhq/troubleshoot/pkg/types"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func LintCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "lint [spec-files...]",
Args: cobra.MinimumNArgs(1),
Short: "Lint v1beta2/v1beta3 preflight specs for syntax and structural errors",
Long: `Lint v1beta2/v1beta3 preflight specs for syntax and structural errors.
This command validates v1beta2/v1beta3 preflight specs and checks for:
- YAML syntax errors
- Missing required fields (apiVersion, kind, metadata, spec)
- Invalid template syntax ({{ .Values.* }})
- Missing analyzers or collectors
- Common structural issues
- Missing docStrings (warning)
Examples:
# Lint a single spec file
preflight lint my-preflight.yaml
# Lint multiple spec files
preflight lint spec1.yaml spec2.yaml spec3.yaml
# Lint with automatic fixes
preflight lint --fix my-preflight.yaml
# Lint and output as JSON for CI/CD integration
preflight lint --format json my-preflight.yaml
Notes:
- v1beta2 does not support templating; template syntax in v1beta2 files will be flagged as errors.
- v1beta3 supports templating and is linted with template-awareness.
Exit codes:
0 - No errors found
2 - Validation errors found`,
PreRun: func(cmd *cobra.Command, args []string) {
viper.BindPFlags(cmd.Flags())
},
RunE: func(cmd *cobra.Command, args []string) error {
v := viper.GetViper()
opts := lint.LintOptions{
FilePaths: args,
Fix: v.GetBool("fix"),
Format: v.GetString("format"),
ValuesFiles: v.GetStringSlice("values"),
SetValues: v.GetStringSlice("set"),
}
return runLint(opts)
},
}
cmd.Flags().Bool("fix", false, "Automatically fix issues where possible")
cmd.Flags().String("format", "text", "Output format: text or json")
cmd.Flags().StringSlice("values", []string{}, "Path to YAML files with template values (required for v1beta3 specs)")
cmd.Flags().StringSlice("set", []string{}, "Set template values via command line (e.g., --set key=value)")
return cmd
}
func runLint(opts lint.LintOptions) error {
// Validate file paths exist
for _, filePath := range opts.FilePaths {
if _, err := os.Stat(filePath); err != nil {
return errors.Wrapf(err, "file not found: %s", filePath)
}
}
// Run linting
results, err := lint.LintFiles(opts)
if err != nil {
return errors.Wrap(err, "failed to lint files")
}
// Format and print results
output := lint.FormatResults(results, opts.Format)
fmt.Print(output)
// Return appropriate exit code
if lint.HasErrors(results) {
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, nil)
}
return nil
}
+1
View File
@@ -89,6 +89,7 @@ that a cluster meets the requirements to run an application.`,
cmd.AddCommand(TemplateCmd())
cmd.AddCommand(DocsCmd())
cmd.AddCommand(ConvertCmd())
cmd.AddCommand(LintCmd())
preflight.AddFlags(cmd.PersistentFlags())
+100
View File
@@ -0,0 +1,100 @@
package cli
import (
"fmt"
"os"
"github.com/pkg/errors"
"github.com/replicatedhq/troubleshoot/pkg/constants"
"github.com/replicatedhq/troubleshoot/pkg/lint"
"github.com/replicatedhq/troubleshoot/pkg/types"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func LintCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "lint [spec-files...]",
Args: cobra.MinimumNArgs(1),
Short: "Lint v1beta2/v1beta3 troubleshoot specs for syntax and structural errors",
Long: `Lint v1beta2/v1beta3 troubleshoot specs (both preflight and support-bundle) for syntax and structural errors.
This command validates v1beta2/v1beta3 troubleshoot specs and checks for:
- YAML syntax errors
- Missing required fields (apiVersion, kind, metadata, spec)
- Invalid template syntax ({{ .Values.* }})
- Missing collectors or hostCollectors
- Common structural issues
- Missing docStrings (warning)
Examples:
# Lint a single spec file
support-bundle lint my-spec.yaml
# Lint multiple spec files
support-bundle lint spec1.yaml spec2.yaml spec3.yaml
# Lint with automatic fixes
support-bundle lint --fix my-spec.yaml
# Lint and output as JSON for CI/CD integration
support-bundle lint --format json my-spec.yaml
Notes:
- v1beta2 does not support templating; template syntax in v1beta2 files will be flagged as errors.
- v1beta3 supports templating and is linted with template-awareness.
Exit codes:
0 - No errors found
2 - Validation errors found`,
PreRun: func(cmd *cobra.Command, args []string) {
viper.BindPFlags(cmd.Flags())
},
RunE: func(cmd *cobra.Command, args []string) error {
v := viper.GetViper()
opts := lint.LintOptions{
FilePaths: args,
Fix: v.GetBool("fix"),
Format: v.GetString("format"),
ValuesFiles: v.GetStringSlice("values"),
SetValues: v.GetStringSlice("set"),
}
return runLint(opts)
},
}
cmd.Flags().Bool("fix", false, "Automatically fix issues where possible")
cmd.Flags().String("format", "text", "Output format: text or json")
cmd.Flags().StringSlice("values", []string{}, "Path to YAML files with template values (required for v1beta3 specs)")
cmd.Flags().StringSlice("set", []string{}, "Set template values via command line (e.g., --set key=value)")
return cmd
}
func runLint(opts lint.LintOptions) error {
// Validate file paths exist
for _, filePath := range opts.FilePaths {
if _, err := os.Stat(filePath); err != nil {
return errors.Wrapf(err, "file not found: %s", filePath)
}
}
// Run linting
results, err := lint.LintFiles(opts)
if err != nil {
return errors.Wrap(err, "failed to lint files")
}
// Format and print results
output := lint.FormatResults(results, opts.Format)
fmt.Print(output)
// Return appropriate exit code
if lint.HasErrors(results) {
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, nil)
}
return nil
}
+21 -1
View File
@@ -5,10 +5,14 @@ import (
"os"
"strings"
"errors"
"github.com/replicatedhq/troubleshoot/cmd/internal/util"
"github.com/replicatedhq/troubleshoot/internal/traces"
"github.com/replicatedhq/troubleshoot/pkg/constants"
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
"github.com/replicatedhq/troubleshoot/pkg/logger"
"github.com/replicatedhq/troubleshoot/pkg/types"
"github.com/replicatedhq/troubleshoot/pkg/updater"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@@ -108,6 +112,7 @@ If no arguments are provided, specs are automatically loaded from the cluster by
cmd.AddCommand(Diff())
cmd.AddCommand(Schedule())
cmd.AddCommand(UploadCmd())
cmd.AddCommand(LintCmd())
cmd.AddCommand(util.VersionCmd())
cmd.Flags().StringSlice("redactors", []string{}, "names of the additional redactors to use")
@@ -132,6 +137,12 @@ If no arguments are provided, specs are automatically loaded from the cluster by
cmd.Flags().Bool("dry-run", false, "print support bundle spec without collecting anything")
cmd.Flags().Bool("auto-update", true, "enable automatic binary self-update check and install")
// Upload flags
cmd.Flags().Bool("auto-upload", false, "automatically upload resulting bundle to replicated.app")
cmd.Flags().String("license-id", "", "license ID for authentication when uploading (auto-detected from bundle if not provided)")
cmd.Flags().String("app-slug", "", "application slug when uploading (auto-detected from bundle if not provided)")
cmd.Flags().String("upload-domain", "", "custom domain for upload (default: replicated.app)")
// Auto-discovery flags
cmd.Flags().Bool("auto", false, "enable auto-discovery of foundational collectors. When used with YAML specs, adds foundational collectors to YAML collectors. When used alone, collects only foundational data")
cmd.Flags().Bool("include-images", false, "include container image metadata collection when using auto-discovery")
@@ -161,7 +172,16 @@ If no arguments are provided, specs are automatically loaded from the cluster by
}
func InitAndExecute() {
if err := RootCmd().Execute(); err != nil {
cmd := RootCmd()
if err := cmd.Execute(); err != nil {
var exitErr types.ExitError
if errors.As(err, &exitErr) {
if exitErr.ExitStatus() != constants.EXIT_CODE_FAIL && exitErr.ExitStatus() != constants.EXIT_CODE_WARN {
cmd.PrintErrln("Error:", err.Error())
}
os.Exit(exitErr.ExitStatus())
}
cmd.PrintErrln("Error:", err.Error())
os.Exit(1)
}
}
+23 -2
View File
@@ -242,6 +242,26 @@ func runTroubleshoot(v *viper.Viper, args []string) error {
}
}
// Attempt auto-upload before any early returns
if v.GetBool("auto-upload") && !response.FileUploaded {
licenseID := v.GetString("license-id")
appSlug := v.GetString("app-slug")
uploadDomain := v.GetString("upload-domain")
targetDomain := uploadDomain
if targetDomain == "" {
targetDomain = "replicated.app"
}
fmt.Fprintf(os.Stderr, "Auto-uploading bundle to %s...\n", targetDomain)
if err := supportbundle.UploadBundleAutoDetect(response.ArchivePath, licenseID, appSlug, uploadDomain); err != nil {
fmt.Fprintf(os.Stderr, "Auto-upload failed: %v\n", err)
fmt.Fprintf(os.Stderr, "You can manually upload the bundle using: support-bundle upload %s\n", response.ArchivePath)
} else {
response.FileUploaded = true
}
}
if !response.FileUploaded {
if appName := mainBundle.Labels["applicationName"]; appName != "" {
f := `A support bundle for %s has been created in this directory
@@ -269,11 +289,12 @@ the %s Admin Console to begin analysis.`
fmt.Printf("\r%s\r", cursor.ClearEntireLine())
}
if response.FileUploaded {
fmt.Printf("A support bundle has been created and uploaded to your cluster for analysis. Please visit the Troubleshoot page to continue.\n")
fmt.Printf("A support bundle has been created and uploaded to replicated.app for analysis.\n")
fmt.Printf("A copy of this support bundle was written to the current directory, named %q\n", response.ArchivePath)
} else {
fmt.Printf("A support bundle has been created in the current directory named %q\n", response.ArchivePath)
}
return nil
}
@@ -497,7 +518,7 @@ func (a *analysisOutput) FormattedAnalysisOutput() (outputJson string, err error
formatted, err := json.MarshalIndent(o, "", " ")
if err != nil {
return "", fmt.Errorf("\r * Failed to format analysis: %v\n", err)
return "", fmt.Errorf("\r * Failed to format analysis: %v", err)
}
return string(formatted), nil
}
+7 -2
View File
@@ -26,7 +26,10 @@ Examples:
support-bundle upload bundle.tar.gz --license-id YOUR_LICENSE_ID
# Specify both license and app
support-bundle upload bundle.tar.gz --license-id YOUR_LICENSE_ID --app-slug my-app`,
support-bundle upload bundle.tar.gz --license-id YOUR_LICENSE_ID --app-slug my-app
# Upload to a custom domain (e.g., development environment)
support-bundle upload bundle.tar.gz --upload-domain replicated-app-dev.example.com`,
RunE: func(cmd *cobra.Command, args []string) error {
v := viper.GetViper()
bundlePath := args[0]
@@ -39,9 +42,10 @@ Examples:
// Get upload parameters
licenseID := v.GetString("license-id")
appSlug := v.GetString("app-slug")
uploadDomain := v.GetString("upload-domain")
// Use auto-detection for uploads
if err := supportbundle.UploadBundleAutoDetect(bundlePath, licenseID, appSlug); err != nil {
if err := supportbundle.UploadBundleAutoDetect(bundlePath, licenseID, appSlug, uploadDomain); err != nil {
return errors.Wrap(err, "upload failed")
}
@@ -51,6 +55,7 @@ Examples:
cmd.Flags().String("license-id", "", "license ID for authentication (auto-detected from bundle if not provided)")
cmd.Flags().String("app-slug", "", "application slug (auto-detected from bundle if not provided)")
cmd.Flags().String("upload-domain", "", "custom domain for upload (default: replicated.app)")
return cmd
}
+2 -2
View File
@@ -8,7 +8,7 @@ builds:
- id: preflight
main: ./cmd/preflight/main.go
env: [CGO_ENABLED=0]
goos: [linux, darwin]
goos: [linux, darwin, windows]
goarch: [amd64, arm, arm64]
ignore:
- goos: windows
@@ -31,7 +31,7 @@ builds:
- id: support-bundle
main: ./cmd/troubleshoot/main.go
env: [CGO_ENABLED=0]
goos: [linux, darwin]
goos: [linux, darwin, windows]
goarch: [amd64, arm, arm64]
ignore:
- goos: windows
@@ -0,0 +1,59 @@
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: test-v1beta3-secretref
spec:
collectors:
# Test 1: PostgreSQL with URI from secret
- postgres:
collectorName: postgres-with-secret
uri:
valueFrom:
secretKeyRef:
name: test-database-credentials
key: postgres-uri
# This will fail to connect (fake server) but that's OK -
# we're testing secret resolution, not actual DB connectivity
# Test 2: PostgreSQL with TLS certs from secret
- postgres:
collectorName: postgres-with-tls
uri:
value: "postgresql://testuser:testpass@localhost:5432/testdb"
tls:
cacert:
valueFrom:
secretKeyRef:
name: test-database-credentials
key: ca.crt
clientCert:
valueFrom:
secretKeyRef:
name: test-database-credentials
key: client.crt
clientKey:
valueFrom:
secretKeyRef:
name: test-database-credentials
key: client.key
# Test 3: MySQL with URI from secret
- mysql:
collectorName: mysql-with-secret
uri:
valueFrom:
secretKeyRef:
name: test-database-credentials
key: mysql-uri
# Test 4: Redis with URI from secret
- redis:
collectorName: redis-with-secret
uri:
valueFrom:
secretKeyRef:
name: test-database-credentials
key: redis-uri
# Test 5: Literal value (no secret) for comparison
- clusterInfo: {}
+39
View File
@@ -0,0 +1,39 @@
---
# Secret containing database credentials
apiVersion: v1
kind: Secret
metadata:
name: test-database-credentials
namespace: default
type: Opaque
stringData:
# PostgreSQL connection URI
postgres-uri: "postgresql://testuser:supersecret@postgres.example.com:5432/testdb?sslmode=require"
# MySQL connection URI
mysql-uri: "mysql://testuser:supersecret@mysql.example.com:3306/testdb"
# Redis connection URI
redis-uri: "redis://:supersecret@redis.example.com:6379"
# TLS certificates (example data)
ca.crt: |
-----BEGIN CERTIFICATE-----
MIICpDCCAYwCCQDU+pQ3ZUD30jANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls
b2NhbGhvc3QwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjAUMRIwEAYD
VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC7
VJTUt9Us8cKjMzEfYyjiWA4R4/M2bS1+fWIcPm15A8IgC0qC1J3xGhE=
-----END CERTIFICATE-----
client.crt: |
-----BEGIN CERTIFICATE-----
MIICpDCCAYwCCQDU+pQ3ZUD30jANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls
b2NhbGhvc3QwHhcNMjQwMTAxMDAwMDAwWhcNMjUwMTAxMDAwMDAwWjAUMRIwEAYD
VQQDDA5jbGllbnQtY2VydA==
-----END CERTIFICATE-----
client.key: |
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7VJTUt9Us8cKj
MzEfYyjiWA4R4/M2bS1+fWIcPm15A8IgC0qC1J3xGhE=
-----END PRIVATE KEY-----
+1 -1
View File
@@ -92,7 +92,7 @@ spec:
- nodeResources:
checkName: Must have 1 node with 2Gi (available) memory and at least 2 cores (on a single node)
filters:
allocatableMemory: 2Gi
memoryAllocatable: 2Gi
cpuCapacity: "2"
outcomes:
- pass:
+3 -3
View File
@@ -28,7 +28,7 @@ spec:
- nodeResources:
checkName: Must have 1 node with 16 GB (available) memory and 10 cores (on a single node)
filters:
allocatableMemory: 16Gi
memoryAllocatable: 16Gi
cpuCapacity: "10"
outcomes:
- fail:
@@ -39,7 +39,7 @@ spec:
- nodeResources:
checkName: Must have 1 node with 16 GB (available) memory and 4 cores of amd64 arch (on a single node)
filters:
allocatableMemory: 16Gi
memoryAllocatable: 16Gi
cpuArchitecture: amd64
cpuCapacity: "4"
outcomes:
@@ -54,7 +54,7 @@ spec:
selector:
matchLabel:
node-role.kubernetes.io/master: ""
allocatableMemory: 16Gi
memoryAllocatable: 16Gi
cpuArchitecture: amd64
cpuCapacity: "6"
outcomes:
+20 -25
View File
@@ -1,6 +1,6 @@
module helm-template
go 1.24.6
go 1.25.5
// Always use the local version of troubleshoot so as to build using
// the latest version of the library. This will ensure the example
@@ -8,18 +8,17 @@ go 1.24.6
replace github.com/replicatedhq/troubleshoot v0.0.0 => ../../../
require (
github.com/replicatedhq/troubleshoot v0.0.0
helm.sh/helm/v3 v3.19.0
helm.sh/helm/v3 v3.20.0
sigs.k8s.io/yaml v1.6.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
@@ -28,9 +27,7 @@ require (
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
@@ -46,28 +43,26 @@ require (
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.34.1 // indirect
k8s.io/apiextensions-apiserver v0.34.1 // indirect
k8s.io/apimachinery v0.34.1 // indirect
k8s.io/client-go v0.34.1 // indirect
k8s.io/api v0.35.0 // indirect
k8s.io/apiextensions-apiserver v0.35.0 // indirect
k8s.io/apimachinery v0.35.0 // indirect
k8s.io/client-go v0.35.0 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
sigs.k8s.io/controller-runtime v0.22.1 // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
)
+42 -103
View File
@@ -1,27 +1,24 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
@@ -32,21 +29,12 @@ github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
@@ -55,12 +43,6 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
@@ -75,17 +57,12 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw=
github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
@@ -93,95 +70,57 @@ github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+D
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
helm.sh/helm/v3 v3.19.0 h1:krVyCGa8fa/wzTZgqw0DUiXuRT5BPdeqE/sQXujQ22k=
helm.sh/helm/v3 v3.19.0/go.mod h1:Lk/SfzN0w3a3C3o+TdAKrLwJ0wcZ//t1/SDXAvfgDdc=
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI=
k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc=
k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4=
k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
helm.sh/helm/v3 v3.20.0 h1:2M+0qQwnbI1a2CxN7dbmfsWHg/MloeaFMnZCY56as50=
helm.sh/helm/v3 v3.20.0/go.mod h1:rTavWa0lagZOxGfdhu4vgk1OjH2UYCnrDKE2PVC4N0o=
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4=
k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU=
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y=
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/controller-runtime v0.22.1 h1:Ah1T7I+0A7ize291nJZdS1CabF/lB4E++WizgV24Eqg=
sigs.k8s.io/controller-runtime v0.22.1/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
+1 -1
View File
@@ -77,7 +77,7 @@ spec:
- nodeResources:
checkName: Must have 1 node with 2Gi (available) memory and at least 2 cores (on a single node)
filters:
allocatableMemory: 2Gi
memoryAllocatable: 2Gi
cpuCapacity: "2"
outcomes:
- pass:
+236
View File
@@ -0,0 +1,236 @@
# v1beta3 Support Bundle Examples
This directory contains example Support Bundle specs using the v1beta3 API, which introduces `StringOrValueFrom` support for securely referencing Kubernetes Secrets and ConfigMaps in collector fields.
## Features
### StringOrValueFrom Pattern
The v1beta3 API introduces a Kubernetes-native pattern for referencing sensitive values:
```yaml
uri:
valueFrom:
secretKeyRef:
name: my-secret
key: connection-uri
```
or
```yaml
uri: "postgresql://localhost:5432/db" # Literal value
```
### Supported Collectors
Currently, v1beta3 supports `StringOrValueFrom` for:
- **Database collectors**: `postgres`, `mysql`, `redis`, `mssql`
- `uri` field - Connection strings from secrets
- `tls` fields - CA cert, client cert, and client key from secrets
## Examples
### 1. postgres-with-secret.yaml
Basic PostgreSQL collector with connection URI from a secret.
**Use case**: Securely store database credentials without hardcoding them in the spec.
```bash
kubectl apply -f postgres-with-secret.yaml
```
### 2. postgres-with-tls.yaml
PostgreSQL with TLS configuration from secrets.
**Use case**: Secure database connections with mutual TLS, storing certificates in secrets.
```bash
kubectl apply -f postgres-with-tls.yaml
```
### 3. multiple-databases.yaml
Multiple database collectors (PostgreSQL, MySQL, Redis, MSSQL) with various configurations.
**Use case**: Collect diagnostics from multiple databases in your application stack.
```bash
kubectl apply -f multiple-databases.yaml
```
### 4. cross-namespace-secrets.yaml
Accessing secrets from different namespaces.
**Use case**: Centralized credential management in a shared namespace.
```bash
kubectl apply -f cross-namespace-secrets.yaml
```
**RBAC Requirements**: The support bundle service account needs `get` permission on secrets in the referenced namespaces.
### 5. optional-secrets.yaml
Using the `optional` field for graceful degradation.
**Use case**: Collect diagnostics even when some credentials are unavailable (e.g., optional secondary databases).
```bash
kubectl apply -f optional-secrets.yaml
```
### 6. configmap-example.yaml
Using ConfigMaps for non-sensitive configuration.
**Use case**: Store non-sensitive connection strings (e.g., development databases) in ConfigMaps.
```bash
kubectl apply -f configmap-example.yaml
```
## Key Concepts
### Secret vs ConfigMap
- **Secrets**: Use for sensitive data (passwords, tokens, certificates)
- **ConfigMaps**: Use for non-sensitive configuration (development endpoints, feature flags)
### Optional Field
```yaml
uri:
valueFrom:
secretKeyRef:
name: my-secret
key: uri
optional: true # Returns empty string if secret/key doesn't exist
```
- `optional: false` (default): Collection fails if secret is missing
- `optional: true`: Returns empty string if secret/key is missing
### Cross-Namespace Access
```yaml
uri:
valueFrom:
secretKeyRef:
name: shared-secret
key: uri
namespace: other-namespace # Access secrets in different namespaces
```
If `namespace` is not specified, uses the support bundle's namespace.
### Backward Compatibility
v1beta3 maintains backward compatibility with v1beta2 TLS configuration:
```yaml
tls:
secret: # v1beta2 style
name: tls-secret
namespace: default
```
## RBAC Configuration
Support bundles need appropriate RBAC permissions to read secrets:
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: troubleshoot-secret-reader
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["postgres-connection", "redis-creds"] # Restrict to specific secrets
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: troubleshoot-secret-reader-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: troubleshoot-secret-reader
subjects:
- kind: ServiceAccount
name: troubleshoot
namespace: default
```
## Migration from v1beta2
### Before (v1beta2):
```yaml
apiVersion: troubleshoot.sh/v1beta2
kind: SupportBundle
spec:
collectors:
- postgres:
uri: "postgresql://user:password@host:5432/db" # Hardcoded
```
### After (v1beta3):
```yaml
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
spec:
collectors:
- postgres:
uri:
valueFrom:
secretKeyRef:
name: postgres-connection
key: connection-uri
```
## Limitations
1. **No value composition**: Cannot combine multiple secrets into a single value
```yaml
# NOT SUPPORTED
uri: "postgresql://$(USERNAME):$(PASSWORD)@host:5432/db"
```
Store the complete connection string in a single secret key.
2. **Collector scope**: Only database collectors support `StringOrValueFrom` initially
- Future versions will extend to HTTP, Data, and other collectors
3. **No templating**: The entire field value comes from one source
## Security Best Practices
1. **Use resourceNames in RBAC**: Restrict access to specific secrets
2. **Separate secrets**: Don't reuse secrets across applications
3. **Rotate credentials**: Update secrets regularly
4. **Audit access**: Monitor secret access logs
5. **Redact output**: Ensure connection strings are redacted in bundle output
## Troubleshooting
### Error: "failed to get secret default/my-secret"
- **Cause**: Secret doesn't exist or RBAC denied access
- **Solution**: Verify secret exists: `kubectl get secret my-secret`
- **Solution**: Check RBAC: `kubectl auth can-i get secret/my-secret`
### Error: "key 'uri' not found in secret"
- **Cause**: Secret exists but doesn't contain the specified key
- **Solution**: Check secret keys: `kubectl get secret my-secret -o jsonpath='{.data}'`
### Error: "cannot specify both 'value' and 'valueFrom'"
- **Cause**: Both literal value and secret reference provided
- **Solution**: Use only one: either `value: "string"` or `valueFrom: {...}`
## Additional Resources
- [Troubleshoot Documentation](https://troubleshoot.sh)
- [v1beta3 API Reference](https://troubleshoot.sh/docs/v1beta3/)
- [Kubernetes Secrets](https://kubernetes.io/docs/concepts/configuration/secret/)
- [RBAC Authorization](https://kubernetes.io/docs/reference/access-authn-authz/rbac/)
@@ -0,0 +1,57 @@
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: configmap-example
spec:
collectors:
# Database URI from ConfigMap (non-sensitive connection string)
- postgres:
collectorName: dev-database
uri:
valueFrom:
configMapKeyRef:
name: database-config
key: dev-connection-uri
# Redis URI from ConfigMap
- redis:
collectorName: dev-redis
uri:
valueFrom:
configMapKeyRef:
name: cache-config
key: redis-uri
# Mixed: URI from ConfigMap, password from Secret
# Note: This shows the limitation - you can't compose values from multiple sources
# The full connection string must be in one place
- mysql:
collectorName: staging-mysql
uri:
valueFrom:
secretKeyRef:
name: mysql-secret
key: complete-connection-string
---
apiVersion: v1
kind: ConfigMap
metadata:
name: database-config
data:
dev-connection-uri: "postgresql://devuser@dev-postgres.default.svc:5432/devdb"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: cache-config
data:
redis-uri: "redis://dev-redis.default.svc:6379/0"
---
apiVersion: v1
kind: Secret
metadata:
name: mysql-secret
type: Opaque
stringData:
# Complete connection string with password included
complete-connection-string: "mysql://staging:stagingpass@staging-mysql:3306/stagingdb"
@@ -0,0 +1,45 @@
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: cross-namespace-example
spec:
collectors:
# Database in one namespace, secret in another
- postgres:
collectorName: shared-database
uri:
valueFrom:
secretKeyRef:
name: shared-postgres-connection
key: uri
namespace: shared-services # Secret is in a different namespace
# Redis accessing centralized credentials
- redis:
collectorName: shared-cache
uri:
valueFrom:
secretKeyRef:
name: shared-redis-creds
key: uri
namespace: platform-credentials
---
# This secret would be in the 'shared-services' namespace
apiVersion: v1
kind: Secret
metadata:
name: shared-postgres-connection
namespace: shared-services
type: Opaque
stringData:
uri: "postgresql://shared:password@shared-postgres.shared-services.svc:5432/shared_db"
---
# This secret would be in the 'platform-credentials' namespace
apiVersion: v1
kind: Secret
metadata:
name: shared-redis-creds
namespace: platform-credentials
type: Opaque
stringData:
uri: "redis://shared-redis.shared-services.svc:6379/0"
@@ -0,0 +1,78 @@
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: multi-database-support-bundle
spec:
collectors:
# PostgreSQL with secret reference
- postgres:
collectorName: primary-db
uri:
valueFrom:
secretKeyRef:
name: postgres-primary
key: connection-uri
# PostgreSQL replica with secret reference
- postgres:
collectorName: replica-db
uri:
valueFrom:
secretKeyRef:
name: postgres-replica
key: connection-uri
# Redis cache with secret reference
- redis:
collectorName: cache
uri:
valueFrom:
secretKeyRef:
name: redis-creds
key: uri
# MySQL with literal value (for development/testing)
- mysql:
collectorName: local-mysql
uri: "mysql://root:password@localhost:3306/testdb"
# MSSQL with secret reference
- mssql:
collectorName: legacy-db
uri:
valueFrom:
secretKeyRef:
name: mssql-connection
key: dsn
---
apiVersion: v1
kind: Secret
metadata:
name: postgres-primary
type: Opaque
stringData:
connection-uri: "postgresql://app:secret123@postgres-primary.default.svc:5432/appdb"
---
apiVersion: v1
kind: Secret
metadata:
name: postgres-replica
type: Opaque
stringData:
connection-uri: "postgresql://app:secret123@postgres-replica.default.svc:5432/appdb"
---
apiVersion: v1
kind: Secret
metadata:
name: redis-creds
type: Opaque
stringData:
uri: "redis://:cachesecret@redis.default.svc:6379/0"
---
apiVersion: v1
kind: Secret
metadata:
name: mssql-connection
type: Opaque
stringData:
dsn: "sqlserver://sa:Str0ngP@ssw0rd@mssql.default.svc:1433?database=legacy"
@@ -0,0 +1,58 @@
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: optional-secrets-example
spec:
collectors:
# Required database - collection will fail if secret doesn't exist
- postgres:
collectorName: required-db
uri:
valueFrom:
secretKeyRef:
name: required-postgres
key: uri
optional: false # Default behavior - secret must exist
# Optional database - collection continues if secret doesn't exist
- postgres:
collectorName: optional-db
uri:
valueFrom:
secretKeyRef:
name: optional-postgres
key: uri
optional: true # Gracefully degrades if secret is missing
# Mixed required and optional TLS
- postgres:
collectorName: partially-optional
uri: "postgresql://localhost:5432/db"
tls:
cacert:
valueFrom:
secretKeyRef:
name: tls-certs
key: ca.crt
optional: false # CA cert is required
clientCert:
valueFrom:
secretKeyRef:
name: tls-certs
key: client.crt
optional: true # Client cert is optional (cert-only TLS)
clientKey:
valueFrom:
secretKeyRef:
name: tls-certs
key: client.key
optional: true # Client key is optional
---
apiVersion: v1
kind: Secret
metadata:
name: required-postgres
type: Opaque
stringData:
uri: "postgresql://user:pass@required-postgres:5432/db"
# Note: optional-postgres secret intentionally not created
@@ -0,0 +1,21 @@
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: postgres-support-bundle
spec:
collectors:
- postgres:
collectorName: main-database
uri:
valueFrom:
secretKeyRef:
name: postgres-connection
key: connection-uri
---
apiVersion: v1
kind: Secret
metadata:
name: postgres-connection
type: Opaque
stringData:
connection-uri: "postgresql://myuser:mypassword@postgres.default.svc:5432/mydb?sslmode=require"
@@ -0,0 +1,47 @@
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: postgres-tls-support-bundle
spec:
collectors:
- postgres:
collectorName: secure-database
uri:
valueFrom:
secretKeyRef:
name: postgres-connection
key: connection-uri
tls:
cacert:
valueFrom:
secretKeyRef:
name: postgres-tls
key: ca.crt
clientCert:
valueFrom:
secretKeyRef:
name: postgres-tls
key: tls.crt
clientKey:
valueFrom:
secretKeyRef:
name: postgres-tls
key: tls.key
---
apiVersion: v1
kind: Secret
metadata:
name: postgres-connection
type: Opaque
stringData:
connection-uri: "postgresql://myuser:mypassword@postgres.default.svc:5432/mydb?sslmode=verify-full"
---
apiVersion: v1
kind: Secret
metadata:
name: postgres-tls
type: kubernetes.io/tls
data:
ca.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi4uLgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0t
tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi4uLgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0t
tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCi4uLgotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0t
@@ -0,0 +1,33 @@
apiVersion: troubleshoot.sh/v1beta3
kind: Preflight
metadata:
name: helm-builtins-example
labels:
release: {{ .Release.Name }}
spec:
analyzers:
- docString: |
Title: Example using Helm builtin objects
Requirement: Demonstrates .Values, .Release, .Chart, etc.
Supported Helm builtin objects:
- .Values.* - User-provided values
- .Release.Name - Release name (default: "preflight")
- .Release.Namespace - Release namespace (default: "default")
- .Release.IsInstall - Whether this is an install (true)
- .Release.IsUpgrade - Whether this is an upgrade (false)
- .Release.Revision - Release revision (1)
- .Chart.Name - Chart name
- .Chart.Version - Chart version
- .Capabilities.KubeVersion - Kubernetes version capabilities
clusterVersion:
checkName: Kubernetes version check in {{ .Release.Namespace }}
outcomes:
- fail:
when: '< {{ .Values.minVersion | default "1.19.0" }}'
message: |
Release {{ .Release.Name }} requires Kubernetes {{ .Values.minVersion | default "1.19.0" }} or later.
Chart: {{ .Chart.Name }}
- pass:
when: '>= {{ .Values.minVersion | default "1.19.0" }}'
message: Kubernetes version is supported for release {{ .Release.Name }}
@@ -0,0 +1,19 @@
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: invalid-collectors
spec:
collectors:
# Unknown collector type
- notACollector: {}
# Known collector but missing required fields (e.g., ceph requires namespace)
- ceph: {}
# Field exists but wrong type (should be a list)
hostCollectors: "not-a-list"
analyzers:
# Unknown analyzer type
- notAnAnalyzer: {}
# Known analyzer missing required 'outcomes'
- cephStatus:
namespace: default
@@ -0,0 +1,8 @@
apiVersion: troubleshoot.sh/v1beta3
kind: Preflight
metadata
name: invalid-yaml
spec:
analyzers:
- clusterVersion:
checkName: Kubernetes version
@@ -0,0 +1,11 @@
kind: Preflight
metadata:
name: missing-apiversion
spec:
analyzers:
- clusterVersion:
checkName: Kubernetes version
outcomes:
- pass:
when: '>= 1.19.0'
message: Kubernetes version is supported
@@ -0,0 +1,10 @@
apiVersion: troubleshoot.sh/v1beta3
kind: Preflight
spec:
analyzers:
- clusterVersion:
checkName: Kubernetes version
outcomes:
- pass:
when: '>= 1.19.0'
message: Kubernetes version is supported
@@ -0,0 +1,7 @@
apiVersion: troubleshoot.sh/v1beta3
kind: Preflight
metadata:
name: no-analyzers
spec:
collectors:
- clusterInfo: {}
@@ -0,0 +1,18 @@
apiVersion: troubleshoot.sh/v1beta3
kind: Preflight
metadata:
name: simple-no-template
spec:
analyzers:
- docString: |
Title: Kubernetes Version Check
Requirement: Kubernetes 1.19.0 or later
clusterVersion:
checkName: Kubernetes version
outcomes:
- fail:
when: '< 1.19.0'
message: Kubernetes version must be at least 1.19.0
- pass:
when: '>= 1.19.0'
message: Kubernetes version is supported
@@ -0,0 +1,12 @@
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: no-collectors
spec:
analyzers:
- clusterVersion:
checkName: Kubernetes version
outcomes:
- pass:
when: '>= 1.19.0'
message: Kubernetes version is supported
@@ -0,0 +1,15 @@
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: valid-support-bundle
spec:
collectors:
- clusterInfo: {}
- clusterResources: {}
analyzers:
- clusterVersion:
checkName: Kubernetes version
outcomes:
- pass:
when: '>= 1.19.0'
message: Kubernetes version is supported
@@ -0,0 +1,15 @@
apiVersion: troubleshoot.sh/v1beta3
kind: Preflight
metadata:
name: valid-preflight
spec:
analyzers:
- docString: |
Title: Test Analyzer
Requirement: Test requirement
clusterVersion:
checkName: Kubernetes version
outcomes:
- pass:
when: '>= 1.19.0'
message: Kubernetes version is supported
@@ -0,0 +1,2 @@
# Empty values file for v1beta3 specs without templates
{}
@@ -0,0 +1 @@
minVersion: "1.19.0"
@@ -0,0 +1,12 @@
apiVersion: troubleshoot.sh/v1beta2
kind: Preflight
metadata:
name: wrong-version
spec:
analyzers:
- clusterVersion:
checkName: Kubernetes version
outcomes:
- pass:
when: '>= 1.19.0'
message: Kubernetes version is supported
+112 -121
View File
@@ -1,6 +1,6 @@
module github.com/replicatedhq/troubleshoot
go 1.24.6
go 1.26.0
require (
github.com/Masterminds/sprig/v3 v3.3.0
@@ -8,105 +8,105 @@ require (
github.com/apparentlymart/go-cidr v1.1.0
github.com/blang/semver/v4 v4.0.0
github.com/casbin/govaluate v1.10.0
github.com/cilium/ebpf v0.19.0
github.com/containerd/cgroups/v3 v3.0.5
github.com/containers/image/v5 v5.36.2
github.com/cilium/ebpf v0.20.0
github.com/containerd/cgroups/v3 v3.1.3
github.com/distribution/distribution/v3 v3.0.0
github.com/fatih/color v1.18.0
github.com/go-logr/logr v1.4.3
github.com/go-redis/redis/v7 v7.4.1
github.com/go-sql-driver/mysql v1.9.3
github.com/gobwas/glob v0.2.3
github.com/godbus/dbus/v5 v5.1.0
github.com/godbus/dbus/v5 v5.2.2
github.com/google/gofuzz v1.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/handlers v1.5.2
github.com/hashicorp/go-getter v1.8.2
github.com/hashicorp/go-getter v1.8.4
github.com/hashicorp/go-multierror v1.1.1
github.com/jackc/pgx/v5 v5.7.6
github.com/jackc/pgx/v5 v5.8.0
github.com/longhorn/go-iscsi-helper v0.0.0-20210330030558-49a327fb024e
github.com/manifoldco/promptui v0.9.0
github.com/mattn/go-isatty v0.0.20
github.com/microsoft/go-mssqldb v1.9.3
github.com/miekg/dns v1.1.68
github.com/microsoft/go-mssqldb v1.9.6
github.com/miekg/dns v1.1.72
github.com/opencontainers/image-spec v1.1.1
github.com/pkg/errors v0.9.1
github.com/replicatedhq/termui/v3 v3.1.1-0.20200811145416-f40076d26851
github.com/segmentio/ksuid v1.0.4
github.com/shirou/gopsutil/v4 v4.25.9
github.com/spf13/cobra v1.10.1
github.com/shirou/gopsutil/v4 v4.26.1
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/tj/go-spin v1.1.0
github.com/vishvananda/netlink v1.3.1
github.com/vishvananda/netns v0.0.5
github.com/vmware-tanzu/velero v1.17.0
go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/otel/sdk v1.38.0
github.com/vmware-tanzu/velero v1.17.2
go.opentelemetry.io/otel v1.40.0
go.opentelemetry.io/otel/sdk v1.40.0
go.podman.io/image/v5 v5.39.1
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67
golang.org/x/mod v0.28.0
golang.org/x/sync v0.17.0
golang.org/x/mod v0.33.0
golang.org/x/sync v0.19.0
gopkg.in/yaml.v2 v2.4.0
k8s.io/api v0.34.1
k8s.io/apiextensions-apiserver v0.34.1
k8s.io/apimachinery v0.34.1
k8s.io/apiserver v0.34.1
k8s.io/cli-runtime v0.34.1
k8s.io/client-go v0.34.1
k8s.io/api v0.35.1
k8s.io/apiextensions-apiserver v0.35.1
k8s.io/apimachinery v0.35.1
k8s.io/apiserver v0.35.1
k8s.io/cli-runtime v0.35.1
k8s.io/client-go v0.35.1
k8s.io/klog/v2 v2.130.1
k8s.io/kubernetes v1.34.1
oras.land/oras-go v1.2.6
sigs.k8s.io/controller-runtime v0.22.2
oras.land/oras-go/v2 v2.6.0
sigs.k8s.io/controller-runtime v0.23.1
sigs.k8s.io/e2e-framework v0.6.0
)
require (
cel.dev/expr v0.24.0 // indirect
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/monitoring v1.24.2 // indirect
cyphar.com/go-pathrs v0.2.1 // indirect
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
filippo.io/edwards25519 v1.1.1 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
github.com/MakeNowJust/heredoc v1.0.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.15 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.68 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.20 // indirect
github.com/aws/smithy-go v1.22.3 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.6 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/chai2010/gettext-go v1.0.2 // indirect
github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
@@ -114,18 +114,18 @@ require (
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-containerregistry v0.20.3 // indirect
github.com/google/go-containerregistry v0.20.6 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/gosuri/uitable v0.0.4 // indirect
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.65 // indirect
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.70 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -134,7 +134,7 @@ require (
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mistifyio/go-zfs/v3 v3.0.1 // indirect
github.com/mistifyio/go-zfs/v4 v4.0.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
@@ -143,66 +143,60 @@ require (
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/rubenv/sql-migrate v1.8.0 // indirect
github.com/rubenv/sql-migrate v1.8.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/sylabs/sif/v2 v2.21.1 // indirect
github.com/sylabs/sif/v2 v2.22.0 // indirect
github.com/tchap/go-patricia/v2 v2.3.3 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/vladimirvivien/gexe v0.4.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.36.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/tools v0.36.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
k8s.io/component-base v0.34.1 // indirect
k8s.io/kubectl v0.34.0 // indirect
oras.land/oras-go/v2 v2.6.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
k8s.io/component-base v0.35.1 // indirect
k8s.io/kubectl v0.35.0 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect
)
require (
cloud.google.com/go v0.121.1 // indirect
cloud.google.com/go/iam v1.5.2 // indirect
cloud.google.com/go/storage v1.55.0 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/storage v1.58.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Microsoft/hcsshim v0.13.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
github.com/c9s/goprocinfo v0.0.0-20170724085704-0010a05ce49f // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chzyer/readline v1.5.1 // indirect
github.com/containerd/containerd v1.7.28 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
github.com/containerd/containerd v1.7.30 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 // indirect
github.com/containers/ocicrypt v1.2.1 // indirect
github.com/containers/storage v1.59.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/docker/cli v28.3.2+incompatible // indirect
github.com/docker/docker v28.3.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/docker/docker v28.5.1+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.4 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/evanphx/json-patch v5.9.11+incompatible // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -212,21 +206,19 @@ require (
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-intervals v0.0.2 // indirect
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-version v1.7.0
github.com/hashicorp/go-version v1.8.0
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
github.com/mailru/easyjson v0.9.0 // indirect
@@ -234,7 +226,6 @@ require (
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1
github.com/moby/locker v1.0.1 // indirect
github.com/moby/spdystream v0.5.0 // indirect
github.com/moby/sys/mountinfo v0.7.2 // indirect
github.com/moby/term v0.5.2 // indirect
@@ -244,45 +235,45 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/runtime-spec v1.2.1
github.com/opencontainers/selinux v1.12.0 // indirect
github.com/opencontainers/runtime-spec v1.3.0
github.com/opencontainers/selinux v1.13.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/vbatts/tar-split v0.12.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/vbatts/tar-split v0.12.2 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/net v0.44.0
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.36.0
golang.org/x/term v0.35.0 // indirect
golang.org/x/text v0.29.0
golang.org/x/time v0.12.0 // indirect
google.golang.org/api v0.241.0 // indirect
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 // indirect
google.golang.org/grpc v1.73.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
go.podman.io/storage v1.62.1-0.20260218215809-4bd29ff8b87e // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sys v0.41.0
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0
golang.org/x/time v0.14.0 // indirect
google.golang.org/api v0.256.0 // indirect
google.golang.org/genproto v0.0.0-20250922171735-9219d122eba9 // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
helm.sh/helm/v3 v3.19.0
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
k8s.io/kubelet v0.34.1
k8s.io/metrics v0.34.1
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397
helm.sh/helm/v3 v3.20.0
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
k8s.io/kubelet v0.35.1
k8s.io/metrics v0.35.1
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4
periph.io/x/host/v3 v3.8.5
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/kustomize/api v0.20.1 // indirect
sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect
sigs.k8s.io/yaml v1.6.0
+250 -395
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -191,7 +191,9 @@ func LoadFromCLIArgs(ctx context.Context, client kubernetes.Interface, args []st
// load URL spec first to remove URI key from the spec
urlSpec, err := loader.LoadSpecs(ctx, loader.LoadOptions{
RawSpec: rawURLSpec,
RawSpec: rawURLSpec,
Client: client,
Namespace: vp.GetString("namespace"),
})
if err != nil {
fmt.Println(color.YellowString("failed to load spec from URI %q: %v\n", v, err))
@@ -209,7 +211,9 @@ func LoadFromCLIArgs(ctx context.Context, client kubernetes.Interface, args []st
}
kinds, err := loader.LoadSpecs(ctx, loader.LoadOptions{
RawSpecs: rawSpecs,
RawSpecs: rawSpecs,
Client: client,
Namespace: vp.GetString("namespace"),
})
if err != nil {
return nil, err
@@ -363,7 +367,9 @@ func LoadFromCluster(ctx context.Context, client kubernetes.Interface, selectors
// Load troubleshoot specs from the raw specs
return loader.LoadSpecs(ctx, loader.LoadOptions{
RawSpecs: rawSpecs,
RawSpecs: rawSpecs,
Client: client,
Namespace: ns,
})
}
+2
View File
@@ -194,6 +194,8 @@ func GetAnalyzer(analyzer *troubleshootv1beta2.Analyze) Analyzer {
return &AnalyzeClusterVersion{analyzer: analyzer.ClusterVersion}
case analyzer.StorageClass != nil:
return &AnalyzeStorageClass{analyzer: analyzer.StorageClass}
case analyzer.IngressClass != nil:
return &AnalyzeIngressClass{analyzer: analyzer.IngressClass}
case analyzer.CustomResourceDefinition != nil:
return &AnalyzeCustomResourceDefinition{analyzer: analyzer.CustomResourceDefinition}
case analyzer.Ingress != nil:
+87
View File
@@ -0,0 +1,87 @@
package analyzer
import (
"encoding/json"
"fmt"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/constants"
networkingv1 "k8s.io/api/networking/v1"
)
type AnalyzeIngressClass struct {
analyzer *troubleshootv1beta2.IngressClass
}
func (a *AnalyzeIngressClass) Title() string {
title := a.analyzer.CheckName
if title == "" {
if a.analyzer.IngressClassName != "" {
title = fmt.Sprintf("Ingress class %s", a.analyzer.IngressClassName)
} else {
title = "Default Ingress Class"
}
}
return title
}
func (a *AnalyzeIngressClass) IsExcluded() (bool, error) {
return isExcluded(a.analyzer.Exclude)
}
func (a *AnalyzeIngressClass) Analyze(getFile getCollectedFileContents, findFiles getChildCollectedFileContents) ([]*AnalyzeResult, error) {
result, err := a.analyzeIngressClass(a.analyzer, getFile)
if err != nil {
return nil, err
}
result.Strict = a.analyzer.Strict.BoolOrDefaultFalse()
return []*AnalyzeResult{result}, nil
}
func (a *AnalyzeIngressClass) analyzeIngressClass(analyzer *troubleshootv1beta2.IngressClass, getCollectedFileContents func(string) ([]byte, error)) (*AnalyzeResult, error) {
ingressClassesData, err := getCollectedFileContents(fmt.Sprintf("%s/%s.json", constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_INGRESS_CLASS))
if err != nil {
return nil, err
}
var ingressClasses networkingv1.IngressClassList
if err := json.Unmarshal(ingressClassesData, &ingressClasses); err != nil {
return nil, err
}
result := AnalyzeResult{
Title: a.Title(),
IconKey: "kubernetes_ingress_class",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/ingress-class.svg?w=12&h=12",
}
for _, ingressClass := range ingressClasses.Items {
val := ingressClass.Annotations["ingressclass.kubernetes.io/is-default-class"]
if (ingressClass.Name == analyzer.IngressClassName) || (analyzer.IngressClassName == "" && val == "true") {
result.IsPass = true
for _, outcome := range analyzer.Outcomes {
if outcome.Pass != nil {
result.Message = outcome.Pass.Message
result.URI = outcome.Pass.URI
}
}
if analyzer.IngressClassName == "" && result.Message == "" {
result.Message = "Default Ingress Class found"
}
return &result, nil
}
}
result.IsFail = true
for _, outcome := range analyzer.Outcomes {
if outcome.Fail != nil {
result.Message = outcome.Fail.Message
result.URI = outcome.Fail.URI
}
}
if analyzer.IngressClassName == "" && result.Message == "" {
result.Message = "No Default Ingress Class found"
}
return &result, nil
}
+202
View File
@@ -0,0 +1,202 @@
package analyzer
import (
"encoding/json"
"testing"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
networkingv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestAnalyzeIngressClass(t *testing.T) {
tests := []struct {
name string
analyzer *troubleshootv1beta2.IngressClass
ingressList *networkingv1.IngressClassList
expectResult AnalyzeResult
}{
{
name: "named ingress class found",
analyzer: &troubleshootv1beta2.IngressClass{
IngressClassName: "nginx",
Outcomes: []*troubleshootv1beta2.Outcome{
{
Pass: &troubleshootv1beta2.SingleOutcome{
Message: "nginx ingress class found",
},
},
{
Fail: &troubleshootv1beta2.SingleOutcome{
Message: "nginx ingress class not found",
},
},
},
},
ingressList: &networkingv1.IngressClassList{
Items: []networkingv1.IngressClass{
{ObjectMeta: metav1.ObjectMeta{Name: "nginx"}},
},
},
expectResult: AnalyzeResult{
IsPass: true,
Title: "Ingress class nginx",
Message: "nginx ingress class found",
IconKey: "kubernetes_ingress_class",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/ingress-class.svg?w=12&h=12",
},
},
{
name: "named ingress class not found",
analyzer: &troubleshootv1beta2.IngressClass{
IngressClassName: "nginx",
Outcomes: []*troubleshootv1beta2.Outcome{
{
Pass: &troubleshootv1beta2.SingleOutcome{
Message: "nginx ingress class found",
},
},
{
Fail: &troubleshootv1beta2.SingleOutcome{
Message: "nginx ingress class not found",
},
},
},
},
ingressList: &networkingv1.IngressClassList{
Items: []networkingv1.IngressClass{
{ObjectMeta: metav1.ObjectMeta{Name: "traefik"}},
},
},
expectResult: AnalyzeResult{
IsFail: true,
Title: "Ingress class nginx",
Message: "nginx ingress class not found",
IconKey: "kubernetes_ingress_class",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/ingress-class.svg?w=12&h=12",
},
},
{
name: "default ingress class found",
analyzer: &troubleshootv1beta2.IngressClass{
Outcomes: []*troubleshootv1beta2.Outcome{
{
Pass: &troubleshootv1beta2.SingleOutcome{
Message: "default ingress class exists",
},
},
{
Fail: &troubleshootv1beta2.SingleOutcome{
Message: "no default ingress class",
},
},
},
},
ingressList: &networkingv1.IngressClassList{
Items: []networkingv1.IngressClass{
{
ObjectMeta: metav1.ObjectMeta{
Name: "nginx",
Annotations: map[string]string{
"ingressclass.kubernetes.io/is-default-class": "true",
},
},
},
},
},
expectResult: AnalyzeResult{
IsPass: true,
Title: "Default Ingress Class",
Message: "default ingress class exists",
IconKey: "kubernetes_ingress_class",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/ingress-class.svg?w=12&h=12",
},
},
{
name: "default ingress class not found",
analyzer: &troubleshootv1beta2.IngressClass{
Outcomes: []*troubleshootv1beta2.Outcome{
{
Pass: &troubleshootv1beta2.SingleOutcome{
Message: "default ingress class exists",
},
},
{
Fail: &troubleshootv1beta2.SingleOutcome{
Message: "no default ingress class",
},
},
},
},
ingressList: &networkingv1.IngressClassList{
Items: []networkingv1.IngressClass{
{ObjectMeta: metav1.ObjectMeta{Name: "nginx"}},
},
},
expectResult: AnalyzeResult{
IsFail: true,
Title: "Default Ingress Class",
Message: "no default ingress class",
IconKey: "kubernetes_ingress_class",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/ingress-class.svg?w=12&h=12",
},
},
{
name: "default ingress class not found with default message",
analyzer: &troubleshootv1beta2.IngressClass{
Outcomes: []*troubleshootv1beta2.Outcome{},
},
ingressList: &networkingv1.IngressClassList{},
expectResult: AnalyzeResult{
IsFail: true,
Title: "Default Ingress Class",
Message: "No Default Ingress Class found",
IconKey: "kubernetes_ingress_class",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/ingress-class.svg?w=12&h=12",
},
},
{
name: "default ingress class found with default message",
analyzer: &troubleshootv1beta2.IngressClass{
Outcomes: []*troubleshootv1beta2.Outcome{},
},
ingressList: &networkingv1.IngressClassList{
Items: []networkingv1.IngressClass{
{
ObjectMeta: metav1.ObjectMeta{
Name: "nginx",
Annotations: map[string]string{
"ingressclass.kubernetes.io/is-default-class": "true",
},
},
},
},
},
expectResult: AnalyzeResult{
IsPass: true,
Title: "Default Ingress Class",
Message: "Default Ingress Class found",
IconKey: "kubernetes_ingress_class",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/ingress-class.svg?w=12&h=12",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b, err := json.Marshal(tt.ingressList)
require.NoError(t, err)
getFile := func(_ string) ([]byte, error) {
return b, nil
}
a := AnalyzeIngressClass{analyzer: tt.analyzer}
result, err := a.analyzeIngressClass(tt.analyzer, getFile)
require.NoError(t, err)
assert.Equal(t, tt.expectResult, *result)
})
}
}
+2 -2
View File
@@ -12,11 +12,11 @@ import (
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/kubernetes/pkg/util/taints"
"github.com/replicatedhq/troubleshoot/internal/util"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/constants"
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
)
type AnalyzeNodeResources struct {
@@ -453,7 +453,7 @@ func nodeMatchesFilters(node corev1.Node, filters *troubleshootv1beta2.NodeResou
}
if filters.Taint != nil {
return taints.TaintExists(node.Spec.Taints, filters.Taint), nil
return k8sutil.TaintExists(node.Spec.Taints, filters.Taint), nil
}
if filters.CPUArchitecture != "" {
+8 -1
View File
@@ -146,7 +146,7 @@ func (h *OllamaHelper) downloadAndInstallWindows() error {
return errors.Wrap(err, "failed to create temporary file")
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
defer tmpFile.Close() // Ensures file is closed in error paths
// Download installer
resp, err := http.Get(h.downloadURL)
@@ -165,6 +165,13 @@ func (h *OllamaHelper) downloadAndInstallWindows() error {
return errors.Wrap(err, "failed to write installer")
}
// Close the file before executing it (required on Windows)
// Note: This will be called twice (here and via defer), but that's safe
// The defer ensures cleanup on error paths, this ensures closure before execution
if err := tmpFile.Close(); err != nil {
return errors.Wrap(err, "failed to close installer file")
}
// Run installer
klog.Info("Running Ollama installer...")
cmd := exec.Command(tmpFile.Name())
@@ -18,6 +18,12 @@ type StorageClass struct {
StorageClassName string `json:"storageClassName,omitempty" yaml:"storageClassName,omitempty"`
}
type IngressClass struct {
AnalyzeMeta `json:",inline" yaml:",inline"`
Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"`
IngressClassName string `json:"ingressClassName,omitempty" yaml:"ingressClassName,omitempty"`
}
type CustomResourceDefinition struct {
AnalyzeMeta `json:",inline" yaml:",inline"`
Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"`
@@ -276,6 +282,7 @@ type PVCRef struct {
type Analyze struct {
ClusterVersion *ClusterVersion `json:"clusterVersion,omitempty" yaml:"clusterVersion,omitempty"`
StorageClass *StorageClass `json:"storageClass,omitempty" yaml:"storageClass,omitempty"`
IngressClass *IngressClass `json:"ingressClass,omitempty" yaml:"ingressClass,omitempty"`
CustomResourceDefinition *CustomResourceDefinition `json:"customResourceDefinition,omitempty" yaml:"customResourceDefinition,omitempty"`
Ingress *Ingress `json:"ingress,omitempty" yaml:"ingress,omitempty"`
Secret *AnalyzeSecret `json:"secret,omitempty" yaml:"secret,omitempty"`
@@ -65,6 +65,11 @@ func (in *Analyze) DeepCopyInto(out *Analyze) {
*out = new(StorageClass)
(*in).DeepCopyInto(*out)
}
if in.IngressClass != nil {
in, out := &in.IngressClass, &out.IngressClass
*out = new(IngressClass)
(*in).DeepCopyInto(*out)
}
if in.CustomResourceDefinition != nil {
in, out := &in.CustomResourceDefinition, &out.CustomResourceDefinition
*out = new(CustomResourceDefinition)
@@ -3039,6 +3044,33 @@ func (in *Ingress) DeepCopy() *Ingress {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *IngressClass) DeepCopyInto(out *IngressClass) {
*out = *in
in.AnalyzeMeta.DeepCopyInto(&out.AnalyzeMeta)
if in.Outcomes != nil {
in, out := &in.Outcomes, &out.Outcomes
*out = make([]*Outcome, len(*in))
for i := range *in {
if (*in)[i] != nil {
in, out := &(*in)[i], &(*out)[i]
*out = new(Outcome)
(*in).DeepCopyInto(*out)
}
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IngressClass.
func (in *IngressClass) DeepCopy() *IngressClass {
if in == nil {
return nil
}
out := new(IngressClass)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *JobStatus) DeepCopyInto(out *JobStatus) {
*out = *in
@@ -0,0 +1,78 @@
package v1beta3
import (
"github.com/replicatedhq/troubleshoot/pkg/multitype"
)
// CollectorMeta contains metadata for collectors
type CollectorMeta struct {
CollectorName string `json:"collectorName,omitempty" yaml:"collectorName,omitempty"`
// +optional
Exclude *multitype.BoolOrString `json:"exclude,omitempty" yaml:"exclude,omitempty"`
}
// Database represents database collectors (PostgreSQL, MySQL, Redis, MSSQL)
// In v1beta3, URI and TLS fields support valueFrom references
type Database struct {
CollectorMeta `json:",inline" yaml:",inline"`
// URI can be a literal value or reference to a Secret/ConfigMap
URI StringOrValueFrom `json:"uri" yaml:"uri"`
// Parameters for the database connection
Parameters []string `json:"parameters,omitempty"`
// TLS configuration with support for valueFrom references
TLS *TLSParams `json:"tls,omitempty" yaml:"tls,omitempty"`
}
// TLSParams contains TLS configuration
// In v1beta3, certificate fields support valueFrom references
type TLSParams struct {
// SkipVerify disables TLS verification
SkipVerify bool `json:"skipVerify,omitempty" yaml:"skipVerify,omitempty"`
// Secret references a Kubernetes Secret containing TLS materials (v1beta2 compatibility)
Secret *TLSSecret `json:"secret,omitempty" yaml:"secret,omitempty"`
// CACert can be a literal value or reference to a Secret/ConfigMap
CACert StringOrValueFrom `json:"cacert,omitempty" yaml:"cacert,omitempty"`
// ClientCert can be a literal value or reference to a Secret/ConfigMap
ClientCert StringOrValueFrom `json:"clientCert,omitempty" yaml:"clientCert,omitempty"`
// ClientKey can be a literal value or reference to a Secret/ConfigMap
ClientKey StringOrValueFrom `json:"clientKey,omitempty" yaml:"clientKey,omitempty"`
}
// TLSSecret references a Kubernetes Secret containing TLS materials
// Maintained for backward compatibility
type TLSSecret struct {
Name string `json:"name" yaml:"name"`
Namespace string `json:"namespace" yaml:"namespace"`
}
// Temporary placeholder types for minimal v1beta3 implementation
// These will be properly defined as we expand v1beta3 support
type AfterCollection struct {
CollectorMeta `json:",inline" yaml:",inline"`
// TODO: Add fields as needed
}
type Analyze struct {
// TODO: Add fields as needed
}
type HostAnalyze struct {
// TODO: Add fields as needed
}
type HostCollect struct {
// TODO: Add fields as needed
}
// Collect contains all collector definitions
// For phase 1, we're focusing on Database collectors with StringOrValueFrom support
type Collect struct {
// Database collectors with v1beta3 StringOrValueFrom support
Postgres *Database `json:"postgres,omitempty" yaml:"postgres,omitempty"`
Mssql *Database `json:"mssql,omitempty" yaml:"mssql,omitempty"`
Mysql *Database `json:"mysql,omitempty" yaml:"mysql,omitempty"`
Redis *Database `json:"redis,omitempty" yaml:"redis,omitempty"`
// TODO: Add remaining collector types as we expand v1beta3 support
// For now, these are placeholders to make the types compile
}
+162
View File
@@ -0,0 +1,162 @@
package v1beta3
import (
"context"
"fmt"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"k8s.io/client-go/kubernetes"
)
// ConvertToV1Beta2WithResolution converts a v1beta3 SupportBundleSpec to v1beta2
// by resolving all StringOrValueFrom fields to their actual values
func ConvertToV1Beta2WithResolution(
ctx context.Context,
v3spec *SupportBundleSpec,
client kubernetes.Interface,
defaultNamespace string,
) (*troubleshootv1beta2.SupportBundleSpec, error) {
v2spec := &troubleshootv1beta2.SupportBundleSpec{
Uri: v3spec.Uri,
RunHostCollectorsInPod: v3spec.RunHostCollectorsInPod,
}
// Convert collectors
if v3spec.Collectors != nil {
v2collectors := make([]*troubleshootv1beta2.Collect, 0, len(v3spec.Collectors))
for _, v3collector := range v3spec.Collectors {
v2collector, err := convertCollector(ctx, v3collector, client, defaultNamespace)
if err != nil {
return nil, fmt.Errorf("failed to convert collector: %w", err)
}
v2collectors = append(v2collectors, v2collector)
}
v2spec.Collectors = v2collectors
}
// TODO: Convert AfterCollection, HostCollectors, Analyzers, HostAnalyzers when v1beta3 support is expanded
return v2spec, nil
}
// convertCollector converts a v1beta3 Collect to v1beta2 Collect
func convertCollector(
ctx context.Context,
v3collector *Collect,
client kubernetes.Interface,
defaultNamespace string,
) (*troubleshootv1beta2.Collect, error) {
v2collector := &troubleshootv1beta2.Collect{}
// Convert database collectors
if v3collector.Postgres != nil {
db, err := convertDatabase(ctx, v3collector.Postgres, client, defaultNamespace)
if err != nil {
return nil, fmt.Errorf("failed to convert postgres collector: %w", err)
}
v2collector.Postgres = db
}
if v3collector.Mysql != nil {
db, err := convertDatabase(ctx, v3collector.Mysql, client, defaultNamespace)
if err != nil {
return nil, fmt.Errorf("failed to convert mysql collector: %w", err)
}
v2collector.Mysql = db
}
if v3collector.Mssql != nil {
db, err := convertDatabase(ctx, v3collector.Mssql, client, defaultNamespace)
if err != nil {
return nil, fmt.Errorf("failed to convert mssql collector: %w", err)
}
v2collector.Mssql = db
}
if v3collector.Redis != nil {
db, err := convertDatabase(ctx, v3collector.Redis, client, defaultNamespace)
if err != nil {
return nil, fmt.Errorf("failed to convert redis collector: %w", err)
}
v2collector.Redis = db
}
// TODO: Add conversion for other collector types as v1beta3 support expands
return v2collector, nil
}
// convertDatabase converts a v1beta3 Database to v1beta2 Database
func convertDatabase(
ctx context.Context,
v3db *Database,
client kubernetes.Interface,
defaultNamespace string,
) (*troubleshootv1beta2.Database, error) {
// Resolve URI
uri, err := ResolveStringOrValueFrom(ctx, v3db.URI, client, defaultNamespace)
if err != nil {
return nil, fmt.Errorf("failed to resolve database URI: %w", err)
}
v2db := &troubleshootv1beta2.Database{
CollectorMeta: troubleshootv1beta2.CollectorMeta{
CollectorName: v3db.CollectorName,
Exclude: v3db.Exclude,
},
URI: uri,
Parameters: v3db.Parameters,
}
// Convert TLS params if present
if v3db.TLS != nil {
tlsParams, err := convertTLSParams(ctx, v3db.TLS, client, defaultNamespace)
if err != nil {
return nil, fmt.Errorf("failed to convert TLS params: %w", err)
}
v2db.TLS = tlsParams
}
return v2db, nil
}
// convertTLSParams converts v1beta3 TLSParams to v1beta2 TLSParams
func convertTLSParams(
ctx context.Context,
v3tls *TLSParams,
client kubernetes.Interface,
defaultNamespace string,
) (*troubleshootv1beta2.TLSParams, error) {
v2tls := &troubleshootv1beta2.TLSParams{
SkipVerify: v3tls.SkipVerify,
}
// Preserve v1beta2 Secret reference if present (backward compatibility)
if v3tls.Secret != nil {
v2tls.Secret = &troubleshootv1beta2.TLSSecret{
Name: v3tls.Secret.Name,
Namespace: v3tls.Secret.Namespace,
}
}
// Resolve v1beta3 StringOrValueFrom fields
caCert, err := ResolveStringOrValueFrom(ctx, v3tls.CACert, client, defaultNamespace)
if err != nil {
return nil, fmt.Errorf("failed to resolve CA cert: %w", err)
}
v2tls.CACert = caCert
clientCert, err := ResolveStringOrValueFrom(ctx, v3tls.ClientCert, client, defaultNamespace)
if err != nil {
return nil, fmt.Errorf("failed to resolve client cert: %w", err)
}
v2tls.ClientCert = clientCert
clientKey, err := ResolveStringOrValueFrom(ctx, v3tls.ClientKey, client, defaultNamespace)
if err != nil {
return nil, fmt.Errorf("failed to resolve client key: %w", err)
}
v2tls.ClientKey = clientKey
return v2tls, nil
}
@@ -0,0 +1,386 @@
package v1beta3
import (
"context"
"testing"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
func TestConvertToV1Beta2WithResolution_PostgresWithLiteralValue(t *testing.T) {
client := fake.NewSimpleClientset()
uri := "postgresql://user:pass@localhost:5432/db"
v3spec := &SupportBundleSpec{
Collectors: []*Collect{
{
Postgres: &Database{
URI: StringOrValueFrom{
Value: &uri,
},
},
},
},
}
v2spec, err := ConvertToV1Beta2WithResolution(context.Background(), v3spec, client, "default")
require.NoError(t, err)
require.NotNil(t, v2spec)
require.Len(t, v2spec.Collectors, 1)
require.NotNil(t, v2spec.Collectors[0].Postgres)
assert.Equal(t, "postgresql://user:pass@localhost:5432/db", v2spec.Collectors[0].Postgres.URI)
}
func TestConvertToV1Beta2WithResolution_PostgresWithSecretRef(t *testing.T) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "db-secret",
Namespace: "default",
},
Data: map[string][]byte{
"uri": []byte("postgresql://user:secret-pass@db.example.com:5432/mydb"),
},
}
client := fake.NewSimpleClientset(secret)
v3spec := &SupportBundleSpec{
Collectors: []*Collect{
{
Postgres: &Database{
URI: StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "db-secret",
Key: "uri",
},
},
},
},
},
},
}
v2spec, err := ConvertToV1Beta2WithResolution(context.Background(), v3spec, client, "default")
require.NoError(t, err)
require.NotNil(t, v2spec)
require.Len(t, v2spec.Collectors, 1)
require.NotNil(t, v2spec.Collectors[0].Postgres)
assert.Equal(t, "postgresql://user:secret-pass@db.example.com:5432/mydb", v2spec.Collectors[0].Postgres.URI)
}
func TestConvertToV1Beta2WithResolution_PostgresWithTLS(t *testing.T) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "tls-secret",
Namespace: "default",
},
Data: map[string][]byte{
"ca.crt": []byte("-----BEGIN CERTIFICATE-----\nCA_CERT_DATA\n-----END CERTIFICATE-----"),
"client.crt": []byte("-----BEGIN CERTIFICATE-----\nCLIENT_CERT_DATA\n-----END CERTIFICATE-----"),
"client.key": []byte("-----BEGIN PRIVATE KEY-----\nCLIENT_KEY_DATA\n-----END PRIVATE KEY-----"),
},
}
client := fake.NewSimpleClientset(secret)
uri := "postgresql://user:pass@localhost:5432/db"
v3spec := &SupportBundleSpec{
Collectors: []*Collect{
{
Postgres: &Database{
URI: StringOrValueFrom{
Value: &uri,
},
TLS: &TLSParams{
CACert: StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "tls-secret",
Key: "ca.crt",
},
},
},
ClientCert: StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "tls-secret",
Key: "client.crt",
},
},
},
ClientKey: StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "tls-secret",
Key: "client.key",
},
},
},
},
},
},
},
}
v2spec, err := ConvertToV1Beta2WithResolution(context.Background(), v3spec, client, "default")
require.NoError(t, err)
require.NotNil(t, v2spec)
require.Len(t, v2spec.Collectors, 1)
require.NotNil(t, v2spec.Collectors[0].Postgres)
require.NotNil(t, v2spec.Collectors[0].Postgres.TLS)
assert.Equal(t, "-----BEGIN CERTIFICATE-----\nCA_CERT_DATA\n-----END CERTIFICATE-----", v2spec.Collectors[0].Postgres.TLS.CACert)
assert.Equal(t, "-----BEGIN CERTIFICATE-----\nCLIENT_CERT_DATA\n-----END CERTIFICATE-----", v2spec.Collectors[0].Postgres.TLS.ClientCert)
assert.Equal(t, "-----BEGIN PRIVATE KEY-----\nCLIENT_KEY_DATA\n-----END PRIVATE KEY-----", v2spec.Collectors[0].Postgres.TLS.ClientKey)
}
func TestConvertToV1Beta2WithResolution_MultipleDatabases(t *testing.T) {
pgSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "postgres-secret",
Namespace: "default",
},
Data: map[string][]byte{
"uri": []byte("postgresql://user:pass@pg.example.com:5432/db"),
},
}
mysqlSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "mysql-secret",
Namespace: "default",
},
Data: map[string][]byte{
"uri": []byte("mysql://user:pass@mysql.example.com:3306/db"),
},
}
redisSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "redis-secret",
Namespace: "default",
},
Data: map[string][]byte{
"uri": []byte("redis://redis.example.com:6379"),
},
}
client := fake.NewSimpleClientset(pgSecret, mysqlSecret, redisSecret)
v3spec := &SupportBundleSpec{
Collectors: []*Collect{
{
Postgres: &Database{
URI: StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "postgres-secret",
Key: "uri",
},
},
},
},
},
{
Mysql: &Database{
URI: StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "mysql-secret",
Key: "uri",
},
},
},
},
},
{
Redis: &Database{
URI: StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "redis-secret",
Key: "uri",
},
},
},
},
},
},
}
v2spec, err := ConvertToV1Beta2WithResolution(context.Background(), v3spec, client, "default")
require.NoError(t, err)
require.NotNil(t, v2spec)
require.Len(t, v2spec.Collectors, 3)
require.NotNil(t, v2spec.Collectors[0].Postgres)
assert.Equal(t, "postgresql://user:pass@pg.example.com:5432/db", v2spec.Collectors[0].Postgres.URI)
require.NotNil(t, v2spec.Collectors[1].Mysql)
assert.Equal(t, "mysql://user:pass@mysql.example.com:3306/db", v2spec.Collectors[1].Mysql.URI)
require.NotNil(t, v2spec.Collectors[2].Redis)
assert.Equal(t, "redis://redis.example.com:6379", v2spec.Collectors[2].Redis.URI)
}
func TestConvertToV1Beta2WithResolution_SecretNotFound(t *testing.T) {
client := fake.NewSimpleClientset()
v3spec := &SupportBundleSpec{
Collectors: []*Collect{
{
Postgres: &Database{
URI: StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "nonexistent-secret",
Key: "uri",
},
},
},
},
},
},
}
_, err := ConvertToV1Beta2WithResolution(context.Background(), v3spec, client, "default")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to convert collector")
assert.Contains(t, err.Error(), "failed to resolve database URI")
}
func TestConvertToV1Beta2WithResolution_PreservesCollectorMeta(t *testing.T) {
client := fake.NewSimpleClientset()
uri := "postgresql://user:pass@localhost:5432/db"
v3spec := &SupportBundleSpec{
Collectors: []*Collect{
{
Postgres: &Database{
CollectorMeta: CollectorMeta{
CollectorName: "my-postgres-collector",
},
URI: StringOrValueFrom{
Value: &uri,
},
Parameters: []string{"sslmode=require"},
},
},
},
}
v2spec, err := ConvertToV1Beta2WithResolution(context.Background(), v3spec, client, "default")
require.NoError(t, err)
require.NotNil(t, v2spec)
require.Len(t, v2spec.Collectors, 1)
require.NotNil(t, v2spec.Collectors[0].Postgres)
assert.Equal(t, "my-postgres-collector", v2spec.Collectors[0].Postgres.CollectorName)
assert.Equal(t, []string{"sslmode=require"}, v2spec.Collectors[0].Postgres.Parameters)
}
func TestConvertToV1Beta2WithResolution_TLSBackwardCompatibility(t *testing.T) {
client := fake.NewSimpleClientset()
uri := "postgresql://user:pass@localhost:5432/db"
v3spec := &SupportBundleSpec{
Collectors: []*Collect{
{
Postgres: &Database{
URI: StringOrValueFrom{
Value: &uri,
},
TLS: &TLSParams{
SkipVerify: true,
Secret: &TLSSecret{
Name: "old-tls-secret",
Namespace: "default",
},
},
},
},
},
}
v2spec, err := ConvertToV1Beta2WithResolution(context.Background(), v3spec, client, "default")
require.NoError(t, err)
require.NotNil(t, v2spec)
require.Len(t, v2spec.Collectors, 1)
require.NotNil(t, v2spec.Collectors[0].Postgres)
require.NotNil(t, v2spec.Collectors[0].Postgres.TLS)
assert.True(t, v2spec.Collectors[0].Postgres.TLS.SkipVerify)
require.NotNil(t, v2spec.Collectors[0].Postgres.TLS.Secret)
assert.Equal(t, "old-tls-secret", v2spec.Collectors[0].Postgres.TLS.Secret.Name)
assert.Equal(t, "default", v2spec.Collectors[0].Postgres.TLS.Secret.Namespace)
}
func TestConvertToV1Beta2WithResolution_EmptySpec(t *testing.T) {
client := fake.NewSimpleClientset()
v3spec := &SupportBundleSpec{}
v2spec, err := ConvertToV1Beta2WithResolution(context.Background(), v3spec, client, "default")
require.NoError(t, err)
require.NotNil(t, v2spec)
assert.Nil(t, v2spec.Collectors)
}
func TestConvertDatabase_AllDatabaseTypes(t *testing.T) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "db-secret",
Namespace: "default",
},
Data: map[string][]byte{
"uri": []byte("test-uri"),
},
}
client := fake.NewSimpleClientset(secret)
v3db := &Database{
URI: StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "db-secret",
Key: "uri",
},
},
},
}
// Test that the same database struct works for all DB types
ctx := context.Background()
pgDB, err := convertDatabase(ctx, v3db, client, "default")
require.NoError(t, err)
assert.Equal(t, "test-uri", pgDB.URI)
mysqlDB, err := convertDatabase(ctx, v3db, client, "default")
require.NoError(t, err)
assert.Equal(t, "test-uri", mysqlDB.URI)
mssqlDB, err := convertDatabase(ctx, v3db, client, "default")
require.NoError(t, err)
assert.Equal(t, "test-uri", mssqlDB.URI)
redisDB, err := convertDatabase(ctx, v3db, client, "default")
require.NoError(t, err)
assert.Equal(t, "test-uri", redisDB.URI)
}
// Helper function to convert v2spec back to ensure type compatibility
func ensureV2SpecCompatibility(v2spec *troubleshootv1beta2.SupportBundleSpec) {
// This function just exists to ensure the types are compatible
// If this compiles, we know the conversion produces valid v1beta2 types
_ = v2spec.Uri
_ = v2spec.Collectors
_ = v2spec.Analyzers
}
+6
View File
@@ -0,0 +1,6 @@
// +k8s:deepcopy-gen=package
// +k8s:defaulter-gen=TypeMeta
// +groupName=troubleshoot.sh
// Package v1beta3 is the v1beta3 version of the API.
package v1beta3
+46
View File
@@ -0,0 +1,46 @@
/*
Copyright 2019 Replicated, Inc..
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.
*/
// NOTE: Boilerplate only. Ignore this file.
// Package v1beta3 contains API Schema definitions for the troubleshoot v1beta3 API group
// +k8s:openapi-gen=true
// +k8s:deepcopy-gen=package,register
// +k8s:conversion-gen=github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot
// +k8s:defaulter-gen=TypeMeta
// +groupName=troubleshoot.sh
package v1beta3
import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
// SchemeGroupVersion is group version used to register these objects
SchemeGroupVersion = schema.GroupVersion{Group: "troubleshoot.sh", Version: "v1beta3"}
// SchemeBuilder is used to add go types to the GroupVersionKind scheme
SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion}
// AddToScheme is required by pkg/client/...
AddToScheme = SchemeBuilder.AddToScheme
)
// Resource is required by pkg/client/listers/...
func Resource(resource string) schema.GroupResource {
return SchemeGroupVersion.WithResource(resource).GroupResource()
}
+117
View File
@@ -0,0 +1,117 @@
package v1beta3
import (
"context"
"fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
// ResolveStringOrValueFrom resolves a StringOrValueFrom to its actual string value
// by fetching from Secrets or ConfigMaps as needed.
//
// Parameters:
// - ctx: Context for the resolution operation
// - sov: The StringOrValueFrom to resolve
// - client: Kubernetes client for fetching Secrets/ConfigMaps
// - defaultNamespace: Namespace to use when not specified in the reference
//
// Returns:
// - The resolved string value
// - An error if resolution fails (unless Optional is true)
func ResolveStringOrValueFrom(
ctx context.Context,
sov StringOrValueFrom,
client kubernetes.Interface,
defaultNamespace string,
) (string, error) {
// If Value is directly specified, use it
if sov.Value != nil {
return *sov.Value, nil
}
// If ValueFrom is not specified, return empty string
if sov.ValueFrom == nil {
return "", nil
}
// Resolve from SecretKeyRef
if sov.ValueFrom.SecretKeyRef != nil {
return resolveSecretKeyRef(ctx, sov.ValueFrom.SecretKeyRef, client, defaultNamespace)
}
// Resolve from ConfigMapKeyRef
if sov.ValueFrom.ConfigMapKeyRef != nil {
return resolveConfigMapKeyRef(ctx, sov.ValueFrom.ConfigMapKeyRef, client, defaultNamespace)
}
return "", nil
}
// resolveSecretKeyRef fetches a value from a Kubernetes Secret
func resolveSecretKeyRef(
ctx context.Context,
ref *SecretKeyRef,
client kubernetes.Interface,
defaultNamespace string,
) (string, error) {
namespace := ref.Namespace
if namespace == "" {
namespace = defaultNamespace
}
secret, err := client.CoreV1().Secrets(namespace).Get(ctx, ref.Name, metav1.GetOptions{})
if err != nil {
if isOptional(ref.Optional) {
return "", nil
}
return "", fmt.Errorf("failed to get secret %s/%s: %w", namespace, ref.Name, err)
}
value, ok := secret.Data[ref.Key]
if !ok {
if isOptional(ref.Optional) {
return "", nil
}
return "", fmt.Errorf("key %q not found in secret %s/%s", ref.Key, namespace, ref.Name)
}
return string(value), nil
}
// resolveConfigMapKeyRef fetches a value from a Kubernetes ConfigMap
func resolveConfigMapKeyRef(
ctx context.Context,
ref *ConfigMapKeyRef,
client kubernetes.Interface,
defaultNamespace string,
) (string, error) {
namespace := ref.Namespace
if namespace == "" {
namespace = defaultNamespace
}
configMap, err := client.CoreV1().ConfigMaps(namespace).Get(ctx, ref.Name, metav1.GetOptions{})
if err != nil {
if isOptional(ref.Optional) {
return "", nil
}
return "", fmt.Errorf("failed to get configmap %s/%s: %w", namespace, ref.Name, err)
}
value, ok := configMap.Data[ref.Key]
if !ok {
if isOptional(ref.Optional) {
return "", nil
}
return "", fmt.Errorf("key %q not found in configmap %s/%s", ref.Key, namespace, ref.Name)
}
return value, nil
}
// isOptional checks if the optional flag is set to true
func isOptional(optional *bool) bool {
return optional != nil && *optional
}
@@ -0,0 +1,333 @@
package v1beta3
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
func TestResolveStringOrValueFrom_LiteralValue(t *testing.T) {
client := fake.NewSimpleClientset()
value := "literal-value"
sov := StringOrValueFrom{
Value: &value,
}
result, err := ResolveStringOrValueFrom(context.Background(), sov, client, "default")
require.NoError(t, err)
assert.Equal(t, "literal-value", result)
}
func TestResolveStringOrValueFrom_EmptyValue(t *testing.T) {
client := fake.NewSimpleClientset()
sov := StringOrValueFrom{}
result, err := ResolveStringOrValueFrom(context.Background(), sov, client, "default")
require.NoError(t, err)
assert.Equal(t, "", result)
}
func TestResolveStringOrValueFrom_SecretKeyRef(t *testing.T) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "default",
},
Data: map[string][]byte{
"password": []byte("super-secret-password"),
},
}
client := fake.NewSimpleClientset(secret)
sov := StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "test-secret",
Key: "password",
},
},
}
result, err := ResolveStringOrValueFrom(context.Background(), sov, client, "default")
require.NoError(t, err)
assert.Equal(t, "super-secret-password", result)
}
func TestResolveStringOrValueFrom_SecretKeyRef_WithNamespace(t *testing.T) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "custom-namespace",
},
Data: map[string][]byte{
"password": []byte("secret-from-custom-ns"),
},
}
client := fake.NewSimpleClientset(secret)
sov := StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "test-secret",
Key: "password",
Namespace: "custom-namespace",
},
},
}
result, err := ResolveStringOrValueFrom(context.Background(), sov, client, "default")
require.NoError(t, err)
assert.Equal(t, "secret-from-custom-ns", result)
}
func TestResolveStringOrValueFrom_SecretKeyRef_NotFound(t *testing.T) {
client := fake.NewSimpleClientset()
sov := StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "nonexistent-secret",
Key: "password",
},
},
}
result, err := ResolveStringOrValueFrom(context.Background(), sov, client, "default")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to get secret")
assert.Equal(t, "", result)
}
func TestResolveStringOrValueFrom_SecretKeyRef_KeyNotFound(t *testing.T) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "default",
},
Data: map[string][]byte{
"password": []byte("secret-value"),
},
}
client := fake.NewSimpleClientset(secret)
sov := StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "test-secret",
Key: "nonexistent-key",
},
},
}
result, err := ResolveStringOrValueFrom(context.Background(), sov, client, "default")
require.Error(t, err)
assert.Contains(t, err.Error(), "key \"nonexistent-key\" not found")
assert.Equal(t, "", result)
}
func TestResolveStringOrValueFrom_SecretKeyRef_Optional(t *testing.T) {
client := fake.NewSimpleClientset()
optional := true
sov := StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "nonexistent-secret",
Key: "password",
Optional: &optional,
},
},
}
result, err := ResolveStringOrValueFrom(context.Background(), sov, client, "default")
require.NoError(t, err)
assert.Equal(t, "", result)
}
func TestResolveStringOrValueFrom_SecretKeyRef_OptionalKeyNotFound(t *testing.T) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "test-secret",
Namespace: "default",
},
Data: map[string][]byte{
"password": []byte("secret-value"),
},
}
client := fake.NewSimpleClientset(secret)
optional := true
sov := StringOrValueFrom{
ValueFrom: &ValueFromSource{
SecretKeyRef: &SecretKeyRef{
Name: "test-secret",
Key: "nonexistent-key",
Optional: &optional,
},
},
}
result, err := ResolveStringOrValueFrom(context.Background(), sov, client, "default")
require.NoError(t, err)
assert.Equal(t, "", result)
}
func TestResolveStringOrValueFrom_ConfigMapKeyRef(t *testing.T) {
configMap := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-configmap",
Namespace: "default",
},
Data: map[string]string{
"config-key": "config-value",
},
}
client := fake.NewSimpleClientset(configMap)
sov := StringOrValueFrom{
ValueFrom: &ValueFromSource{
ConfigMapKeyRef: &ConfigMapKeyRef{
Name: "test-configmap",
Key: "config-key",
},
},
}
result, err := ResolveStringOrValueFrom(context.Background(), sov, client, "default")
require.NoError(t, err)
assert.Equal(t, "config-value", result)
}
func TestResolveStringOrValueFrom_ConfigMapKeyRef_WithNamespace(t *testing.T) {
configMap := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-configmap",
Namespace: "custom-namespace",
},
Data: map[string]string{
"config-key": "config-from-custom-ns",
},
}
client := fake.NewSimpleClientset(configMap)
sov := StringOrValueFrom{
ValueFrom: &ValueFromSource{
ConfigMapKeyRef: &ConfigMapKeyRef{
Name: "test-configmap",
Key: "config-key",
Namespace: "custom-namespace",
},
},
}
result, err := ResolveStringOrValueFrom(context.Background(), sov, client, "default")
require.NoError(t, err)
assert.Equal(t, "config-from-custom-ns", result)
}
func TestResolveStringOrValueFrom_ConfigMapKeyRef_NotFound(t *testing.T) {
client := fake.NewSimpleClientset()
sov := StringOrValueFrom{
ValueFrom: &ValueFromSource{
ConfigMapKeyRef: &ConfigMapKeyRef{
Name: "nonexistent-configmap",
Key: "config-key",
},
},
}
result, err := ResolveStringOrValueFrom(context.Background(), sov, client, "default")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to get configmap")
assert.Equal(t, "", result)
}
func TestResolveStringOrValueFrom_ConfigMapKeyRef_KeyNotFound(t *testing.T) {
configMap := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-configmap",
Namespace: "default",
},
Data: map[string]string{
"config-key": "config-value",
},
}
client := fake.NewSimpleClientset(configMap)
sov := StringOrValueFrom{
ValueFrom: &ValueFromSource{
ConfigMapKeyRef: &ConfigMapKeyRef{
Name: "test-configmap",
Key: "nonexistent-key",
},
},
}
result, err := ResolveStringOrValueFrom(context.Background(), sov, client, "default")
require.Error(t, err)
assert.Contains(t, err.Error(), "key \"nonexistent-key\" not found")
assert.Equal(t, "", result)
}
func TestResolveStringOrValueFrom_ConfigMapKeyRef_Optional(t *testing.T) {
client := fake.NewSimpleClientset()
optional := true
sov := StringOrValueFrom{
ValueFrom: &ValueFromSource{
ConfigMapKeyRef: &ConfigMapKeyRef{
Name: "nonexistent-configmap",
Key: "config-key",
Optional: &optional,
},
},
}
result, err := ResolveStringOrValueFrom(context.Background(), sov, client, "default")
require.NoError(t, err)
assert.Equal(t, "", result)
}
func TestResolveStringOrValueFrom_ConfigMapKeyRef_OptionalKeyNotFound(t *testing.T) {
configMap := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-configmap",
Namespace: "default",
},
Data: map[string]string{
"config-key": "config-value",
},
}
client := fake.NewSimpleClientset(configMap)
optional := true
sov := StringOrValueFrom{
ValueFrom: &ValueFromSource{
ConfigMapKeyRef: &ConfigMapKeyRef{
Name: "test-configmap",
Key: "nonexistent-key",
Optional: &optional,
},
},
}
result, err := ResolveStringOrValueFrom(context.Background(), sov, client, "default")
require.NoError(t, err)
assert.Equal(t, "", result)
}
@@ -0,0 +1,64 @@
package v1beta3
// StringOrValueFrom represents a string value that can either be specified
// directly or sourced from a Kubernetes Secret or ConfigMap
type StringOrValueFrom struct {
// Value is a literal string value
// +optional
Value *string `json:"value,omitempty" yaml:"value,omitempty"`
// ValueFrom is a reference to a value in a Secret or ConfigMap
// +optional
ValueFrom *ValueFromSource `json:"valueFrom,omitempty" yaml:"valueFrom,omitempty"`
}
// ValueFromSource represents the source of a value from a Secret or ConfigMap
type ValueFromSource struct {
// SecretKeyRef references a key in a Secret
// +optional
SecretKeyRef *SecretKeyRef `json:"secretKeyRef,omitempty" yaml:"secretKeyRef,omitempty"`
// ConfigMapKeyRef references a key in a ConfigMap
// +optional
ConfigMapKeyRef *ConfigMapKeyRef `json:"configMapKeyRef,omitempty" yaml:"configMapKeyRef,omitempty"`
}
// SecretKeyRef references a specific key in a Kubernetes Secret
type SecretKeyRef struct {
// Name is the name of the Secret
Name string `json:"name" yaml:"name"`
// Key is the key within the Secret to read
Key string `json:"key" yaml:"key"`
// Namespace is the namespace of the Secret
// If not specified, defaults to the namespace where the SupportBundle is running
// +optional
Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"`
// Optional specifies whether the Secret must exist
// If true and the Secret or key doesn't exist, resolves to empty string
// If false (default) and the Secret or key doesn't exist, resolution fails
// +optional
Optional *bool `json:"optional,omitempty" yaml:"optional,omitempty"`
}
// ConfigMapKeyRef references a specific key in a Kubernetes ConfigMap
type ConfigMapKeyRef struct {
// Name is the name of the ConfigMap
Name string `json:"name" yaml:"name"`
// Key is the key within the ConfigMap to read
Key string `json:"key" yaml:"key"`
// Namespace is the namespace of the ConfigMap
// If not specified, defaults to the namespace where the SupportBundle is running
// +optional
Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"`
// Optional specifies whether the ConfigMap must exist
// If true and the ConfigMap or key doesn't exist, resolves to empty string
// If false (default) and the ConfigMap or key doesn't exist, resolution fails
// +optional
Optional *bool `json:"optional,omitempty" yaml:"optional,omitempty"`
}
@@ -0,0 +1,65 @@
/*
Copyright 2019 Replicated, Inc..
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.
*/
package v1beta3
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// SupportBundleSpec defines the desired state of SupportBundle
type SupportBundleSpec struct {
AfterCollection []*AfterCollection `json:"afterCollection,omitempty" yaml:"afterCollection,omitempty"`
Collectors []*Collect `json:"collectors,omitempty" yaml:"collectors,omitempty"`
HostCollectors []*HostCollect `json:"hostCollectors,omitempty" yaml:"hostCollectors,omitempty"`
Analyzers []*Analyze `json:"analyzers,omitempty" yaml:"analyzers,omitempty"`
HostAnalyzers []*HostAnalyze `json:"hostAnalyzers,omitempty" yaml:"hostAnalyzers,omitempty"`
// URI optionally defines a location which is the source of this spec to allow updating of the spec at runtime
Uri string `json:"uri,omitempty" yaml:"uri,omitempty"`
RunHostCollectorsInPod bool `json:"runHostCollectorsInPod,omitempty" yaml:"runHostCollectorsInPod,omitempty"`
}
// SupportBundleStatus defines the observed state of SupportBundle
type SupportBundleStatus struct {
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
// Important: Run "make" to regenerate code after modifying this file
}
// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// SupportBundle is the Schema for the SupportBundles API
// +k8s:openapi-gen=true
type SupportBundle struct {
metav1.TypeMeta `json:",inline" yaml:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty" yaml:"metadata,omitempty"`
Spec SupportBundleSpec `json:"spec,omitempty" yaml:"spec,omitempty"`
Status SupportBundleStatus `json:"status,omitempty"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// SupportBundleList contains a list of SupportBundle
type SupportBundleList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []SupportBundle `json:"items"`
}
func init() {
SchemeBuilder.Register(&SupportBundle{}, &SupportBundleList{})
}
@@ -0,0 +1,441 @@
//go:build !ignore_autogenerated
/*
Copyright 2019 Replicated, Inc..
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.
*/
// Code generated by controller-gen. DO NOT EDIT.
package v1beta3
import (
"github.com/replicatedhq/troubleshoot/pkg/multitype"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *AfterCollection) DeepCopyInto(out *AfterCollection) {
*out = *in
in.CollectorMeta.DeepCopyInto(&out.CollectorMeta)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AfterCollection.
func (in *AfterCollection) DeepCopy() *AfterCollection {
if in == nil {
return nil
}
out := new(AfterCollection)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Analyze) DeepCopyInto(out *Analyze) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Analyze.
func (in *Analyze) DeepCopy() *Analyze {
if in == nil {
return nil
}
out := new(Analyze)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Collect) DeepCopyInto(out *Collect) {
*out = *in
if in.Postgres != nil {
in, out := &in.Postgres, &out.Postgres
*out = new(Database)
(*in).DeepCopyInto(*out)
}
if in.Mssql != nil {
in, out := &in.Mssql, &out.Mssql
*out = new(Database)
(*in).DeepCopyInto(*out)
}
if in.Mysql != nil {
in, out := &in.Mysql, &out.Mysql
*out = new(Database)
(*in).DeepCopyInto(*out)
}
if in.Redis != nil {
in, out := &in.Redis, &out.Redis
*out = new(Database)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Collect.
func (in *Collect) DeepCopy() *Collect {
if in == nil {
return nil
}
out := new(Collect)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *CollectorMeta) DeepCopyInto(out *CollectorMeta) {
*out = *in
if in.Exclude != nil {
in, out := &in.Exclude, &out.Exclude
*out = new(multitype.BoolOrString)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CollectorMeta.
func (in *CollectorMeta) DeepCopy() *CollectorMeta {
if in == nil {
return nil
}
out := new(CollectorMeta)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ConfigMapKeyRef) DeepCopyInto(out *ConfigMapKeyRef) {
*out = *in
if in.Optional != nil {
in, out := &in.Optional, &out.Optional
*out = new(bool)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapKeyRef.
func (in *ConfigMapKeyRef) DeepCopy() *ConfigMapKeyRef {
if in == nil {
return nil
}
out := new(ConfigMapKeyRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Database) DeepCopyInto(out *Database) {
*out = *in
in.CollectorMeta.DeepCopyInto(&out.CollectorMeta)
in.URI.DeepCopyInto(&out.URI)
if in.Parameters != nil {
in, out := &in.Parameters, &out.Parameters
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.TLS != nil {
in, out := &in.TLS, &out.TLS
*out = new(TLSParams)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Database.
func (in *Database) DeepCopy() *Database {
if in == nil {
return nil
}
out := new(Database)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HostAnalyze) DeepCopyInto(out *HostAnalyze) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostAnalyze.
func (in *HostAnalyze) DeepCopy() *HostAnalyze {
if in == nil {
return nil
}
out := new(HostAnalyze)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *HostCollect) DeepCopyInto(out *HostCollect) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostCollect.
func (in *HostCollect) DeepCopy() *HostCollect {
if in == nil {
return nil
}
out := new(HostCollect)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SecretKeyRef) DeepCopyInto(out *SecretKeyRef) {
*out = *in
if in.Optional != nil {
in, out := &in.Optional, &out.Optional
*out = new(bool)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeyRef.
func (in *SecretKeyRef) DeepCopy() *SecretKeyRef {
if in == nil {
return nil
}
out := new(SecretKeyRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *StringOrValueFrom) DeepCopyInto(out *StringOrValueFrom) {
*out = *in
if in.Value != nil {
in, out := &in.Value, &out.Value
*out = new(string)
**out = **in
}
if in.ValueFrom != nil {
in, out := &in.ValueFrom, &out.ValueFrom
*out = new(ValueFromSource)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StringOrValueFrom.
func (in *StringOrValueFrom) DeepCopy() *StringOrValueFrom {
if in == nil {
return nil
}
out := new(StringOrValueFrom)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SupportBundle) DeepCopyInto(out *SupportBundle) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
out.Status = in.Status
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SupportBundle.
func (in *SupportBundle) DeepCopy() *SupportBundle {
if in == nil {
return nil
}
out := new(SupportBundle)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *SupportBundle) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SupportBundleList) DeepCopyInto(out *SupportBundleList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]SupportBundle, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SupportBundleList.
func (in *SupportBundleList) DeepCopy() *SupportBundleList {
if in == nil {
return nil
}
out := new(SupportBundleList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *SupportBundleList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SupportBundleSpec) DeepCopyInto(out *SupportBundleSpec) {
*out = *in
if in.AfterCollection != nil {
in, out := &in.AfterCollection, &out.AfterCollection
*out = make([]*AfterCollection, len(*in))
for i := range *in {
if (*in)[i] != nil {
in, out := &(*in)[i], &(*out)[i]
*out = new(AfterCollection)
(*in).DeepCopyInto(*out)
}
}
}
if in.Collectors != nil {
in, out := &in.Collectors, &out.Collectors
*out = make([]*Collect, len(*in))
for i := range *in {
if (*in)[i] != nil {
in, out := &(*in)[i], &(*out)[i]
*out = new(Collect)
(*in).DeepCopyInto(*out)
}
}
}
if in.HostCollectors != nil {
in, out := &in.HostCollectors, &out.HostCollectors
*out = make([]*HostCollect, len(*in))
for i := range *in {
if (*in)[i] != nil {
in, out := &(*in)[i], &(*out)[i]
*out = new(HostCollect)
**out = **in
}
}
}
if in.Analyzers != nil {
in, out := &in.Analyzers, &out.Analyzers
*out = make([]*Analyze, len(*in))
for i := range *in {
if (*in)[i] != nil {
in, out := &(*in)[i], &(*out)[i]
*out = new(Analyze)
**out = **in
}
}
}
if in.HostAnalyzers != nil {
in, out := &in.HostAnalyzers, &out.HostAnalyzers
*out = make([]*HostAnalyze, len(*in))
for i := range *in {
if (*in)[i] != nil {
in, out := &(*in)[i], &(*out)[i]
*out = new(HostAnalyze)
**out = **in
}
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SupportBundleSpec.
func (in *SupportBundleSpec) DeepCopy() *SupportBundleSpec {
if in == nil {
return nil
}
out := new(SupportBundleSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SupportBundleStatus) DeepCopyInto(out *SupportBundleStatus) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SupportBundleStatus.
func (in *SupportBundleStatus) DeepCopy() *SupportBundleStatus {
if in == nil {
return nil
}
out := new(SupportBundleStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TLSParams) DeepCopyInto(out *TLSParams) {
*out = *in
if in.Secret != nil {
in, out := &in.Secret, &out.Secret
*out = new(TLSSecret)
**out = **in
}
in.CACert.DeepCopyInto(&out.CACert)
in.ClientCert.DeepCopyInto(&out.ClientCert)
in.ClientKey.DeepCopyInto(&out.ClientKey)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSParams.
func (in *TLSParams) DeepCopy() *TLSParams {
if in == nil {
return nil
}
out := new(TLSParams)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TLSSecret) DeepCopyInto(out *TLSSecret) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSecret.
func (in *TLSSecret) DeepCopy() *TLSSecret {
if in == nil {
return nil
}
out := new(TLSSecret)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ValueFromSource) DeepCopyInto(out *ValueFromSource) {
*out = *in
if in.SecretKeyRef != nil {
in, out := &in.SecretKeyRef, &out.SecretKeyRef
*out = new(SecretKeyRef)
(*in).DeepCopyInto(*out)
}
if in.ConfigMapKeyRef != nil {
in, out := &in.ConfigMapKeyRef, &out.ConfigMapKeyRef
*out = new(ConfigMapKeyRef)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ValueFromSource.
func (in *ValueFromSource) DeepCopy() *ValueFromSource {
if in == nil {
return nil
}
out := new(ValueFromSource)
in.DeepCopyInto(out)
return out
}
@@ -20,6 +20,7 @@ package scheme
import (
troubleshootv1beta1 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta1"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
troubleshootv1beta3 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta3"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
schema "k8s.io/apimachinery/pkg/runtime/schema"
@@ -33,6 +34,7 @@ var ParameterCodec = runtime.NewParameterCodec(Scheme)
var localSchemeBuilder = runtime.SchemeBuilder{
troubleshootv1beta1.AddToScheme,
troubleshootv1beta2.AddToScheme,
troubleshootv1beta3.AddToScheme,
}
// AddToScheme adds all types of this clientset into the given scheme. This allows composition
+145 -3
View File
@@ -232,7 +232,7 @@ func (c *CollectClusterResources) Collect(progressChan chan<- interface{}) (Coll
// replicasets
replicasets, replicasetsErrors := replicasets(ctx, client, namespaceNames)
for k, v := range replicasets {
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s-errors.json", constants.CLUSTER_RESOURCES_STATEFULSETS), k), bytes.NewBuffer(v))
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_REPLICASETS, k), bytes.NewBuffer(v))
}
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s-errors.json", constants.CLUSTER_RESOURCES_REPLICASETS)), marshalErrors(replicasetsErrors))
@@ -276,6 +276,11 @@ func (c *CollectClusterResources) Collect(progressChan chan<- interface{}) (Coll
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_STORAGE_CLASS)), bytes.NewBuffer(storageClasses))
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s-errors.json", constants.CLUSTER_RESOURCES_STORAGE_CLASS)), marshalErrors(storageErrors))
// ingress classes
ingressClasses, ingressClassErrors := ingressClasses(ctx, client)
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_INGRESS_CLASS)), bytes.NewBuffer(ingressClasses))
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s-errors.json", constants.CLUSTER_RESOURCES_INGRESS_CLASS)), marshalErrors(ingressClassErrors))
// priority classes
priorityClasses, priorityErrors := priorityClasses(ctx, client)
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_PRIORITY_CLASS)), bytes.NewBuffer(priorityClasses))
@@ -370,9 +375,9 @@ func (c *CollectClusterResources) Collect(progressChan chan<- interface{}) (Coll
// endpointslices
endpointslices, endpointslicesErrors := endpointslices(ctx, client, namespaceNames)
for k, v := range endpointslices {
_ = output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_ENDPOINTSICES, k), bytes.NewBuffer(v))
_ = output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_ENDPOINTSLICES, k), bytes.NewBuffer(v))
}
_ = output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s-errors.json", constants.CLUSTER_RESOURCES_ENDPOINTSICES)), marshalErrors(endpointslicesErrors))
_ = output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s-errors.json", constants.CLUSTER_RESOURCES_ENDPOINTSLICES)), marshalErrors(endpointslicesErrors))
// Service Accounts
servicesAccounts, servicesAccountsErrors := serviceAccounts(ctx, client, namespaceNames)
@@ -393,6 +398,11 @@ func (c *CollectClusterResources) Collect(progressChan chan<- interface{}) (Coll
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_VOLUME_ATTACHMENTS)), bytes.NewBuffer(volumeAttachments))
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s-errors.json", constants.CLUSTER_RESOURCES_VOLUME_ATTACHMENTS)), marshalErrors(volumeAttachmentsErrors))
// Certificate Signing Requests
csrs, csrsErrors := certificateSigningRequests(ctx, client)
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_CERTIFICATE_SIGNING_REQUESTS)), bytes.NewBuffer(csrs))
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s-errors.json", constants.CLUSTER_RESOURCES_CERTIFICATE_SIGNING_REQUESTS)), marshalErrors(csrsErrors))
// ConfigMaps
configMaps, configMapsErrors := configMaps(ctx, client, namespaceNames)
for k, v := range configMaps {
@@ -400,6 +410,13 @@ func (c *CollectClusterResources) Collect(progressChan chan<- interface{}) (Coll
}
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s-errors.json", constants.CLUSTER_RESOURCES_CONFIGMAPS)), marshalErrors(configMapsErrors))
// Replicated License
licenseData, licenseErr := replicatedLicense(ctx, client, namespaceNames)
if licenseErr == nil {
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_REPLICATED_LICENSE), bytes.NewBuffer(licenseData))
}
return output, nil
}
@@ -1105,6 +1122,40 @@ func storageClassesV1beta(ctx context.Context, client *kubernetes.Clientset) ([]
return b, nil
}
func ingressClasses(ctx context.Context, client *kubernetes.Clientset) ([]byte, []string) {
ok, err := discovery.HasResource(client, "networking.k8s.io/v1", "IngressClass")
if err != nil {
return nil, []string{err.Error()}
}
if !ok {
return nil, nil
}
ingressClasses, err := client.NetworkingV1().IngressClasses().List(ctx, metav1.ListOptions{})
if err != nil {
return nil, []string{err.Error()}
}
gvk, err := apiutil.GVKForObject(ingressClasses, scheme.Scheme)
if err == nil {
ingressClasses.GetObjectKind().SetGroupVersionKind(gvk)
}
for i, o := range ingressClasses.Items {
gvk, err := apiutil.GVKForObject(&o, scheme.Scheme)
if err == nil {
ingressClasses.Items[i].GetObjectKind().SetGroupVersionKind(gvk)
}
}
b, err := json.MarshalIndent(ingressClasses, "", " ")
if err != nil {
return nil, []string{err.Error()}
}
return b, nil
}
func priorityClasses(ctx context.Context, client *kubernetes.Clientset) ([]byte, []string) {
ok, err := discovery.HasResource(client, "scheduling.k8s.io/v1", "PriorityClass")
if err != nil {
@@ -2122,6 +2173,32 @@ func volumeAttachments(ctx context.Context, client kubernetes.Interface) ([]byte
return b, nil
}
func certificateSigningRequests(ctx context.Context, client kubernetes.Interface) ([]byte, []string) {
csrs, err := client.CertificatesV1().CertificateSigningRequests().List(ctx, metav1.ListOptions{})
if err != nil {
return nil, []string{err.Error()}
}
gvk, err := apiutil.GVKForObject(csrs, scheme.Scheme)
if err == nil {
csrs.GetObjectKind().SetGroupVersionKind(gvk)
}
for i, o := range csrs.Items {
gvk, err := apiutil.GVKForObject(&o, scheme.Scheme)
if err == nil {
csrs.Items[i].GetObjectKind().SetGroupVersionKind(gvk)
}
}
b, err := json.MarshalIndent(csrs, "", " ")
if err != nil {
return nil, []string{err.Error()}
}
return b, nil
}
func configMaps(ctx context.Context, client kubernetes.Interface, namespaces []string) (map[string][]byte, map[string]string) {
configmapByNamespace := make(map[string][]byte)
errorsByNamespace := make(map[string]string)
@@ -2176,3 +2253,68 @@ func storeCustomResource(name string, objects any, m map[string][]byte) error {
m[fmt.Sprintf("%s.yaml", name)] = y
return nil
}
// replicatedLicense searches for the replicated secret across namespaces,
// extracts the config.yaml field, and extracts the licenseID and appSlug.
// Note: secret.Data already contains decoded bytes; no base64 decoding is required.
func replicatedLicense(ctx context.Context, client *kubernetes.Clientset, namespaces []string) ([]byte, error) {
// Structure to parse the config.yaml content
type ConfigYAML struct {
License string `yaml:"license"` // This is a YAML string containing the License object
}
type LicenseSpec struct {
LicenseID string `yaml:"licenseID"`
AppSlug string `yaml:"appSlug"`
}
type License struct {
Spec LicenseSpec `yaml:"spec"`
}
// Search through all namespaces for the replicated secret
for _, namespace := range namespaces {
secret, err := client.CoreV1().Secrets(namespace).Get(ctx, "replicated", metav1.GetOptions{})
if err != nil {
// Secret not found in this namespace, continue to next
continue
}
// Extract the config.yaml field from the secret data
configYAMLBase64, exists := secret.Data["config.yaml"]
if !exists {
continue
}
configYAMLBytes := configYAMLBase64
// Parse the YAML to extract the license field
var config ConfigYAML
if err := yaml.Unmarshal(configYAMLBytes, &config); err != nil {
// Malformed config in this namespace; try the next namespace
continue
}
// Parse the license field (which is a YAML string) to extract licenseID and appSlug
var license License
if err := yaml.Unmarshal([]byte(config.License), &license); err != nil {
// Malformed license in this namespace; try the next namespace
continue
}
// Return both licenseID and appSlug as JSON
licenseData := map[string]string{
"licenseID": license.Spec.LicenseID,
"appSlug": license.Spec.AppSlug,
}
licenseJSON, err := json.Marshal(licenseData)
if err != nil {
return nil, fmt.Errorf("failed to marshal license data: %w", err)
}
return licenseJSON, nil
}
// No replicated secret with a parsable license found in any namespace
return nil, fmt.Errorf("replicated secret with parsable license not found in any namespace")
}
+79
View File
@@ -3,6 +3,7 @@ package collect
import (
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"testing"
@@ -11,6 +12,7 @@ import (
"github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
certificatesv1 "k8s.io/api/certificates/v1"
v1 "k8s.io/api/coordination/v1"
corev1 "k8s.io/api/core/v1"
policyv1 "k8s.io/api/policy/v1"
@@ -18,11 +20,13 @@ import (
storagev1 "k8s.io/api/storage/v1"
apixfake "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/fake"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/intstr"
fakediscovery "k8s.io/client-go/discovery/fake"
testdynamicclient "k8s.io/client-go/dynamic/fake"
"k8s.io/client-go/kubernetes"
testclient "k8s.io/client-go/kubernetes/fake"
k8stesting "k8s.io/client-go/testing"
"sigs.k8s.io/yaml"
)
@@ -697,3 +701,78 @@ func createTestPodDisruptionBudgetsV1beta1(client kubernetes.Interface, pdbNames
}
return nil
}
func Test_CertificateSigningRequests(t *testing.T) {
tests := []struct {
name string
csrNames []string
}{
{
name: "single certificate signing request",
csrNames: []string{"test-csr"},
},
{
name: "multiple certificate signing requests",
csrNames: []string{"test-csr-1", "test-csr-2", "test-csr-3"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := testclient.NewSimpleClientset()
ctx := context.Background()
err := createTestCertificateSigningRequests(client, tt.csrNames)
assert.NoError(t, err)
csrs, csrErrors := certificateSigningRequests(ctx, client)
assert.Empty(t, csrErrors)
assert.NotEmpty(t, csrs)
var csrList certificatesv1.CertificateSigningRequestList
err = json.Unmarshal(csrs, &csrList)
assert.NoError(t, err)
assert.Equal(t, len(tt.csrNames), len(csrList.Items))
for _, csr := range csrList.Items {
assert.Contains(t, tt.csrNames, csr.ObjectMeta.Name)
}
})
}
}
func Test_CertificateSigningRequests_PermissionDenied(t *testing.T) {
client := testclient.NewSimpleClientset()
ctx := context.Background()
// Add a reactor to simulate permission denied error
client.PrependReactor("list", "certificatesigningrequests", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) {
return true, nil, fmt.Errorf("certificatesigningrequests.certificates.k8s.io is forbidden: User \"system:serviceaccount:default:default\" cannot list resource \"certificatesigningrequests\" in API group \"certificates.k8s.io\" at the cluster scope")
})
csrs, csrErrors := certificateSigningRequests(ctx, client)
// Verify fail-safe behavior: returns nil data + error string (not panic)
assert.Nil(t, csrs)
assert.NotEmpty(t, csrErrors)
assert.Len(t, csrErrors, 1)
// Verify the error is captured as a string
assert.IsType(t, "", csrErrors[0])
assert.Contains(t, csrErrors[0], "forbidden")
}
func createTestCertificateSigningRequests(client kubernetes.Interface, csrNames []string) error {
for _, csrName := range csrNames {
_, err := client.CertificatesV1().CertificateSigningRequests().Create(context.Background(), &certificatesv1.CertificateSigningRequest{
ObjectMeta: metav1.ObjectMeta{
Name: csrName,
},
Spec: certificatesv1.CertificateSigningRequestSpec{
Request: []byte("-----BEGIN CERTIFICATE REQUEST-----\ntest\n-----END CERTIFICATE REQUEST-----"),
SignerName: "kubernetes.io/kube-apiserver-client",
Usages: []certificatesv1.KeyUsage{certificatesv1.UsageClientAuth},
},
}, metav1.CreateOptions{})
if err != nil {
return err
}
}
return nil
}
+94 -1
View File
@@ -15,7 +15,7 @@ func Test_ensureClusterResourcesFirst(t *testing.T) {
list []*troubleshootv1beta2.Collect
}{
{
name: "Reorg OK",
name: "Reorg OK - clusterResources moved to front",
want: []*troubleshootv1beta2.Collect{
{
ClusterResources: &troubleshootv1beta2.ClusterResources{},
@@ -33,6 +33,99 @@ func Test_ensureClusterResourcesFirst(t *testing.T) {
},
},
},
{
name: "Already first - no change",
want: []*troubleshootv1beta2.Collect{
{
ClusterResources: &troubleshootv1beta2.ClusterResources{},
},
{
Data: &troubleshootv1beta2.Data{},
},
{
Secret: &troubleshootv1beta2.Secret{},
},
},
list: []*troubleshootv1beta2.Collect{
{
ClusterResources: &troubleshootv1beta2.ClusterResources{},
},
{
Data: &troubleshootv1beta2.Data{},
},
{
Secret: &troubleshootv1beta2.Secret{},
},
},
},
{
name: "Multiple clusterResources - all moved to front",
want: []*troubleshootv1beta2.Collect{
{
ClusterResources: &troubleshootv1beta2.ClusterResources{},
},
{
ClusterResources: &troubleshootv1beta2.ClusterResources{},
},
{
Data: &troubleshootv1beta2.Data{},
},
{
Secret: &troubleshootv1beta2.Secret{},
},
},
list: []*troubleshootv1beta2.Collect{
{
Data: &troubleshootv1beta2.Data{},
},
{
ClusterResources: &troubleshootv1beta2.ClusterResources{},
},
{
Secret: &troubleshootv1beta2.Secret{},
},
{
ClusterResources: &troubleshootv1beta2.ClusterResources{},
},
},
},
{
name: "No clusterResources - no change",
want: []*troubleshootv1beta2.Collect{
{
Data: &troubleshootv1beta2.Data{},
},
{
Secret: &troubleshootv1beta2.Secret{},
},
},
list: []*troubleshootv1beta2.Collect{
{
Data: &troubleshootv1beta2.Data{},
},
{
Secret: &troubleshootv1beta2.Secret{},
},
},
},
{
name: "Empty list - no change",
want: []*troubleshootv1beta2.Collect{},
list: []*troubleshootv1beta2.Collect{},
},
{
name: "Only clusterResources - no change",
want: []*troubleshootv1beta2.Collect{
{
ClusterResources: &troubleshootv1beta2.ClusterResources{},
},
},
list: []*troubleshootv1beta2.Collect{
{
ClusterResources: &troubleshootv1beta2.ClusterResources{},
},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
+7 -14
View File
@@ -44,8 +44,7 @@ pwd=somethinggoeshere;`,
want: map[string]string{
"data/datacollectorname": ` 123
another***HIDDEN***here
pwd=***HIDDEN***;
`,
pwd=***HIDDEN***;`,
},
},
{
@@ -78,8 +77,7 @@ pwd=somethinggoeshere;`,
want: map[string]string{
"data/datacollectorname": `abc 123
another***HIDDEN***here
pwd=***HIDDEN***;
`,
pwd=***HIDDEN***;`,
},
},
{
@@ -112,8 +110,7 @@ pwd=somethinggoeshere;`,
want: map[string]string{
"data/datacollectorname": `abc 123
another line here
pwd=***HIDDEN***;
`,
pwd=***HIDDEN***;`,
},
},
{
@@ -149,8 +146,7 @@ pwd=somethinggoeshere;`,
want: map[string]string{
"data/datacollectorname": `abc 123
another***HIDDEN***here
pwd=***HIDDEN***;
`,
pwd=***HIDDEN***;`,
},
},
{
@@ -186,8 +182,7 @@ pwd=somethinggoeshere;`,
want: map[string]string{
"data/data/collectorname": `***HIDDEN*** ***HIDDEN***
***HIDDEN*** line here
pwd=***HIDDEN***;
`,
pwd=***HIDDEN***;`,
},
},
{
@@ -213,8 +208,7 @@ another line here`,
},
want: map[string]string{
"data/datacollectorname": `abc 123
another line here
`,
another line here`,
},
},
{
@@ -249,8 +243,7 @@ abc`,
abc
123
xyz123
abc
`,
abc`,
},
},
{
+4 -4
View File
@@ -9,15 +9,15 @@ import (
"strings"
"time"
imagedocker "github.com/containers/image/v5/docker"
dockerref "github.com/containers/image/v5/docker/reference"
"github.com/containers/image/v5/transports/alltransports"
"github.com/containers/image/v5/types"
"github.com/distribution/distribution/v3/registry/api/errcode"
registryv2 "github.com/distribution/distribution/v3/registry/api/v2"
"github.com/pkg/errors"
"github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
imagedocker "go.podman.io/image/v5/docker"
dockerref "go.podman.io/image/v5/docker/reference"
"go.podman.io/image/v5/transports/alltransports"
"go.podman.io/image/v5/types"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
+1 -1
View File
@@ -8,9 +8,9 @@ import (
"testing"
"time"
"github.com/containers/image/v5/transports/alltransports"
"github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/stretchr/testify/assert"
"go.podman.io/image/v5/transports/alltransports"
"k8s.io/client-go/rest"
)
+13 -1
View File
@@ -80,7 +80,19 @@ func DeterministicIDForCollector(collector *troubleshootv1beta2.Collect) string
}
func selectorToString(selector []string) string {
return strings.Replace(strings.Join(selector, "-"), "=", "-", -1)
result := strings.Replace(strings.Join(selector, "-"), "=", "-", -1)
// Sanitize characters that are invalid in Windows filenames: < > : " / \ | ? *
// Replace them with underscores to ensure cross-platform compatibility
result = strings.ReplaceAll(result, "*", "all")
result = strings.ReplaceAll(result, "?", "_")
result = strings.ReplaceAll(result, ":", "_")
result = strings.ReplaceAll(result, "<", "_")
result = strings.ReplaceAll(result, ">", "_")
result = strings.ReplaceAll(result, "|", "_")
result = strings.ReplaceAll(result, "\"", "_")
result = strings.ReplaceAll(result, "/", "_")
result = strings.ReplaceAll(result, "\\", "_")
return result
}
func pathToString(path string) string {
+41 -38
View File
@@ -23,44 +23,47 @@ const (
ANALYSIS_FILENAME = "analysis.json"
// Cluster Resources Collector Directories
CLUSTER_RESOURCES_DIR = "cluster-resources"
CLUSTER_RESOURCES_NAMESPACES = "namespaces"
CLUSTER_RESOURCES_AUTH_CANI = "auth-cani-list"
CLUSTER_RESOURCES_PODS = "pods"
CLUSTER_RESOURCES_PODS_LOGS = "pods/logs"
CLUSTER_RESOURCES_POD_DISRUPTION_BUDGETS = "pod-disruption-budgets"
CLUSTER_RESOURCES_SERVICES = "services"
CLUSTER_RESOURCES_DEPLOYMENTS = "deployments"
CLUSTER_RESOURCES_REPLICASETS = "replicasets"
CLUSTER_RESOURCES_STATEFULSETS = "statefulsets"
CLUSTER_RESOURCES_DAEMONSETS = "daemonsets"
CLUSTER_RESOURCES_JOBS = "jobs"
CLUSTER_RESOURCES_CRONJOBS = "cronjobs"
CLUSTER_RESOURCES_INGRESS = "ingress"
CLUSTER_RESOURCES_NETWORK_POLICY = "network-policy"
CLUSTER_RESOURCES_RESOURCE_QUOTA = "resource-quota"
CLUSTER_RESOURCES_STORAGE_CLASS = "storage-classes"
CLUSTER_RESOURCES_CUSTOM_RESOURCE_DEFINITIONS = "custom-resource-definitions"
CLUSTER_RESOURCES_CUSTOM_RESOURCES = "custom-resources"
CLUSTER_RESOURCES_IMAGE_PULL_SECRETS = "image-pull-secrets" // nolint:gosec
CLUSTER_RESOURCES_NODES = "nodes"
CLUSTER_RESOURCES_GROUPS = "groups"
CLUSTER_RESOURCES_RESOURCES = "resources"
CLUSTER_RESOURCES_LIMITRANGES = "limitranges"
CLUSTER_RESOURCES_EVENTS = "events"
CLUSTER_RESOURCES_PVS = "pvs"
CLUSTER_RESOURCES_PVCS = "pvcs"
CLUSTER_RESOURCES_ROLES = "roles"
CLUSTER_RESOURCES_ROLE_BINDINGS = "rolebindings"
CLUSTER_RESOURCES_CLUSTER_ROLES = "clusterroles"
CLUSTER_RESOURCES_CLUSTER_ROLE_BINDINGS = "clusterrolebindings"
CLUSTER_RESOURCES_PRIORITY_CLASS = "priorityclasses"
CLUSTER_RESOURCES_ENDPOINTS = "endpoints"
CLUSTER_RESOURCES_ENDPOINTSICES = "endpointslices"
CLUSTER_RESOURCES_SERVICE_ACCOUNTS = "serviceaccounts"
CLUSTER_RESOURCES_LEASES = "leases"
CLUSTER_RESOURCES_VOLUME_ATTACHMENTS = "volumeattachments"
CLUSTER_RESOURCES_CONFIGMAPS = "configmaps"
CLUSTER_RESOURCES_DIR = "cluster-resources"
CLUSTER_RESOURCES_NAMESPACES = "namespaces"
CLUSTER_RESOURCES_AUTH_CANI = "auth-cani-list"
CLUSTER_RESOURCES_PODS = "pods"
CLUSTER_RESOURCES_PODS_LOGS = "pods/logs"
CLUSTER_RESOURCES_POD_DISRUPTION_BUDGETS = "pod-disruption-budgets"
CLUSTER_RESOURCES_SERVICES = "services"
CLUSTER_RESOURCES_DEPLOYMENTS = "deployments"
CLUSTER_RESOURCES_REPLICASETS = "replicasets"
CLUSTER_RESOURCES_STATEFULSETS = "statefulsets"
CLUSTER_RESOURCES_DAEMONSETS = "daemonsets"
CLUSTER_RESOURCES_JOBS = "jobs"
CLUSTER_RESOURCES_CRONJOBS = "cronjobs"
CLUSTER_RESOURCES_INGRESS = "ingress"
CLUSTER_RESOURCES_NETWORK_POLICY = "network-policy"
CLUSTER_RESOURCES_RESOURCE_QUOTA = "resource-quota"
CLUSTER_RESOURCES_STORAGE_CLASS = "storage-classes"
CLUSTER_RESOURCES_INGRESS_CLASS = "ingress-classes"
CLUSTER_RESOURCES_CUSTOM_RESOURCE_DEFINITIONS = "custom-resource-definitions"
CLUSTER_RESOURCES_CUSTOM_RESOURCES = "custom-resources"
CLUSTER_RESOURCES_IMAGE_PULL_SECRETS = "image-pull-secrets" // nolint:gosec
CLUSTER_RESOURCES_NODES = "nodes"
CLUSTER_RESOURCES_GROUPS = "groups"
CLUSTER_RESOURCES_RESOURCES = "resources"
CLUSTER_RESOURCES_LIMITRANGES = "limitranges"
CLUSTER_RESOURCES_EVENTS = "events"
CLUSTER_RESOURCES_PVS = "pvs"
CLUSTER_RESOURCES_PVCS = "pvcs"
CLUSTER_RESOURCES_ROLES = "roles"
CLUSTER_RESOURCES_ROLE_BINDINGS = "rolebindings"
CLUSTER_RESOURCES_CLUSTER_ROLES = "clusterroles"
CLUSTER_RESOURCES_CLUSTER_ROLE_BINDINGS = "clusterrolebindings"
CLUSTER_RESOURCES_PRIORITY_CLASS = "priorityclasses"
CLUSTER_RESOURCES_ENDPOINTS = "endpoints"
CLUSTER_RESOURCES_ENDPOINTSLICES = "endpointslices"
CLUSTER_RESOURCES_SERVICE_ACCOUNTS = "serviceaccounts"
CLUSTER_RESOURCES_LEASES = "leases"
CLUSTER_RESOURCES_VOLUME_ATTACHMENTS = "volumeattachments"
CLUSTER_RESOURCES_CONFIGMAPS = "configmaps"
CLUSTER_RESOURCES_REPLICATED_LICENSE = "license.json"
CLUSTER_RESOURCES_CERTIFICATE_SIGNING_REQUESTS = "certificatesigningrequests"
// SelfSubjectRulesReview evaluation responses
SELFSUBJECTRULESREVIEW_ERROR_AUTHORIZATION_WEBHOOK_UNSUPPORTED = "webhook authorizer does not support user rule resolution"
+7 -2
View File
@@ -6,11 +6,15 @@ import (
// HasResource takes an api version and a kind of a resource and checks if the resource
// is supported by the k8s api server.
// This function handles partial results from ServerGroupsAndResources(): "The returned group and resource lists might be non-nil with partial
// results even in the case of non-nil error."
func HasResource(dc discovery.DiscoveryInterface, apiVersion, kind string) (bool, error) {
_, apiLists, err := dc.ServerGroupsAndResources()
if err != nil {
if apiLists == nil {
return false, err
}
// Compare the resource api version and kind and find the resource.
for _, apiList := range apiLists {
if apiList.GroupVersion == apiVersion {
@@ -21,5 +25,6 @@ func HasResource(dc discovery.DiscoveryInterface, apiVersion, kind string) (bool
}
}
}
return false, nil
return false, err
}
+232
View File
@@ -1,9 +1,12 @@
package discovery
import (
"errors"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/discovery"
fakediscovery "k8s.io/client-go/discovery/fake"
fakeclientset "k8s.io/client-go/kubernetes/fake"
)
@@ -78,3 +81,232 @@ func TestHasResource(t *testing.T) {
})
}
}
// TestHasResourceWithPartialDiscoveryFailure verifies that HasResource correctly handles
// partial discovery failures where ServerGroupsAndResources() returns both an error AND
// partial results (non-nil apiLists). This simulates real Kubernetes behavior when some
// API groups fail to load but others succeed.
func TestHasResourceWithPartialDiscoveryFailure(t *testing.T) {
testKind := "Foo"
testKindGroupVersion := "v1"
testcases := []struct {
name string
apiResourceList []*metav1.APIResourceList
discoveryError error
wantResult bool
wantError bool
description string
}{
{
name: "resource found in partial results with discovery error",
apiResourceList: []*metav1.APIResourceList{
{
GroupVersion: "v1",
APIResources: []metav1.APIResource{
{
Kind: "Foo",
},
{
Kind: "Bar",
},
},
},
},
discoveryError: &discovery.ErrGroupDiscoveryFailed{
Groups: map[schema.GroupVersion]error{
{Group: "apps", Version: "v1"}: errors.New("failed to retrieve apps/v1"),
},
},
wantResult: true,
wantError: false,
description: "Should return (true, nil) when resource exists in partial results despite discovery error",
},
{
name: "resource not found in partial results with discovery error",
apiResourceList: []*metav1.APIResourceList{
{
GroupVersion: "v1",
APIResources: []metav1.APIResource{
{
Kind: "Bar",
},
{
Kind: "Baz",
},
},
},
},
discoveryError: &discovery.ErrGroupDiscoveryFailed{
Groups: map[schema.GroupVersion]error{
{Group: "apps", Version: "v1"}: errors.New("failed to retrieve apps/v1"),
},
},
wantResult: false,
wantError: true,
description: "Should return (false, error) when resource not in partial results and discovery error exists",
},
{
name: "nil api resource list with discovery error",
apiResourceList: nil,
discoveryError: &discovery.ErrGroupDiscoveryFailed{
Groups: map[schema.GroupVersion]error{
{Group: "apps", Version: "v1"}: errors.New("failed to retrieve apps/v1"),
},
},
wantResult: false,
wantError: true,
description: "Should return (false, error) when apiLists is nil and discovery error exists",
},
{
name: "empty api resource list with discovery error",
apiResourceList: []*metav1.APIResourceList{},
discoveryError: &discovery.ErrGroupDiscoveryFailed{
Groups: map[schema.GroupVersion]error{
{Group: "apps", Version: "v1"}: errors.New("failed to retrieve apps/v1"),
},
},
wantResult: false,
wantError: true,
description: "Should return (false, error) when apiLists is empty and discovery error exists",
},
{
name: "multiple groups with partial results and discovery error",
apiResourceList: []*metav1.APIResourceList{
{
GroupVersion: "v1",
APIResources: []metav1.APIResource{
{
Kind: "Pod",
},
{
Kind: "Service",
},
},
},
{
GroupVersion: "v2",
APIResources: []metav1.APIResource{
{
Kind: "Foo",
},
},
},
},
discoveryError: &discovery.ErrGroupDiscoveryFailed{
Groups: map[schema.GroupVersion]error{
{Group: "apps", Version: "v1"}: errors.New("failed to retrieve apps/v1"),
{Group: "batch", Version: "v1beta1"}: errors.New("failed to retrieve batch/v1beta1"),
},
},
wantResult: false,
wantError: true,
description: "Should return (false, error) when resource not found across multiple partial groups with discovery error",
},
{
name: "resource found with different version in partial results with discovery error",
apiResourceList: []*metav1.APIResourceList{
{
GroupVersion: "v2",
APIResources: []metav1.APIResource{
{
Kind: "Foo",
},
},
},
},
discoveryError: &discovery.ErrGroupDiscoveryFailed{
Groups: map[schema.GroupVersion]error{
{Group: "apps", Version: "v1"}: errors.New("failed to retrieve apps/v1"),
},
},
wantResult: false,
wantError: true,
description: "Should return (false, error) when resource exists with different version in partial results",
},
{
name: "generic error with partial results containing resource",
apiResourceList: []*metav1.APIResourceList{
{
GroupVersion: "v1",
APIResources: []metav1.APIResource{
{
Kind: "Foo",
},
},
},
},
discoveryError: errors.New("connection timeout"),
wantResult: true,
wantError: false,
description: "Should return (true, nil) when resource exists in partial results even with generic error",
},
{
name: "generic error without resource in partial results",
apiResourceList: []*metav1.APIResourceList{
{
GroupVersion: "v1",
APIResources: []metav1.APIResource{
{
Kind: "Bar",
},
},
},
},
discoveryError: errors.New("connection timeout"),
wantResult: false,
wantError: true,
description: "Should return (false, error) when resource not in partial results with generic error",
},
}
for _, tc := range testcases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
client := fakeclientset.NewSimpleClientset()
fakeDiscovery, ok := client.Discovery().(*fakediscovery.FakeDiscovery)
if !ok {
t.Fatalf("could not convert Discovery() to *FakeDiscovery")
}
// Configure the fake discovery to return both resources and error
fakeDiscovery.Resources = tc.apiResourceList
// Create a mock discovery interface that returns both error and partial results
mockDiscovery := &mockDiscoveryWithPartialFailure{
FakeDiscovery: fakeDiscovery,
errorToReturn: tc.discoveryError,
}
exists, err := HasResource(mockDiscovery, testKindGroupVersion, testKind)
// Verify error expectation
if tc.wantError && err == nil {
t.Errorf("%s: expected error but got nil", tc.description)
}
if !tc.wantError && err != nil {
t.Errorf("%s: expected no error but got: %v", tc.description, err)
}
// Verify result expectation
if exists != tc.wantResult {
t.Errorf("%s: unexpected result for HasResource:\n\t(WANT) %t\n\t(GOT) %t", tc.description, tc.wantResult, exists)
}
})
}
}
// mockDiscoveryWithPartialFailure wraps FakeDiscovery to simulate partial discovery failures
// where ServerGroupsAndResources() returns both an error AND partial results.
type mockDiscoveryWithPartialFailure struct {
*fakediscovery.FakeDiscovery
errorToReturn error
}
// ServerGroupsAndResources simulates the Kubernetes API behavior where partial results
// can be returned even when an error occurs. This happens when some API groups fail to
// load but others succeed.
func (m *mockDiscoveryWithPartialFailure) ServerGroupsAndResources() ([]*metav1.APIGroup, []*metav1.APIResourceList, error) {
groups, resources, _ := m.FakeDiscovery.ServerGroupsAndResources()
return groups, resources, m.errorToReturn
}
+25
View File
@@ -25,6 +25,26 @@ const (
PodStatusReasonInitCrashLoopBackOff PodStatusReason = "Init:CrashLoopBackOff"
)
// isNativeSidecar checks if an init container is a native sidecar.
// Native sidecars are init containers with restartPolicy: Always (Kubernetes 1.28+).
// They run continuously alongside main containers, unlike traditional init containers
// which must complete before main containers start.
func isNativeSidecar(pod *corev1.Pod, initContainerIndex int) bool {
// Bounds check - ensure the index is valid
if initContainerIndex >= len(pod.Spec.InitContainers) {
return false
}
initContainer := pod.Spec.InitContainers[initContainerIndex]
// Check if RestartPolicy is set to Always
if initContainer.RestartPolicy != nil && *initContainer.RestartPolicy == corev1.ContainerRestartPolicyAlways {
return true
}
return false
}
// reference: https://github.com/kubernetes/kubernetes/blob/e8fcd0de98d50f4019561a6b7a0287f5c059267a/pkg/printers/internalversion/printers.go#L741
func GetPodStatusReason(pod *corev1.Pod) (string, string) {
reason := string(pod.Status.Phase)
@@ -55,6 +75,11 @@ func GetPodStatusReason(pod *corev1.Pod) (string, string) {
case container.State.Waiting != nil && len(container.State.Waiting.Reason) > 0 && container.State.Waiting.Reason != "PodInitializing":
reason = "Init:" + container.State.Waiting.Reason
initializing = true
case isNativeSidecar(pod, i) && container.State.Running != nil:
// Native sidecar running - this is expected, not stuck initializing.
// Native sidecars (init containers with restartPolicy: Always) are designed
// to run continuously, so a Running state means successful initialization.
continue
default:
reason = fmt.Sprintf("Init:%d/%d", i, len(pod.Spec.InitContainers))
initializing = true
+284 -1
View File
@@ -1,9 +1,11 @@
package k8sutil
import (
"testing"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestIsPodUnhealthy(t *testing.T) {
@@ -99,3 +101,284 @@ func TestIsPodUnhealthy(t *testing.T) {
})
}
}
// TestGetPodStatusReason_HealthyNativeSidecar tests that a pod with a running native sidecar
// is correctly reported as "Running" and not stuck initializing.
func TestGetPodStatusReason_HealthyNativeSidecar(t *testing.T) {
startedTrue := true
restartPolicyAlways := corev1.ContainerRestartPolicyAlways
pod := &corev1.Pod{
Spec: corev1.PodSpec{
InitContainers: []corev1.Container{
{
Name: "istio-proxy",
Image: "istio/proxyv2:1.20",
RestartPolicy: &restartPolicyAlways, // Native sidecar!
},
},
Containers: []corev1.Container{
{
Name: "app",
Image: "myapp:latest",
},
},
},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
Conditions: []corev1.PodCondition{
{
Type: corev1.PodReady,
Status: corev1.ConditionTrue,
},
},
InitContainerStatuses: []corev1.ContainerStatus{
{
Name: "istio-proxy",
Ready: true,
Started: &startedTrue,
State: corev1.ContainerState{
Running: &corev1.ContainerStateRunning{
StartedAt: metav1.Now(),
},
},
},
},
ContainerStatuses: []corev1.ContainerStatus{
{
Name: "app",
Ready: true,
Started: &startedTrue,
State: corev1.ContainerState{
Running: &corev1.ContainerStateRunning{
StartedAt: metav1.Now(),
},
},
},
},
},
}
reason, message := GetPodStatusReason(pod)
// Should report as Running, not Init:0/1
if reason != "Running" {
t.Errorf("Expected reason 'Running', got '%s'", reason)
}
if message != "" {
t.Errorf("Expected empty message, got '%s'", message)
}
}
// TestIsPodUnhealthy_HealthyNativeSidecar tests that a pod with a healthy running native sidecar
// is not marked as unhealthy.
func TestIsPodUnhealthy_HealthyNativeSidecar(t *testing.T) {
startedTrue := true
restartPolicyAlways := corev1.ContainerRestartPolicyAlways
pod := &corev1.Pod{
Spec: corev1.PodSpec{
InitContainers: []corev1.Container{
{
Name: "istio-proxy",
RestartPolicy: &restartPolicyAlways,
},
},
Containers: []corev1.Container{
{
Name: "app",
},
},
},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
Conditions: []corev1.PodCondition{
{
Type: corev1.PodReady,
Status: corev1.ConditionTrue,
},
},
InitContainerStatuses: []corev1.ContainerStatus{
{
Name: "istio-proxy",
Ready: true,
Started: &startedTrue,
State: corev1.ContainerState{
Running: &corev1.ContainerStateRunning{},
},
},
},
ContainerStatuses: []corev1.ContainerStatus{
{
Name: "app",
Ready: true,
Started: &startedTrue,
State: corev1.ContainerState{
Running: &corev1.ContainerStateRunning{},
},
},
},
},
}
unhealthy := IsPodUnhealthy(pod)
if unhealthy {
t.Error("Pod with healthy native sidecar should not be marked as unhealthy")
}
}
// TestGetPodStatusReason_TraditionalInitAndNativeSidecar tests that a pod with both
// a completed traditional init container and a running native sidecar is reported as "Running".
func TestGetPodStatusReason_TraditionalInitAndNativeSidecar(t *testing.T) {
startedTrue := true
restartPolicyAlways := corev1.ContainerRestartPolicyAlways
pod := &corev1.Pod{
Spec: corev1.PodSpec{
InitContainers: []corev1.Container{
{
Name: "init-setup",
// No RestartPolicy = traditional init container
},
{
Name: "istio-proxy",
RestartPolicy: &restartPolicyAlways, // Native sidecar
},
},
Containers: []corev1.Container{
{
Name: "app",
},
},
},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
Conditions: []corev1.PodCondition{
{
Type: corev1.PodReady,
Status: corev1.ConditionTrue,
},
},
InitContainerStatuses: []corev1.ContainerStatus{
{
Name: "init-setup",
State: corev1.ContainerState{
Terminated: &corev1.ContainerStateTerminated{
ExitCode: 0,
Reason: "Completed",
},
},
},
{
Name: "istio-proxy",
Ready: true,
Started: &startedTrue,
State: corev1.ContainerState{
Running: &corev1.ContainerStateRunning{},
},
},
},
ContainerStatuses: []corev1.ContainerStatus{
{
Name: "app",
Ready: true,
Started: &startedTrue,
State: corev1.ContainerState{
Running: &corev1.ContainerStateRunning{},
},
},
},
},
}
reason, _ := GetPodStatusReason(pod)
if reason != "Running" {
t.Errorf("Expected reason 'Running', got '%s'", reason)
}
}
// TestGetPodStatusReason_NativeSidecarCrashLoopBackOff tests that a native sidecar
// in CrashLoopBackOff is still correctly detected as an error.
func TestGetPodStatusReason_NativeSidecarCrashLoopBackOff(t *testing.T) {
restartPolicyAlways := corev1.ContainerRestartPolicyAlways
pod := &corev1.Pod{
Spec: corev1.PodSpec{
InitContainers: []corev1.Container{
{
Name: "istio-proxy",
RestartPolicy: &restartPolicyAlways,
},
},
Containers: []corev1.Container{
{
Name: "app",
},
},
},
Status: corev1.PodStatus{
Phase: corev1.PodPending,
InitContainerStatuses: []corev1.ContainerStatus{
{
Name: "istio-proxy",
Ready: false,
State: corev1.ContainerState{
Waiting: &corev1.ContainerStateWaiting{
Reason: "CrashLoopBackOff",
Message: "Back-off 5m0s restarting failed container",
},
},
},
},
},
}
reason, _ := GetPodStatusReason(pod)
// Should still catch the error
if reason != "Init:CrashLoopBackOff" {
t.Errorf("Expected reason 'Init:CrashLoopBackOff', got '%s'", reason)
}
}
// TestGetPodStatusReason_TraditionalInitStuck tests that a traditional init container
// that is stuck running is still correctly detected as stuck initializing.
func TestGetPodStatusReason_TraditionalInitStuck(t *testing.T) {
pod := &corev1.Pod{
Spec: corev1.PodSpec{
InitContainers: []corev1.Container{
{
Name: "init-setup",
// No RestartPolicy = traditional init
},
},
Containers: []corev1.Container{
{
Name: "app",
},
},
},
Status: corev1.PodStatus{
Phase: corev1.PodPending,
InitContainerStatuses: []corev1.ContainerStatus{
{
Name: "init-setup",
Ready: false,
State: corev1.ContainerState{
Running: &corev1.ContainerStateRunning{},
},
},
},
},
}
reason, _ := GetPodStatusReason(pod)
// Traditional init running = stuck
if reason != "Init:0/1" {
t.Errorf("Expected reason 'Init:0/1', got '%s'", reason)
}
}
+17
View File
@@ -0,0 +1,17 @@
package k8sutil
import corev1 "k8s.io/api/core/v1"
// TaintExists checks if the given taint exists in list of taints. Returns true
// if exists false otherwise.
//
// Copied from k8s.io/kubernetes/pkg/util/taints so we don't have to import
// k8s.io/kubernetes.
func TaintExists(taints []corev1.Taint, taintToFind *corev1.Taint) bool {
for _, taint := range taints {
if taint.MatchTaint(taintToFind) {
return true
}
}
return false
}
+345
View File
@@ -0,0 +1,345 @@
package lint
import (
"regexp"
"strings"
"github.com/replicatedhq/troubleshoot/pkg/constants"
)
func applyFixesInMemory(content string, result LintResult) (string, bool, error) {
fixed := false
newContent := content
lines := strings.Split(newContent, "\n")
// Fix A: If templating errors exist in a v1beta2 file, upgrade apiVersion to v1beta3 (minimal, deterministic)
hasTemplateInV1beta2 := false
for _, e := range result.Errors {
if e.Field == "template" && strings.Contains(e.Message, "not supported in v1beta2") {
hasTemplateInV1beta2 = true
break
}
}
if hasTemplateInV1beta2 {
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "apiVersion:") && strings.Contains(line, constants.Troubleshootv1beta2Kind) {
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
lines[i] = indent + "apiVersion: " + constants.Troubleshootv1beta3Kind
fixed = true
break
}
}
}
// Sort errors by line number (descending) to avoid line number shifts when editing
errorsByLine := make(map[int][]LintError)
for _, err := range result.Errors {
if err.Line > 0 {
errorsByLine[err.Line] = append(errorsByLine[err.Line], err)
}
}
// Process errors line by line
for lineNum, errs := range errorsByLine {
if lineNum > len(lines) {
continue
}
line := lines[lineNum-1]
originalLine := line
for _, err := range errs {
// Fix 1: Add missing colon
if strings.Contains(err.Message, "could not find expected ':'") {
if !strings.Contains(line, ":") {
trimmed := strings.TrimSpace(line)
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
line = indent + trimmed + ":"
fixed = true
}
}
// Fix 2: Add missing leading dot in template expressions
if strings.Contains(err.Message, "Template expression may be missing leading dot:") {
// Extract the expression from the error message
re := regexp.MustCompile(`Template expression may be missing leading dot: \{\{ (.+?) \}\}`)
matches := re.FindStringSubmatch(err.Message)
if len(matches) > 1 {
badExpr := matches[1]
// Add the leading dot
fixedExpr := "." + badExpr
// Replace in the line
line = strings.Replace(line, "{{ "+badExpr+" }}", "{{ "+fixedExpr+" }}", 1)
line = strings.Replace(line, "{{"+badExpr+"}}", "{{"+fixedExpr+"}}", 1)
line = strings.Replace(line, "{{- "+badExpr+" }}", "{{- "+fixedExpr+" }}", 1)
line = strings.Replace(line, "{{- "+badExpr+" -}}", "{{- "+fixedExpr+" -}}", 1)
fixed = true
}
}
// Fix 3: Fix wrong apiVersion
if strings.Contains(err.Message, "File must contain apiVersion:") && err.Field == "apiVersion" {
if strings.Contains(line, "apiVersion:") && !strings.Contains(line, constants.Troubleshootv1beta3Kind) {
// Replace existing apiVersion with correct one
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
line = indent + "apiVersion: " + constants.Troubleshootv1beta3Kind
fixed = true
}
}
}
// Update the line if it changed
if line != originalLine {
lines[lineNum-1] = line
}
}
// Fix B: Wrap mapping under required list fields (collectors, hostCollectors, analyzers)
for _, err := range result.Errors {
if strings.HasPrefix(err.Message, "Expected 'collectors' to be a list") {
if wrapFirstChildAsList(&lines, "collectors:") {
fixed = true
}
}
if strings.HasPrefix(err.Message, "Expected 'hostCollectors' to be a list") {
if wrapFirstChildAsList(&lines, "hostCollectors:") || convertScalarToEmptyList(&lines, "hostCollectors:") {
fixed = true
}
}
if strings.HasPrefix(err.Message, "Expected 'analyzers' to be a list") {
if wrapFirstChildAsList(&lines, "analyzers:") {
fixed = true
}
}
}
// Fix C: Add missing required fields with empty placeholders (non-assumptive)
// Collectors
for _, err := range result.Errors {
if strings.HasPrefix(err.Message, "Missing required field '") && strings.Contains(err.Message, " for collector '") {
// Parse field and collector type
// e.g., Missing required field 'namespace' for collector 'ceph'
fieldName := between(err.Message, "Missing required field '", "'")
collectorType := betweenAfter(err.Message, "collector '", "'")
if fieldName == "" || collectorType == "" {
continue
}
// Only handle simple case where the list item is in {} form: "- type: {}"
// Find the list item line from current content
cur := strings.Join(lines, "\n")
lineNum := findCollectorLine(cur, "collectors", indexFromField(err.Field))
if lineNum > 0 {
li := lineNum - 1
if strings.Contains(lines[li], "- "+collectorType+": {}") {
indent := lines[li][:len(lines[li])-len(strings.TrimLeft(lines[li], " \t"))]
childIndent := indent + " "
// choose placeholder: outcomes -> [] ; others -> ""
placeholder := "\"\""
if fieldName == "outcomes" {
placeholder = "[]"
}
lines[li] = strings.Replace(lines[li], ": {}", ":\n"+childIndent+fieldName+": "+placeholder, 1)
fixed = true
} else if strings.Contains(lines[li], "- "+collectorType+":") {
// Multi-line mapping; insert missing field under this item
if insertMissingFieldUnderListItem(&lines, li, fieldName) {
fixed = true
}
}
}
}
}
// Analyzers
for _, err := range result.Errors {
if strings.HasPrefix(err.Message, "Missing required field '") && strings.Contains(err.Message, " for analyzer '") {
fieldName := between(err.Message, "Missing required field '", "'")
analyzerType := betweenAfter(err.Message, "analyzer '", "'")
if fieldName == "" || analyzerType == "" {
continue
}
cur := strings.Join(lines, "\n")
lineNum := findAnalyzerLine(cur, indexFromField(err.Field))
if lineNum > 0 {
li := lineNum - 1
if strings.Contains(lines[li], "- "+analyzerType+": {}") {
indent := lines[li][:len(lines[li])-len(strings.TrimLeft(lines[li], " \t"))]
childIndent := indent + " "
placeholder := "\"\""
if fieldName == "outcomes" {
placeholder = "[]"
}
lines[li] = strings.Replace(lines[li], ": {}", ":\n"+childIndent+fieldName+": "+placeholder, 1)
fixed = true
} else if strings.Contains(lines[li], "- "+analyzerType+":") {
if insertMissingFieldUnderListItem(&lines, li, fieldName) {
fixed = true
}
}
}
}
}
// Return fixed content if changes were made
if fixed {
newContent = strings.Join(lines, "\n")
return newContent, true, nil
}
return content, false, nil
}
// wrapFirstChildAsList prefixes the first child mapping line under the given key with '- '
func wrapFirstChildAsList(lines *[]string, key string) bool {
arr := *lines
// find key line index
baseIdx := -1
for i, l := range arr {
if strings.Contains(l, key) {
baseIdx = i
break
}
}
if baseIdx == -1 {
return false
}
baseIndent := arr[baseIdx][:len(arr[baseIdx])-len(strings.TrimLeft(arr[baseIdx], " \t"))]
// find first child line with greater indent
for j := baseIdx + 1; j < len(arr); j++ {
line := arr[j]
if strings.TrimSpace(line) == "" {
continue
}
// stop when indentation goes back to or less than base
if !strings.HasPrefix(line, baseIndent+" ") && !strings.HasPrefix(line, baseIndent+"\t") {
break
}
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "- ") {
// already a list
return false
}
// prefix '- '
childIndent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
arr[j] = childIndent + "- " + strings.TrimSpace(line)
*lines = arr
return true
}
return false
}
// convertScalarToEmptyList changes `key: <scalar>` to `key: []` on the same line
func convertScalarToEmptyList(lines *[]string, key string) bool {
arr := *lines
for i, l := range arr {
trimmed := strings.TrimSpace(l)
if strings.HasPrefix(trimmed, key) {
// If already ends with ':' leave for wrapper; else replace value with []
if strings.HasSuffix(trimmed, ":") {
return false
}
// Replace everything after the first ':' with [] preserving indentation/key
parts := strings.SplitN(l, ":", 2)
if len(parts) == 2 {
arr[i] = parts[0] + ": []"
*lines = arr
return true
}
}
}
return false
}
// indexFromField extracts the numeric index from a path like spec.collectors[1] or spec.analyzers[0]
func indexFromField(field string) int {
// find [number]
start := strings.Index(field, "[")
end := strings.Index(field, "]")
if start == -1 || end == -1 || end <= start+1 {
return 0
}
numStr := field[start+1 : end]
// naive parse
n := 0
for _, ch := range numStr {
if ch < '0' || ch > '9' {
return 0
}
n = n*10 + int(ch-'0')
}
return n
}
// insertMissingFieldUnderListItem inserts "fieldName: <placeholder>" under list item at startIdx
// Placeholder is [] for outcomes, "" otherwise. Preserves indentation by using the next child indentation if available
func insertMissingFieldUnderListItem(lines *[]string, startIdx int, fieldName string) bool {
arr := *lines
baseLine := arr[startIdx]
baseIndent := baseLine[:len(baseLine)-len(strings.TrimLeft(baseLine, " \t"))]
// Determine child indentation: prefer next non-empty line's indent if deeper than base
childIndent := baseIndent + " "
insertPos := startIdx + 1
for j := startIdx + 1; j < len(arr); j++ {
if strings.TrimSpace(arr[j]) == "" {
insertPos = j + 1
continue
}
lineIndent := arr[j][:len(arr[j])-len(strings.TrimLeft(arr[j], " \t"))]
if len(lineIndent) > len(baseIndent) {
childIndent = lineIndent
insertPos = j
}
break
}
// Choose placeholder
placeholder := "\"\""
if fieldName == "outcomes" {
placeholder = "[]"
}
// Insert new line
newLine := childIndent + fieldName + ": " + placeholder
// Avoid duplicate insert if the field already exists within this block
for k := startIdx + 1; k < len(arr); k++ {
if strings.TrimSpace(arr[k]) == "" {
continue
}
// Stop when block ends (indentation returns to base or less)
kIndent := arr[k][:len(arr[k])-len(strings.TrimLeft(arr[k], " \t"))]
if len(kIndent) <= len(baseIndent) {
break
}
if strings.HasPrefix(strings.TrimSpace(arr[k]), fieldName+":") {
return false
}
}
arr = append(arr[:insertPos], append([]string{newLine}, arr[insertPos:]...)...)
*lines = arr
return true
}
// between extracts substring between prefix and suffix (first occurrences)
func between(s, prefix, suffix string) string {
i := strings.Index(s, prefix)
if i == -1 {
return ""
}
s2 := s[i+len(prefix):]
j := strings.Index(s2, suffix)
if j == -1 {
return ""
}
return s2[:j]
}
// betweenAfter extracts substring between prefix and suffix starting search after prefix
func betweenAfter(s, prefix, suffix string) string {
i := strings.Index(s, prefix)
if i == -1 {
return ""
}
s2 := s[i+len(prefix):]
j := strings.Index(s2, suffix)
if j == -1 {
return ""
}
return s2[:j]
}
+103
View File
@@ -0,0 +1,103 @@
package lint
import (
"fmt"
"strings"
)
// FormatResults formats lint results for output
func FormatResults(results []LintResult, format string) string {
if format == "json" {
return formatJSON(results)
}
return formatText(results)
}
func formatText(results []LintResult) string {
var output strings.Builder
totalErrors := 0
totalWarnings := 0
for _, result := range results {
if len(result.Errors) == 0 && len(result.Warnings) == 0 {
output.WriteString(fmt.Sprintf("✓ %s: No issues found\n", result.FilePath))
continue
}
output.WriteString(fmt.Sprintf("\n%s:\n", result.FilePath))
for _, err := range result.Errors {
output.WriteString(fmt.Sprintf(" ✗ Error (line %d): %s\n", err.Line, err.Message))
if err.Field != "" {
output.WriteString(fmt.Sprintf(" Field: %s\n", err.Field))
}
totalErrors++
}
for _, warn := range result.Warnings {
output.WriteString(fmt.Sprintf(" ⚠ Warning (line %d): %s\n", warn.Line, warn.Message))
if warn.Field != "" {
output.WriteString(fmt.Sprintf(" Field: %s\n", warn.Field))
}
totalWarnings++
}
}
output.WriteString(fmt.Sprintf("\nSummary: %d error(s), %d warning(s) across %d file(s)\n", totalErrors, totalWarnings, len(results)))
return output.String()
}
func formatJSON(results []LintResult) string {
// Simple JSON formatting without importing encoding/json
var output strings.Builder
output.WriteString("{\n")
output.WriteString(" \"results\": [\n")
for i, result := range results {
output.WriteString(" {\n")
output.WriteString(fmt.Sprintf(" \"filePath\": %q,\n", result.FilePath))
output.WriteString(" \"errors\": [\n")
for j, err := range result.Errors {
output.WriteString(" {\n")
output.WriteString(fmt.Sprintf(" \"line\": %d,\n", err.Line))
output.WriteString(fmt.Sprintf(" \"column\": %d,\n", err.Column))
output.WriteString(fmt.Sprintf(" \"message\": %q,\n", err.Message))
output.WriteString(fmt.Sprintf(" \"field\": %q\n", err.Field))
output.WriteString(" }")
if j < len(result.Errors)-1 {
output.WriteString(",")
}
output.WriteString("\n")
}
output.WriteString(" ],\n")
output.WriteString(" \"warnings\": [\n")
for j, warn := range result.Warnings {
output.WriteString(" {\n")
output.WriteString(fmt.Sprintf(" \"line\": %d,\n", warn.Line))
output.WriteString(fmt.Sprintf(" \"column\": %d,\n", warn.Column))
output.WriteString(fmt.Sprintf(" \"message\": %q,\n", warn.Message))
output.WriteString(fmt.Sprintf(" \"field\": %q\n", warn.Field))
output.WriteString(" }")
if j < len(result.Warnings)-1 {
output.WriteString(",")
}
output.WriteString("\n")
}
output.WriteString(" ]\n")
output.WriteString(" }")
if i < len(results)-1 {
output.WriteString(",")
}
output.WriteString("\n")
}
output.WriteString(" ]\n")
output.WriteString("}\n")
return output.String()
}
+63
View File
@@ -0,0 +1,63 @@
package lint
import (
"fmt"
"regexp"
"strings"
)
// findLineNumber returns the first 1-based line number containing the search string
func findLineNumber(content, search string) int {
lines := strings.Split(content, "\n")
for i, line := range lines {
if strings.Contains(line, search) {
return i + 1
}
}
return 0
}
func findAnalyzerLine(content string, index int) int {
return findListItemLine(content, "analyzers", index)
}
// findCollectorLine locates the starting line of the Nth entry in a collectors list
func findCollectorLine(content string, field string, index int) int {
return findListItemLine(content, field, index)
}
// findListItemLine locates the starting line of the Nth entry in a list under listKey
func findListItemLine(content, listKey string, index int) int {
lines := strings.Split(content, "\n")
count := 0
inList := false
for i, line := range lines {
if strings.Contains(line, listKey+":") {
inList = true
continue
}
if inList && strings.HasPrefix(strings.TrimSpace(line), "- ") {
if count == index {
return i + 1
}
count++
}
if inList && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") && strings.TrimSpace(line) != "" {
break
}
}
return 0
}
// extractLineFromError tries to parse a YAML error message for a line number
func extractLineFromError(err error) int {
// Try to extract line number from YAML error message
re := regexp.MustCompile(`line (\d+)`)
matches := re.FindStringSubmatch(err.Error())
if len(matches) > 1 {
var line int
fmt.Sscanf(matches[1], "%d", &line)
return line
}
return 0
}
+325
View File
@@ -0,0 +1,325 @@
package lint
import (
"fmt"
"os"
"strings"
"sync"
"github.com/pkg/errors"
"github.com/replicatedhq/troubleshoot/internal/util"
"github.com/replicatedhq/troubleshoot/pkg/constants"
"github.com/replicatedhq/troubleshoot/pkg/preflight"
"helm.sh/helm/v3/pkg/strvals"
"sigs.k8s.io/yaml"
)
// LintFiles validates troubleshoot specs for syntax and structural errors
func LintFiles(opts LintOptions) ([]LintResult, error) {
results := []LintResult{}
// Load known analyzer/collector types from schemas (best effort)
ensureKnownTypesLoaded()
for _, filePath := range opts.FilePaths {
// Read entire file once
fileBytes, readErr := os.ReadFile(filePath)
if readErr != nil {
return nil, errors.Wrapf(readErr, "failed to read file %s", filePath)
}
fileContent := string(fileBytes)
// Check if this is a v1beta3 spec
isV1Beta3 := detectAPIVersionFromContent(fileContent) == constants.Troubleshootv1beta3Kind
isV1Beta2 := detectAPIVersionFromContent(fileContent) == constants.Troubleshootv1beta2Kind
// Check if the content has Helm templates (for preflight v1beta3)
hasTemplates := strings.Contains(fileContent, "{{") && strings.Contains(fileContent, "}}")
// Track if we should add a warning about unused values for v1beta2
hasUnusedValuesWarning := isV1Beta2 && (len(opts.ValuesFiles) > 0 || len(opts.SetValues) > 0)
// If v1beta3 with templates, require values and render the template
if isV1Beta3 && hasTemplates {
if len(opts.ValuesFiles) == 0 && len(opts.SetValues) == 0 {
return nil, errors.New("v1beta3 specs with Helm templates require a values file. Please provide values using --values or --set flags")
}
// Load values from files and --set flags
values := make(map[string]interface{})
for _, valuesFile := range opts.ValuesFiles {
if valuesFile == "" {
continue
}
data, err := os.ReadFile(valuesFile)
if err != nil {
return nil, errors.Wrapf(err, "failed to read values file %s", valuesFile)
}
var fileValues map[string]interface{}
if err := yaml.Unmarshal(data, &fileValues); err != nil {
return nil, errors.Wrapf(err, "failed to parse values file %s", valuesFile)
}
values = preflight.MergeMaps(values, fileValues)
}
// Apply --set values
for _, setValue := range opts.SetValues {
if err := strvals.ParseInto(setValue, values); err != nil {
return nil, errors.Wrapf(err, "failed to parse --set value: %s", setValue)
}
}
// Render the template
preflight.SeedDefaultBooleans(fileContent, values)
preflight.SeedParentMapsForValueRefs(fileContent, values)
rendered, err := preflight.RenderWithHelmTemplate(fileContent, values)
if err != nil {
// If rendering fails, create a result with the render error
// This allows us to report template syntax errors
results = append(results, LintResult{
FilePath: filePath,
Errors: []LintError{
{
Line: 1,
Message: fmt.Sprintf("Failed to render v1beta3 template: %v", err),
Field: "template",
},
},
})
continue
}
// Use the rendered content for linting
fileContent = rendered
}
// Split into YAML documents
docs := util.SplitYAML(fileContent)
// Pre-compute starting line number for each doc within the file (1-based)
docStarts := make([]int, len(docs))
runningStart := 1
for i, d := range docs {
docStarts[i] = runningStart
// Count lines in this doc
runningStart += util.EstimateNumberOfLines(d)
// Account for the '---' separator line between documents
if i < len(docs)-1 {
runningStart += 1
}
}
// Lint each document, in parallel
type docOutcome struct {
errs []LintError
warns []LintWarning
newDoc string
changed bool
}
outcomes := make([]docOutcome, len(docs))
var wg sync.WaitGroup
wg.Add(len(docs))
for i := range docs {
i := i
go func() {
defer wg.Done()
// Compute lint result for this doc, optionally applying fixes in-memory
res, finalDoc, _ /*changed*/, _ := lintContentInMemory(docs[i], opts.Fix)
// Adjust line numbers to file coordinates
lineOffset := docStarts[i] - 1
for idx := range res.Errors {
if res.Errors[idx].Line > 0 {
res.Errors[idx].Line += lineOffset
}
}
for idx := range res.Warnings {
if res.Warnings[idx].Line > 0 {
res.Warnings[idx].Line += lineOffset
}
}
changed := finalDoc != docs[i]
outcomes[i] = docOutcome{
errs: res.Errors,
warns: res.Warnings,
newDoc: finalDoc,
changed: changed,
}
}()
}
wg.Wait()
// Assemble per-file result
fileResult := LintResult{FilePath: filePath}
writeNeeded := false
newDocs := make([]string, len(docs))
for i, oc := range outcomes {
fileResult.Errors = append(fileResult.Errors, oc.errs...)
fileResult.Warnings = append(fileResult.Warnings, oc.warns...)
if oc.changed {
writeNeeded = true
}
if oc.newDoc == "" {
newDocs[i] = docs[i]
} else {
newDocs[i] = oc.newDoc
}
}
// Add warning if values were provided for a v1beta2 spec
if hasUnusedValuesWarning {
fileResult.Warnings = append([]LintWarning{
{
Line: 1,
Message: "Values files provided but this is a v1beta2 spec. Values are only used with v1beta3 specs. Did you mean to use apiVersion: troubleshoot.sh/v1beta3?",
Field: "apiVersion",
},
}, fileResult.Warnings...)
}
if writeNeeded {
// Reassemble with the same delimiter used by util.SplitYAML
updated := strings.Join(newDocs, "\n---\n")
if writeErr := os.WriteFile(filePath, []byte(updated), 0644); writeErr != nil {
return nil, errors.Wrapf(writeErr, "failed to write fixed content to %s", filePath)
}
}
results = append(results, fileResult)
}
return results, nil
}
func lintContentInMemory(content string, fix bool) (LintResult, string, bool, error) {
// Compute result for the provided content
compute := func(body string) LintResult {
res := LintResult{Errors: []LintError{}, Warnings: []LintWarning{}}
// Check if content contains template expressions
hasTemplates := strings.Contains(body, "{{") && strings.Contains(body, "}}")
// Validate YAML syntax (but be lenient with templated files)
var parsed map[string]interface{}
if err := yaml.Unmarshal([]byte(body), &parsed); err != nil {
// If the content has templates, YAML parsing may fail - that's expected for v1beta3 only
if !hasTemplates {
res.Errors = append(res.Errors, LintError{
Line: extractLineFromError(err),
Message: fmt.Sprintf("YAML syntax error: %s", err.Error()),
})
return res
}
// Attempt to detect apiVersion from raw content
detectedAPIVersion := detectAPIVersionFromContent(body)
if detectedAPIVersion == "" {
res.Errors = append(res.Errors, LintError{
Line: findLineNumber(body, "apiVersion"),
Field: "apiVersion",
Message: "Missing or unreadable 'apiVersion' field",
})
return res
}
if detectedAPIVersion == constants.Troubleshootv1beta2Kind {
// v1beta2 does not support templating
addTemplatingErrorsForAllLines(&res, body)
return res
}
// For v1beta3 with templates, we can't parse YAML strictly, so just check template syntax
templateErrors, templateValueRefs := checkTemplateSyntax(body)
res.Errors = append(res.Errors, templateErrors...)
// Add warning about template values for v1beta3
if detectedAPIVersion == constants.Troubleshootv1beta3Kind && len(templateValueRefs) > 0 {
res.Warnings = append(res.Warnings, LintWarning{
Line: 1,
Field: "template-values",
Message: fmt.Sprintf("Template values that must be provided at runtime: %s", strings.Join(templateValueRefs, ", ")),
})
}
return res
}
// Determine apiVersion from parsed YAML
apiVersion := ""
if v, ok := parsed["apiVersion"].(string); ok {
apiVersion = v
}
if apiVersion == "" {
res.Errors = append(res.Errors, LintError{
Line: findLineNumber(body, "apiVersion"),
Field: "apiVersion",
Message: "Missing or empty 'apiVersion' field",
})
return res
}
// Templating policy: only v1beta3 supports templating
if apiVersion == constants.Troubleshootv1beta2Kind && hasTemplates {
addTemplatingErrorsForAllLines(&res, body)
}
// Check required fields
res.Errors = append(res.Errors, checkRequiredFields(parsed, body)...)
// Check template syntax and collect template value references
templateErrors, templateValueRefs := checkTemplateSyntax(body)
res.Errors = append(res.Errors, templateErrors...)
// Check for kind-specific requirements
if kind, ok := parsed["kind"].(string); ok {
switch kind {
case "Preflight":
res.Errors = append(res.Errors, checkPreflightSpec(parsed, body)...)
// Validate analyzer entries
res.Errors = append(res.Errors, validateAnalyzers(parsed, body)...)
case "SupportBundle":
res.Errors = append(res.Errors, checkSupportBundleSpec(parsed, body)...)
// Validate analyzers if present in SupportBundle specs as well
res.Errors = append(res.Errors, validateAnalyzers(parsed, body)...)
// Validate collector entries (collectors and hostCollectors)
res.Errors = append(res.Errors, validateCollectors(parsed, body, "collectors")...)
res.Errors = append(res.Errors, validateCollectors(parsed, body, "hostCollectors")...)
}
}
// Check for common issues
res.Warnings = append(res.Warnings, checkCommonIssues(parsed, body, apiVersion, templateValueRefs)...)
return res
}
// Initial lint
result := compute(content)
// Apply fixes if requested (multi-pass within a single invocation), in-memory
changed := false
if fix && (len(result.Errors) > 0 || len(result.Warnings) > 0) {
const maxFixPasses = 3
for pass := 0; pass < maxFixPasses; pass++ {
updatedContent, fixed, err := applyFixesInMemory(content, result)
if err != nil {
return result, content, changed, err
}
if !fixed {
break
}
changed = true
content = updatedContent
// Recompute without applying fixes in this cycle
result = compute(content)
if len(result.Errors) == 0 && len(result.Warnings) == 0 {
break
}
}
}
return result, content, changed, nil
}
+466
View File
@@ -0,0 +1,466 @@
package lint
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLintMultipleFiles(t *testing.T) {
// Get the project root by going up from pkg/lint
projectRoot := filepath.Join("..", "..")
testDir := filepath.Join(projectRoot, "examples", "test-error-messages")
tests := []struct {
name string
files []string
valuesFiles []string // values files for v1beta3 specs
expectErrors map[string][]string // filename -> expected error substrings
expectWarnings map[string][]string // filename -> expected warning substrings
expectPass map[string]bool // filename -> should pass without errors
}{
{
name: "valid v1beta3 with templates",
files: []string{
"helm-builtins-v1beta3.yaml",
},
valuesFiles: []string{
"values-helm-builtins.yaml",
},
expectErrors: map[string][]string{},
expectWarnings: map[string][]string{},
expectPass: map[string]bool{
"helm-builtins-v1beta3.yaml": true,
},
},
{
name: "invalid collectors and analyzers",
files: []string{
"invalid-collectors-analyzers.yaml",
},
valuesFiles: []string{
"values-empty.yaml",
},
expectErrors: map[string][]string{
"invalid-collectors-analyzers.yaml": {
// The linter may stop early due to structural issues
// At minimum, it should catch the hostCollectors type error
"Expected 'hostCollectors' to be a list",
},
},
expectPass: map[string]bool{
"invalid-collectors-analyzers.yaml": false,
},
},
{
name: "missing required fields",
files: []string{
"missing-apiversion-v1beta3.yaml",
"missing-metadata-v1beta3.yaml",
"no-analyzers-v1beta3.yaml",
},
valuesFiles: []string{
"values-empty.yaml",
},
expectErrors: map[string][]string{
"missing-apiversion-v1beta3.yaml": {
"Missing or empty 'apiVersion' field",
},
"missing-metadata-v1beta3.yaml": {
"Missing 'metadata' section",
},
"no-analyzers-v1beta3.yaml": {
"Preflight spec must contain 'analyzers'",
},
},
expectPass: map[string]bool{
"missing-apiversion-v1beta3.yaml": false,
"missing-metadata-v1beta3.yaml": false,
"no-analyzers-v1beta3.yaml": false,
},
},
{
name: "v1beta2 file (valid but with docString warning)",
files: []string{
"wrong-apiversion-v1beta3.yaml", // Actually has v1beta2 which is valid
},
expectErrors: map[string][]string{},
expectWarnings: map[string][]string{
"wrong-apiversion-v1beta3.yaml": {
"Some analyzers are missing docString",
},
},
expectPass: map[string]bool{
"wrong-apiversion-v1beta3.yaml": true, // No errors, just warnings
},
},
{
name: "support bundle specs",
files: []string{
"support-bundle-no-collectors-v1beta3.yaml",
"support-bundle-valid-v1beta3.yaml",
},
valuesFiles: []string{
"values-empty.yaml",
},
expectErrors: map[string][]string{
"support-bundle-no-collectors-v1beta3.yaml": {
"SupportBundle spec must contain 'collectors' or 'hostCollectors'",
},
},
expectPass: map[string]bool{
"support-bundle-no-collectors-v1beta3.yaml": false,
"support-bundle-valid-v1beta3.yaml": true,
},
},
{
name: "multiple files with mixed validity",
files: []string{
"support-bundle-valid-v1beta3.yaml",
"missing-metadata-v1beta3.yaml",
"wrong-apiversion-v1beta3.yaml",
},
valuesFiles: []string{
"values-empty.yaml",
},
expectErrors: map[string][]string{
"missing-metadata-v1beta3.yaml": {
"Missing 'metadata' section",
},
},
expectWarnings: map[string][]string{
"wrong-apiversion-v1beta3.yaml": {
"Some analyzers are missing docString",
},
},
expectPass: map[string]bool{
"support-bundle-valid-v1beta3.yaml": true,
"missing-metadata-v1beta3.yaml": false,
"wrong-apiversion-v1beta3.yaml": true, // No errors, just warnings
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Build full file paths
filePaths := make([]string, len(tt.files))
for i, f := range tt.files {
filePaths[i] = filepath.Join(testDir, f)
// Check file exists
if _, err := os.Stat(filePaths[i]); os.IsNotExist(err) {
t.Skipf("Test file %s does not exist, skipping", filePaths[i])
}
}
// Build values file paths
var valuesFilePaths []string
for _, vf := range tt.valuesFiles {
valuesFilePaths = append(valuesFilePaths, filepath.Join(testDir, vf))
}
// Run linter
opts := LintOptions{
FilePaths: filePaths,
Fix: false,
Format: "text",
ValuesFiles: valuesFilePaths,
}
results, err := LintFiles(opts)
if err != nil {
t.Fatalf("LintFiles failed: %v", err)
}
// Verify we got results for all files
if len(results) != len(filePaths) {
t.Errorf("Expected %d results, got %d", len(filePaths), len(results))
}
// Check each result
for _, result := range results {
filename := filepath.Base(result.FilePath)
// Check expected errors
if expectedErrors, ok := tt.expectErrors[filename]; ok {
if len(expectedErrors) > 0 && len(result.Errors) == 0 {
t.Errorf("File %s: expected errors but got none", filename)
}
for _, expectedErr := range expectedErrors {
found := false
for _, err := range result.Errors {
if strings.Contains(err.Message, expectedErr) {
found = true
break
}
}
if !found {
t.Errorf("File %s: expected error containing '%s' but not found in errors: %v",
filename, expectedErr, getErrorMessages(result.Errors))
}
}
}
// Check expected warnings
if expectedWarnings, ok := tt.expectWarnings[filename]; ok {
for _, expectedWarn := range expectedWarnings {
found := false
for _, warn := range result.Warnings {
if strings.Contains(warn.Message, expectedWarn) {
found = true
break
}
}
if !found {
t.Errorf("File %s: expected warning containing '%s' but not found in warnings: %v",
filename, expectedWarn, getWarningMessages(result.Warnings))
}
}
}
// Check if should pass
if shouldPass, ok := tt.expectPass[filename]; ok {
hasNoErrors := len(result.Errors) == 0
if shouldPass && !hasNoErrors {
t.Errorf("File %s: expected to pass but has errors: %v",
filename, getErrorMessages(result.Errors))
} else if !shouldPass && hasNoErrors && len(tt.expectErrors[filename]) > 0 {
t.Errorf("File %s: expected to fail but passed", filename)
}
}
}
})
}
}
func TestLintWithFix(t *testing.T) {
// Create a temporary directory for test files
tmpDir, err := os.MkdirTemp("", "lint-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
tests := []struct {
name string
content string
expectFix bool
fixedContent string // substring that should appear after fix
}{
{
name: "fix v1beta2 with templates to v1beta3",
content: `apiVersion: troubleshoot.sh/v1beta2
kind: Preflight
metadata:
name: test-{{ .Values.name }}
spec:
analyzers:
- clusterVersion:
outcomes:
- pass:
when: '>= 1.19.0'
message: OK`,
expectFix: true,
fixedContent: "apiVersion: troubleshoot.sh/v1beta3",
},
{
name: "v1beta3 template syntax error is reported",
content: `apiVersion: troubleshoot.sh/v1beta3
kind: Preflight
metadata:
name: test-{{ Values.name }}
spec:
analyzers:
- clusterVersion:
outcomes:
- pass:
when: '>= 1.19.0'
message: OK`,
expectFix: false, // Template errors in v1beta3 are not auto-fixable, rendering will fail
fixedContent: "Failed to render v1beta3 template", // Expect an error message
},
}
// Create empty values file for v1beta3 tests
emptyValuesFile := filepath.Join(tmpDir, "values-empty.yaml")
if err := os.WriteFile(emptyValuesFile, []byte("{}"), 0644); err != nil {
t.Fatalf("Failed to write empty values file: %v", err)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Write test content to temp file
testFile := filepath.Join(tmpDir, tt.name+".yaml")
if err := os.WriteFile(testFile, []byte(tt.content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Run linter with fix enabled
opts := LintOptions{
FilePaths: []string{testFile},
Fix: true,
Format: "text",
ValuesFiles: []string{emptyValuesFile},
}
results, err := LintFiles(opts)
if err != nil {
t.Fatalf("LintFiles failed: %v", err)
}
if len(results) != 1 {
t.Fatalf("Expected 1 result, got %d", len(results))
}
// Read the potentially fixed content
fixedBytes, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("Failed to read fixed file: %v", err)
}
fixedContent := string(fixedBytes)
// Check if fix was applied or error was reported
if tt.expectFix {
if !strings.Contains(fixedContent, tt.fixedContent) {
t.Errorf("Expected fixed content to contain '%s', but got:\n%s",
tt.fixedContent, fixedContent)
}
} else {
// For tests that don't expect fix, check for errors
if len(results[0].Errors) > 0 {
errorFound := false
for _, err := range results[0].Errors {
if strings.Contains(err.Message, tt.fixedContent) {
errorFound = true
break
}
}
if !errorFound {
t.Errorf("Expected error containing '%s', but got errors: %v",
tt.fixedContent, results[0].Errors)
}
}
}
})
}
}
func TestHasErrors(t *testing.T) {
tests := []struct {
name string
results []LintResult
expected bool
}{
{
name: "no errors",
results: []LintResult{
{
FilePath: "test1.yaml",
Errors: []LintError{},
Warnings: []LintWarning{{Message: "warning"}},
},
{
FilePath: "test2.yaml",
Errors: []LintError{},
},
},
expected: false,
},
{
name: "has errors",
results: []LintResult{
{
FilePath: "test1.yaml",
Errors: []LintError{{Message: "error"}},
},
{
FilePath: "test2.yaml",
Errors: []LintError{},
},
},
expected: true,
},
{
name: "empty results",
results: []LintResult{},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := HasErrors(tt.results)
if result != tt.expected {
t.Errorf("HasErrors() = %v, want %v", result, tt.expected)
}
})
}
}
func TestFormatResults(t *testing.T) {
results := []LintResult{
{
FilePath: "test.yaml",
Errors: []LintError{
{Line: 5, Message: "Missing field", Field: "spec.analyzers"},
},
Warnings: []LintWarning{
{Line: 10, Message: "Consider adding docString", Field: "spec.analyzers[0]"},
},
},
}
t.Run("text format", func(t *testing.T) {
output := FormatResults(results, "text")
// Check for key components in text output
if !strings.Contains(output, "test.yaml") {
t.Error("Text output missing file path")
}
if !strings.Contains(output, "Error (line 5)") {
t.Error("Text output missing error with line number")
}
if !strings.Contains(output, "Warning (line 10)") {
t.Error("Text output missing warning with line number")
}
if !strings.Contains(output, "Summary:") {
t.Error("Text output missing summary")
}
})
t.Run("json format", func(t *testing.T) {
output := FormatResults(results, "json")
// Check for key JSON components
if !strings.Contains(output, `"filePath"`) {
t.Error("JSON output missing filePath field")
}
if !strings.Contains(output, `"errors"`) {
t.Error("JSON output missing errors field")
}
if !strings.Contains(output, `"warnings"`) {
t.Error("JSON output missing warnings field")
}
if !strings.Contains(output, `"line": 5`) {
t.Error("JSON output missing line number")
}
})
}
// Helper functions
func getErrorMessages(errors []LintError) []string {
messages := make([]string, len(errors))
for i, err := range errors {
messages[i] = err.Message
}
return messages
}
func getWarningMessages(warnings []LintWarning) []string {
messages := make([]string, len(warnings))
for i, warn := range warnings {
messages[i] = warn.Message
}
return messages
}
+181
View File
@@ -0,0 +1,181 @@
package lint
import (
"fmt"
"regexp"
"sort"
"strings"
)
// detectAPIVersionFromContent tries to extract apiVersion from raw YAML text
func detectAPIVersionFromContent(content string) string {
lines := strings.Split(content, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "apiVersion:") {
parts := strings.SplitN(trimmed, ":", 2)
if len(parts) == 2 {
val := strings.TrimSpace(parts[1])
// strip quotes if present
val = strings.Trim(val, "'\"")
return val
}
}
}
return ""
}
// addTemplatingErrorsForAllLines records an error for each line containing template braces in versions that do not support templating
func addTemplatingErrorsForAllLines(result *LintResult, content string) {
lines := strings.Split(content, "\n")
for i, line := range lines {
if strings.Contains(line, "{{") && strings.Contains(line, "}}") {
result.Errors = append(result.Errors, LintError{
Line: i + 1,
Message: "Templating is not supported in v1beta2 specs",
Field: "template",
})
}
}
}
func checkTemplateSyntax(content string) ([]LintError, []string) {
errors := []LintError{}
lines := strings.Split(content, "\n")
templateValueRefs := map[string]bool{}
// Check for unmatched braces
for i, line := range lines {
// Count opening and closing braces
opening := strings.Count(line, "{{")
closing := strings.Count(line, "}}")
if opening != closing {
errors = append(errors, LintError{
Line: i + 1,
Message: fmt.Sprintf("Unmatched template braces: %d opening, %d closing", opening, closing),
})
}
// Check for common template syntax issues
// Look for templates that might be missing the leading dot
if strings.Contains(line, "{{") && strings.Contains(line, "}}") {
// Extract template expressions
templateExpr := extractTemplateBetweenBraces(line)
for _, expr := range templateExpr {
trimmed := strings.TrimSpace(expr)
// Skip empty expressions
if trimmed == "" {
continue
}
// Skip comments: {{/* ... */}}
if strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*/") {
continue
}
// Track template value references for warning (check this before skipping control structures)
if strings.Contains(trimmed, ".Values.") {
// Extract the value path
valuePattern := regexp.MustCompile(`\.Values\.(\w+(?:\.\w+)*)`)
matches := valuePattern.FindAllStringSubmatch(trimmed, -1)
for _, match := range matches {
if len(match) > 1 {
templateValueRefs[match[1]] = true
}
}
}
// Skip control structures (if, else, end, range, with, etc.)
if isControlStructure(trimmed) {
continue
}
// Skip template variables (start with $)
if strings.HasPrefix(trimmed, "$") {
continue
}
// Skip expressions that start with a dot (valid references)
if strings.HasPrefix(trimmed, ".") {
continue
}
// Skip string literals
if strings.HasPrefix(trimmed, "\"") || strings.HasPrefix(trimmed, "'") {
continue
}
// Skip numeric literals
if regexp.MustCompile(`^[0-9]+$`).MatchString(trimmed) {
continue
}
// Skip function calls (contain parentheses or pipes)
if strings.Contains(trimmed, "(") || strings.Contains(trimmed, "|") {
continue
}
// Skip known Helm functions/keywords
helmFunctions := []string{"toYaml", "toJson", "include", "required", "default", "quote", "nindent", "indent", "upper", "lower", "trim"}
isFunction := false
for _, fn := range helmFunctions {
if strings.HasPrefix(trimmed, fn+" ") || trimmed == fn {
isFunction = true
break
}
}
if isFunction {
continue
}
// If we got here, it might be missing a leading dot
errors = append(errors, LintError{
Line: i + 1,
Message: fmt.Sprintf("Template expression may be missing leading dot: {{ %s }}", expr),
})
}
}
}
// Collect template values that need to be provided at runtime
var valueList []string
for val := range templateValueRefs {
valueList = append(valueList, val)
}
// Sort for consistent output
sort.Strings(valueList)
return errors, valueList
}
// extractTemplateBetweenBraces extracts template expressions from a line
func extractTemplateBetweenBraces(line string) []string {
var expressions []string
// Match {{ ... }} with optional whitespace trimming (-), including comments {{/* */}}
re := regexp.MustCompile(`\{\{-?\s*(.+?)\s*-?\}\}`)
matches := re.FindAllStringSubmatch(line, -1)
for _, match := range matches {
if len(match) > 1 {
// Clean up the expression
expr := match[1]
// Remove */ at the end if it's part of a comment
expr = strings.TrimSuffix(strings.TrimSpace(expr), "*/")
expressions = append(expressions, expr)
}
}
return expressions
}
// isControlStructure checks if a template expression is a control structure
func isControlStructure(expr string) bool {
trimmed := strings.TrimSpace(expr)
controlKeywords := []string{"if", "else", "end", "range", "with", "define", "template", "block", "include"}
for _, keyword := range controlKeywords {
if strings.HasPrefix(trimmed, keyword+" ") || trimmed == keyword {
return true
}
}
return false
}
+41
View File
@@ -0,0 +1,41 @@
package lint
// Core types used by the lint package
type LintResult struct {
FilePath string
Errors []LintError
Warnings []LintWarning
}
type LintError struct {
Line int
Column int
Message string
Field string
}
type LintWarning struct {
Line int
Column int
Message string
Field string
}
type LintOptions struct {
FilePaths []string
Fix bool
Format string // "text" or "json"
ValuesFiles []string // Path to YAML files with template values (for v1beta3)
SetValues []string // Template values from command line (for v1beta3)
}
// HasErrors returns true if any of the results contain errors
func HasErrors(results []LintResult) bool {
for _, result := range results {
if len(result.Errors) > 0 {
return true
}
}
return false
}
+452
View File
@@ -0,0 +1,452 @@
package lint
import (
"fmt"
"os"
"path/filepath"
"strings"
"encoding/json"
)
func checkRequiredFields(parsed map[string]interface{}, content string) []LintError {
errors := []LintError{}
// Check apiVersion
if apiVersion, ok := parsed["apiVersion"].(string); !ok || apiVersion == "" {
errors = append(errors, LintError{
Line: findLineNumber(content, "apiVersion"),
Field: "apiVersion",
Message: "Missing 'apiVersion'",
})
}
// Check kind
if kind, ok := parsed["kind"].(string); !ok || kind == "" {
errors = append(errors, LintError{
Line: findLineNumber(content, "kind"),
Field: "kind",
Message: "Missing or empty 'kind' field",
})
} else if kind != "Preflight" && kind != "SupportBundle" {
errors = append(errors, LintError{
Line: findLineNumber(content, "kind"),
Field: "kind",
Message: fmt.Sprintf("Expected kind 'Preflight' or 'SupportBundle' (found '%s')", kind),
})
}
// Check metadata
if _, ok := parsed["metadata"]; !ok {
errors = append(errors, LintError{
Line: findLineNumber(content, "metadata"),
Field: "metadata",
Message: "Missing 'metadata' section",
})
} else if metadata, ok := parsed["metadata"].(map[string]interface{}); ok {
if name, ok := metadata["name"].(string); !ok || name == "" {
errors = append(errors, LintError{
Line: findLineNumber(content, "name"),
Field: "metadata.name",
Message: "Missing 'metadata.name'",
})
}
}
// Check spec
if _, ok := parsed["spec"]; !ok {
errors = append(errors, LintError{
Line: findLineNumber(content, "spec"),
Field: "spec",
Message: "Missing 'spec' section",
})
}
return errors
}
func checkPreflightSpec(parsed map[string]interface{}, content string) []LintError {
errors := []LintError{}
spec, ok := parsed["spec"].(map[string]interface{})
if !ok {
return errors
}
// Check for analyzers
analyzers, hasAnalyzers := spec["analyzers"]
if !hasAnalyzers {
errors = append(errors, LintError{
Line: findLineNumber(content, "spec:"),
Field: "spec.analyzers",
Message: "Preflight spec must contain 'analyzers'",
})
} else if analyzersList, ok := analyzers.([]interface{}); ok {
if len(analyzersList) == 0 {
errors = append(errors, LintError{
Line: findLineNumber(content, "analyzers"),
Field: "spec.analyzers",
Message: "Preflight spec must have at least one analyzer",
})
}
}
return errors
}
func checkSupportBundleSpec(parsed map[string]interface{}, content string) []LintError {
errors := []LintError{}
spec, ok := parsed["spec"].(map[string]interface{})
if !ok {
return errors
}
// Check for collectors
collectors, hasCollectors := spec["collectors"]
_, hasHostCollectors := spec["hostCollectors"]
if !hasCollectors && !hasHostCollectors {
errors = append(errors, LintError{
Line: findLineNumber(content, "spec:"),
Field: "spec.collectors",
Message: "SupportBundle spec must contain 'collectors' or 'hostCollectors'",
})
} else {
// Check if collectors list is empty
if hasCollectors {
if collectorsList, ok := collectors.([]interface{}); ok && len(collectorsList) == 0 {
errors = append(errors, LintError{
Line: findLineNumber(content, "collectors"),
Field: "spec.collectors",
Message: "Collectors list is empty",
})
}
}
}
return errors
}
// checkCommonIssues aggregates advisory warnings based on best practices
func checkCommonIssues(parsed map[string]interface{}, content string, apiVersion string, templateValueRefs []string) []LintWarning {
warnings := []LintWarning{}
// Check for missing docStrings in analyzers and collectors
spec, ok := parsed["spec"].(map[string]interface{})
if !ok {
return warnings
}
// Check if any analyzers are missing docString
analyzersMissingDocString := false
if analyzers, ok := spec["analyzers"].([]interface{}); ok {
for _, analyzer := range analyzers {
if analyzerMap, ok := analyzer.(map[string]interface{}); ok {
// Check if docString exists at the analyzer level (v1beta3)
if _, hasDocString := analyzerMap["docString"]; !hasDocString {
analyzersMissingDocString = true
break
}
}
}
}
// Check if any collectors are missing docString
collectorsMissingDocString := false
if collectors, ok := spec["collectors"].([]interface{}); ok {
for _, collector := range collectors {
if collectorMap, ok := collector.(map[string]interface{}); ok {
// Get the actual collector type (first key-value pair)
for _, collectorSpec := range collectorMap {
if specMap, ok := collectorSpec.(map[string]interface{}); ok {
if _, hasDocString := specMap["docString"]; !hasDocString {
collectorsMissingDocString = true
break
}
}
// Only check the first key since collectors have single type
break
}
}
if collectorsMissingDocString {
break
}
}
}
// Add consolidated warnings if any items are missing docString
if analyzersMissingDocString && collectorsMissingDocString {
warnings = append(warnings, LintWarning{
Line: findLineNumber(content, "spec:"),
Field: "spec",
Message: "Some analyzers and collectors are missing docString (recommended for v1beta3)",
})
} else if analyzersMissingDocString {
warnings = append(warnings, LintWarning{
Line: findLineNumber(content, "analyzers:"),
Field: "spec.analyzers",
Message: "Some analyzers are missing docString (recommended for v1beta3)",
})
} else if collectorsMissingDocString {
warnings = append(warnings, LintWarning{
Line: findLineNumber(content, "collectors:"),
Field: "spec.collectors",
Message: "Some collectors are missing docString (recommended for v1beta3)",
})
}
// Add warning about template values that need to be provided at runtime (v1beta3 only)
if apiVersion == "troubleshoot.sh/v1beta3" && len(templateValueRefs) > 0 {
warnings = append(warnings, LintWarning{
Line: 1,
Field: "template-values",
Message: fmt.Sprintf("Template values that must be provided at runtime: %s", strings.Join(templateValueRefs, ", ")),
})
}
return warnings
}
// --- Schema-backed quick validation (best-effort) ---
type schemaTypeInfo struct {
required map[string]struct{}
properties map[string]struct{}
}
var (
knownAnalyzerTypes map[string]struct{}
knownCollectorTypes map[string]struct{}
analyzerTypeInfo map[string]schemaTypeInfo
collectorTypeInfo map[string]schemaTypeInfo
knownTypesLoaded bool
)
func ensureKnownTypesLoaded() {
if knownTypesLoaded {
return
}
knownAnalyzerTypes = map[string]struct{}{}
knownCollectorTypes = map[string]struct{}{}
analyzerTypeInfo = map[string]schemaTypeInfo{}
collectorTypeInfo = map[string]schemaTypeInfo{}
// Analyzer schema (v1beta2)
loadKeysFromSchema(
filepath.Join("schemas", "analyzer-troubleshoot-v1beta2.json"),
[]string{"properties", "spec", "properties", "analyzers", "items", "properties"},
knownAnalyzerTypes,
)
loadTypeInfoFromSchema(
filepath.Join("schemas", "analyzer-troubleshoot-v1beta2.json"),
[]string{"properties", "spec", "properties", "analyzers", "items", "properties"},
analyzerTypeInfo,
)
// Collector schema (v1beta2)
loadKeysFromSchema(
filepath.Join("schemas", "collector-troubleshoot-v1beta2.json"),
[]string{"properties", "spec", "properties", "collectors", "items", "properties"},
knownCollectorTypes,
)
loadTypeInfoFromSchema(
filepath.Join("schemas", "collector-troubleshoot-v1beta2.json"),
[]string{"properties", "spec", "properties", "collectors", "items", "properties"},
collectorTypeInfo,
)
knownTypesLoaded = true
}
// loadKeysFromSchema walks a JSON object by keysPath and adds map keys at that node into dest
func loadKeysFromSchema(schemaPath string, keysPath []string, dest map[string]struct{}) {
data, err := os.ReadFile(schemaPath)
if err != nil {
return
}
var obj map[string]interface{}
if err := json.Unmarshal(data, &obj); err != nil {
return
}
node := interface{}(obj)
for _, key := range keysPath {
m, ok := node.(map[string]interface{})
if !ok {
return
}
node, ok = m[key]
if !ok {
return
}
}
props, ok := node.(map[string]interface{})
if !ok {
return
}
for k := range props {
dest[k] = struct{}{}
}
}
// loadTypeInfoFromSchema records required/properties for each type under the node
func loadTypeInfoFromSchema(schemaPath string, keysPath []string, dest map[string]schemaTypeInfo) {
data, err := os.ReadFile(schemaPath)
if err != nil {
return
}
var obj map[string]interface{}
if err := json.Unmarshal(data, &obj); err != nil {
return
}
node := interface{}(obj)
for _, key := range keysPath {
m, ok := node.(map[string]interface{})
if !ok {
return
}
node, ok = m[key]
if !ok {
return
}
}
typesNode, ok := node.(map[string]interface{})
if !ok {
return
}
for typeName, raw := range typesNode {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
info := schemaTypeInfo{required: map[string]struct{}{}, properties: map[string]struct{}{}}
if req, ok := m["required"].([]interface{}); ok {
for _, r := range req {
if s, ok := r.(string); ok {
info.required[s] = struct{}{}
}
}
}
if props, ok := m["properties"].(map[string]interface{}); ok {
for prop := range props {
info.properties[prop] = struct{}{}
}
}
dest[typeName] = info
}
}
// validateAnalyzers delegates to the generic typed-list validator
func validateAnalyzers(parsed map[string]interface{}, content string) []LintError {
return validateTypedList(parsed, content, "analyzers", "analyzer", knownAnalyzerTypes, analyzerTypeInfo)
}
func validateCollectors(parsed map[string]interface{}, content string, field string) []LintError {
return validateTypedList(parsed, content, field, "collector", knownCollectorTypes, collectorTypeInfo)
}
// validateTypedList provides generic validation for lists of typed single-key objects
func validateTypedList(
parsed map[string]interface{},
content string,
listKey string,
subject string,
knownTypes map[string]struct{},
typeInfo map[string]schemaTypeInfo,
) []LintError {
var errs []LintError
spec, ok := parsed["spec"].(map[string]interface{})
if !ok {
return errs
}
raw, exists := spec[listKey]
if !exists {
return errs
}
list, ok := raw.([]interface{})
if !ok {
errs = append(errs, LintError{
Line: findLineNumber(content, listKey+":"),
Field: "spec." + listKey,
Message: fmt.Sprintf("Expected '%s' to be a list", listKey),
})
return errs
}
for i, item := range list {
m, ok := item.(map[string]interface{})
if !ok {
errs = append(errs, LintError{
Line: findListItemLine(content, listKey, i),
Field: fmt.Sprintf("spec.%s[%d]", listKey, i),
Message: fmt.Sprintf("Expected %s entry to be a mapping", subject),
})
continue
}
// Count non-docString keys (docString is metadata in v1beta3, not a type)
typeCount := 0
var typ string
var body interface{}
for k, v := range m {
if k == "docString" {
// docString is metadata, not a type - skip it
continue
} else {
typeCount++
typ, body = k, v
}
}
// Check that we have exactly one type (excluding docString)
if typeCount != 1 {
errs = append(errs, LintError{
Line: findListItemLine(content, listKey, i),
Field: fmt.Sprintf("spec.%s[%d]", listKey, i),
Message: fmt.Sprintf("%s entry must specify exactly one %s type", strings.Title(subject), subject),
})
continue
}
// If no actual type was found (only docString), skip further validation
if typ == "" {
continue
}
if len(knownTypes) > 0 {
if _, ok := knownTypes[typ]; !ok {
errs = append(errs, LintError{
Line: findListItemLine(content, listKey, i),
Field: fmt.Sprintf("spec.%s[%d]", listKey, i),
Message: fmt.Sprintf("Unknown %s type '%s'", subject, typ),
})
}
}
bodyMap, ok := body.(map[string]interface{})
if !ok {
errs = append(errs, LintError{
Line: findListItemLine(content, listKey, i),
Field: fmt.Sprintf("spec.%s[%d].%s", listKey, i, typ),
Message: fmt.Sprintf("Expected %s definition to be a mapping", subject),
})
continue
}
if ti, ok := typeInfo[typ]; ok {
for req := range ti.required {
if _, ok := bodyMap[req]; !ok {
errs = append(errs, LintError{
Line: findListItemLine(content, listKey, i),
Field: fmt.Sprintf("spec.%s[%d].%s.%s", listKey, i, typ, req),
Message: fmt.Sprintf("Missing required field '%s' for %s '%s'", req, subject, typ),
})
}
}
for k := range bodyMap {
if _, ok := ti.properties[k]; !ok {
errs = append(errs, LintError{
Line: findListItemLine(content, listKey, i),
Field: fmt.Sprintf("spec.%s[%d].%s.%s", listKey, i, typ, k),
Message: fmt.Sprintf("Unknown field '%s' for %s '%s'", k, subject, typ),
})
}
}
}
}
return errs
}
+157 -2
View File
@@ -8,12 +8,14 @@ import (
"github.com/pkg/errors"
"github.com/replicatedhq/troubleshoot/internal/util"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
troubleshootv1beta3 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta3"
"github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme"
"github.com/replicatedhq/troubleshoot/pkg/constants"
"github.com/replicatedhq/troubleshoot/pkg/docrewrite"
"github.com/replicatedhq/troubleshoot/pkg/types"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes"
"k8s.io/klog/v2"
"sigs.k8s.io/yaml"
)
@@ -23,6 +25,8 @@ var decoder runtime.Decoder
func init() {
// Allow serializing Secrets and ConfigMaps
_ = v1.AddToScheme(scheme.Scheme)
// Ensure v1beta3 Troubleshoot types are registered for decoding
_ = troubleshootv1beta3.AddToScheme(scheme.Scheme)
decoder = scheme.Codecs.UniversalDeserializer()
}
@@ -40,6 +44,14 @@ type LoadOptions struct {
// If true, the loader will return an error if any of the specs are not valid
// else the invalid specs will be ignored
Strict bool
// Client is the kubernetes client used for resolving v1beta3 StringOrValueFrom fields
// If not provided, v1beta3 specs with secretKeyRef will fail to load
Client kubernetes.Interface
// Namespace is the default namespace for resolving v1beta3 secret references
// Defaults to "default" if not provided
Namespace string
}
// TODO: Additional requirements needed in this package
@@ -64,8 +76,18 @@ type LoadOptions struct {
// the documents are not valid, else the invalid documents will be ignored.
func LoadSpecs(ctx context.Context, opt LoadOptions) (*TroubleshootKinds, error) {
opt.RawSpecs = append(opt.RawSpecs, opt.RawSpec)
// Default namespace to "default" if not provided
namespace := opt.Namespace
if namespace == "" {
namespace = "default"
}
l := specLoader{
strict: opt.Strict,
strict: opt.Strict,
client: opt.Client,
namespace: namespace,
ctx: ctx,
}
return l.loadFromStrings(opt.RawSpecs...)
@@ -140,7 +162,10 @@ func NewTroubleshootKinds() *TroubleshootKinds {
}
type specLoader struct {
strict bool
strict bool
client kubernetes.Interface
namespace string
ctx context.Context
}
// loadFromStrings accepts a list of strings (exploded) which should be yaml documents
@@ -216,6 +241,23 @@ func (l *specLoader) loadFromSplitDocs(splitdocs []string) (*TroubleshootKinds,
kinds := NewTroubleshootKinds()
for _, doc := range splitdocs {
// Check if this is a v1beta3 spec
var parsed parsedDoc
if err := yaml.Unmarshal([]byte(doc), &parsed); err == nil && parsed.APIVersion == constants.Troubleshootv1beta3Kind {
// Only handle v1beta3 SupportBundle specially (to resolve valueFrom and convert)
if parsed.Kind == "SupportBundle" {
if err := l.loadV1Beta3Spec(doc, kinds); err != nil {
// Always surface v1beta3 SupportBundle errors so users get actionable guidance
return nil, err
}
// handled as support bundle; move to next doc
continue
}
// For other v1beta3 kinds (e.g., Preflight), fall through to the generic
// v1beta3->v1beta2 conversion path below to preserve prior behavior.
}
// Handle v1beta2 and v1beta1 specs
converted, err := docrewrite.ConvertToV1Beta2([]byte(doc))
if err != nil {
if !l.strict {
@@ -263,6 +305,119 @@ func (l *specLoader) loadFromSplitDocs(splitdocs []string) (*TroubleshootKinds,
return kinds, nil
}
// loadV1Beta3Spec handles loading and resolving v1beta3 specs
func (l *specLoader) loadV1Beta3Spec(doc string, kinds *TroubleshootKinds) error {
// Unmarshal to v1beta3 types
obj, _, err := decoder.Decode([]byte(doc), nil, nil)
if err != nil {
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES,
errors.Wrapf(err, "failed to decode v1beta3 spec: '%s'", doc),
)
}
switch v3spec := obj.(type) {
case *troubleshootv1beta3.SupportBundle:
// Resolve secrets and convert to v1beta2
requiresClient := v1beta3SpecRequiresClient(&v3spec.Spec)
if requiresClient && l.client == nil {
return types.NewExitCodeError(
constants.EXIT_CODE_SPEC_ISSUES,
errors.New("kubernetes client required"),
)
}
v2spec, err := troubleshootv1beta3.ConvertToV1Beta2WithResolution(l.ctx, &v3spec.Spec, l.client, l.namespace)
if err != nil {
// When secret/configmap references are present, show a clear guidance message
// instead of leaking underlying RBAC or lookup errors.
if requiresClient {
return types.NewExitCodeError(
constants.EXIT_CODE_SPEC_ISSUES,
errors.New("this v1beta3 SupportBundle uses secret/configmap references and must be run in a cluster"),
)
}
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES,
errors.Wrap(err, "failed to resolve and convert v1beta3 support bundle spec"),
)
}
// Create v1beta2 support bundle
v2bundle := troubleshootv1beta2.SupportBundle{
TypeMeta: v3spec.TypeMeta,
ObjectMeta: v3spec.ObjectMeta,
Spec: *v2spec,
}
// Update apiVersion to v1beta2
v2bundle.APIVersion = constants.Troubleshootv1beta2Kind
kinds.SupportBundlesV1Beta2 = append(kinds.SupportBundlesV1Beta2, v2bundle)
// TODO: Add other v1beta3 types as they are implemented
default:
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES,
errors.Errorf("unsupported v1beta3 kind: %T", v3spec),
)
}
return nil
}
// v1beta3SpecRequiresClient returns true if the v1beta3 spec contains any
// StringOrValueFrom references that require fetching from the cluster.
func v1beta3SpecRequiresClient(spec *troubleshootv1beta3.SupportBundleSpec) bool {
if spec == nil || spec.Collectors == nil {
return false
}
for _, c := range spec.Collectors {
if c == nil {
continue
}
// Database collectors
if c.Postgres != nil && databaseRequiresClient(c.Postgres) {
return true
}
if c.Mysql != nil && databaseRequiresClient(c.Mysql) {
return true
}
if c.Mssql != nil && databaseRequiresClient(c.Mssql) {
return true
}
if c.Redis != nil && databaseRequiresClient(c.Redis) {
return true
}
}
return false
}
func databaseRequiresClient(db *troubleshootv1beta3.Database) bool {
if db == nil {
return false
}
if stringOrValueFromHasRef(db.URI) {
return true
}
if db.TLS != nil {
if stringOrValueFromHasRef(db.TLS.CACert) ||
stringOrValueFromHasRef(db.TLS.ClientCert) ||
stringOrValueFromHasRef(db.TLS.ClientKey) {
return true
}
}
return false
}
func stringOrValueFromHasRef(s troubleshootv1beta3.StringOrValueFrom) bool {
if s.ValueFrom == nil {
return false
}
return s.ValueFrom.SecretKeyRef != nil || s.ValueFrom.ConfigMapKeyRef != nil
}
func isSecret(parsedDocHead parsedDoc) bool {
if parsedDocHead.Kind == "Secret" && parsedDocHead.APIVersion == "v1" {
return true
+289
View File
@@ -0,0 +1,289 @@
package loader
import (
"context"
"testing"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
func TestLoadSpecs_V1Beta3WithSecretRef(t *testing.T) {
// Create test secret
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "postgres-secret",
Namespace: "default",
},
Data: map[string][]byte{
"uri": []byte("postgresql://user:password@localhost:5432/mydb"),
},
}
client := fake.NewSimpleClientset(secret)
spec := `
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: test-bundle
spec:
collectors:
- postgres:
collectorName: main-db
uri:
valueFrom:
secretKeyRef:
name: postgres-secret
key: uri
`
kinds, err := LoadSpecs(context.Background(), LoadOptions{
RawSpec: spec,
Client: client,
Namespace: "default",
})
require.NoError(t, err)
require.NotNil(t, kinds)
require.Len(t, kinds.SupportBundlesV1Beta2, 1)
bundle := kinds.SupportBundlesV1Beta2[0]
assert.Equal(t, "test-bundle", bundle.Name)
assert.Equal(t, "troubleshoot.sh/v1beta2", bundle.APIVersion)
require.Len(t, bundle.Spec.Collectors, 1)
require.NotNil(t, bundle.Spec.Collectors[0].Postgres)
assert.Equal(t, "main-db", bundle.Spec.Collectors[0].Postgres.CollectorName)
assert.Equal(t, "postgresql://user:password@localhost:5432/mydb", bundle.Spec.Collectors[0].Postgres.URI)
}
func TestLoadSpecs_V1Beta3WithTLSSecrets(t *testing.T) {
// Create test secret with TLS certs
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "tls-secret",
Namespace: "default",
},
Data: map[string][]byte{
"ca.crt": []byte("CA_CERT_DATA"),
"client.crt": []byte("CLIENT_CERT_DATA"),
"client.key": []byte("CLIENT_KEY_DATA"),
},
}
client := fake.NewSimpleClientset(secret)
spec := `
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: test-bundle
spec:
collectors:
- postgres:
uri:
value: "postgresql://localhost:5432/db"
tls:
cacert:
valueFrom:
secretKeyRef:
name: tls-secret
key: ca.crt
clientCert:
valueFrom:
secretKeyRef:
name: tls-secret
key: client.crt
clientKey:
valueFrom:
secretKeyRef:
name: tls-secret
key: client.key
`
kinds, err := LoadSpecs(context.Background(), LoadOptions{
RawSpec: spec,
Client: client,
Namespace: "default",
})
require.NoError(t, err)
require.NotNil(t, kinds)
require.Len(t, kinds.SupportBundlesV1Beta2, 1)
bundle := kinds.SupportBundlesV1Beta2[0]
require.Len(t, bundle.Spec.Collectors, 1)
require.NotNil(t, bundle.Spec.Collectors[0].Postgres)
require.NotNil(t, bundle.Spec.Collectors[0].Postgres.TLS)
assert.Equal(t, "CA_CERT_DATA", bundle.Spec.Collectors[0].Postgres.TLS.CACert)
assert.Equal(t, "CLIENT_CERT_DATA", bundle.Spec.Collectors[0].Postgres.TLS.ClientCert)
assert.Equal(t, "CLIENT_KEY_DATA", bundle.Spec.Collectors[0].Postgres.TLS.ClientKey)
}
func TestLoadSpecs_V1Beta3MultipleCollectors(t *testing.T) {
pgSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "postgres-secret",
Namespace: "default",
},
Data: map[string][]byte{
"uri": []byte("postgresql://localhost:5432/db"),
},
}
mysqlSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "mysql-secret",
Namespace: "default",
},
Data: map[string][]byte{
"uri": []byte("mysql://localhost:3306/db"),
},
}
client := fake.NewSimpleClientset(pgSecret, mysqlSecret)
spec := `
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: test-bundle
spec:
collectors:
- postgres:
uri:
valueFrom:
secretKeyRef:
name: postgres-secret
key: uri
- mysql:
uri:
valueFrom:
secretKeyRef:
name: mysql-secret
key: uri
`
kinds, err := LoadSpecs(context.Background(), LoadOptions{
RawSpec: spec,
Client: client,
Namespace: "default",
})
require.NoError(t, err)
require.NotNil(t, kinds)
require.Len(t, kinds.SupportBundlesV1Beta2, 1)
bundle := kinds.SupportBundlesV1Beta2[0]
require.Len(t, bundle.Spec.Collectors, 2)
require.NotNil(t, bundle.Spec.Collectors[0].Postgres)
assert.Equal(t, "postgresql://localhost:5432/db", bundle.Spec.Collectors[0].Postgres.URI)
require.NotNil(t, bundle.Spec.Collectors[1].Mysql)
assert.Equal(t, "mysql://localhost:3306/db", bundle.Spec.Collectors[1].Mysql.URI)
}
func TestLoadSpecs_V1Beta3WithoutClient(t *testing.T) {
spec := `
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: test-bundle
spec:
collectors:
- postgres:
uri:
valueFrom:
secretKeyRef:
name: postgres-secret
key: uri
`
_, err := LoadSpecs(context.Background(), LoadOptions{
RawSpec: spec,
Strict: true, // Enable strict mode to get error instead of warning
// No client provided
})
require.Error(t, err)
assert.Contains(t, err.Error(), "kubernetes client required")
}
func TestLoadSpecs_V1Beta3MixedWithV1Beta2(t *testing.T) {
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "postgres-secret",
Namespace: "default",
},
Data: map[string][]byte{
"uri": []byte("postgresql://localhost:5432/db"),
},
}
client := fake.NewSimpleClientset(secret)
specs := `
---
apiVersion: troubleshoot.sh/v1beta3
kind: SupportBundle
metadata:
name: v1beta3-bundle
spec:
collectors:
- postgres:
uri:
valueFrom:
secretKeyRef:
name: postgres-secret
key: uri
---
apiVersion: troubleshoot.sh/v1beta2
kind: SupportBundle
metadata:
name: v1beta2-bundle
spec:
collectors:
- clusterInfo: {}
`
kinds, err := LoadSpecs(context.Background(), LoadOptions{
RawSpec: specs,
Client: client,
Namespace: "default",
})
require.NoError(t, err)
require.NotNil(t, kinds)
require.Len(t, kinds.SupportBundlesV1Beta2, 2)
// Find the v1beta3-converted bundle
var v3Bundle *troubleshootv1beta2.SupportBundle
var v2Bundle *troubleshootv1beta2.SupportBundle
for i := range kinds.SupportBundlesV1Beta2 {
if kinds.SupportBundlesV1Beta2[i].Name == "v1beta3-bundle" {
v3Bundle = &kinds.SupportBundlesV1Beta2[i]
}
if kinds.SupportBundlesV1Beta2[i].Name == "v1beta2-bundle" {
v2Bundle = &kinds.SupportBundlesV1Beta2[i]
}
}
require.NotNil(t, v3Bundle, "v1beta3 bundle should be converted and loaded")
require.NotNil(t, v2Bundle, "v1beta2 bundle should be loaded")
// Verify v1beta3 bundle was resolved correctly
require.Len(t, v3Bundle.Spec.Collectors, 1)
require.NotNil(t, v3Bundle.Spec.Collectors[0].Postgres)
assert.Equal(t, "postgresql://localhost:5432/db", v3Bundle.Spec.Collectors[0].Postgres.URI)
// Verify v1beta2 bundle was loaded correctly
require.Len(t, v2Bundle.Spec.Collectors, 1)
require.NotNil(t, v2Bundle.Spec.Collectors[0].ClusterInfo)
}
+3 -2
View File
@@ -5,6 +5,7 @@ package namespaces
import (
"fmt"
"net"
"strconv"
"time"
)
@@ -51,7 +52,7 @@ func (n *NamespacePinger) PingUDP(dst net.IP) error {
func (n *NamespacePinger) PingTCP(dst net.IP) error {
n.cfg.Logf("reaching to %q from %q with tcp", dst, n.InternalIP)
pinger := func() error {
addr := fmt.Sprintf("%s:%d", dst, n.cfg.Port)
addr := net.JoinHostPort(dst.String(), strconv.Itoa(n.cfg.Port))
conn, err := net.DialTimeout("tcp", addr, n.cfg.Timeout)
if err != nil {
return fmt.Errorf("error dialing tcp: %w", err)
@@ -90,7 +91,7 @@ func (n *NamespacePinger) StartTCPEchoServer(errors chan error) {
// received, the server ends. Callers must wait until the ready channel is
// closed before they can start sending packets.
func (n *NamespacePinger) startTCPEchoServer(ready chan struct{}) (err error) {
addr := fmt.Sprintf("%s:%d", n.InternalIP, n.cfg.Port)
addr := net.JoinHostPort(n.InternalIP.String(), strconv.Itoa(n.cfg.Port))
n.cfg.Logf("starting tcp echo server on namespace %q(%q)", n.name, addr)
if err = n.Join(); err != nil {
+92 -55
View File
@@ -13,11 +13,12 @@ import (
"github.com/replicatedhq/troubleshoot/internal/util"
"github.com/replicatedhq/troubleshoot/pkg/version"
"k8s.io/klog/v2"
"oras.land/oras-go/pkg/auth"
dockerauth "oras.land/oras-go/pkg/auth/docker"
"oras.land/oras-go/pkg/content"
"oras.land/oras-go/pkg/oras"
"oras.land/oras-go/pkg/registry"
"oras.land/oras-go/v2"
"oras.land/oras-go/v2/content/memory"
"oras.land/oras-go/v2/registry/remote"
"oras.land/oras-go/v2/registry/remote/auth"
"oras.land/oras-go/v2/registry/remote/credentials"
"oras.land/oras-go/v2/registry/remote/retry"
)
const (
@@ -75,66 +76,97 @@ func PullSpecsFromOCI(ctx context.Context, uri string) ([]string, error) {
}
func pullFromOCI(ctx context.Context, uri string, mediaType string, imageName string) ([]byte, error) {
// helm credentials
helmCredentialsFile := filepath.Join(util.HomeDir(), HelmCredentialsFileBasename)
dockerauthClient, err := dockerauth.NewClientWithDockerFallback(helmCredentialsFile)
if err != nil {
return nil, errors.Wrap(err, "failed to create auth client")
}
authClient := dockerauthClient
headers := http.Header{}
headers.Set("User-Agent", version.GetUserAgent())
opts := []auth.ResolverOption{auth.WithResolverHeaders(headers)}
resolver, err := authClient.ResolverWithOpts(opts...)
if err != nil {
return nil, errors.Wrap(err, "failed to create resolver")
}
memoryStore := content.NewMemory()
allowedMediaTypes := []string{
mediaType,
}
var descriptors, layers []ocispec.Descriptor
registryStore := content.Registry{Resolver: resolver}
parsedRef, err := parseURI(uri, imageName)
// Parse the URI to get the repository reference
ref, err := parseURI(uri, imageName)
if err != nil {
return nil, err
}
klog.V(1).Infof("Pulling spec from %q OCI uri", parsedRef)
klog.V(1).Infof("Pulling spec from %q OCI uri", ref)
manifest, err := oras.Copy(ctx, registryStore, parsedRef, memoryStore, "",
oras.WithPullEmptyNameAllowed(),
oras.WithAllowedMediaTypes(allowedMediaTypes),
oras.WithLayerDescriptors(func(l []ocispec.Descriptor) {
layers = l
}))
// Create a repository instance
repo, err := remote.NewRepository(ref)
if err != nil {
if strings.Contains(err.Error(), "not found") {
return nil, errors.Wrap(err, "failed to create repository")
}
// Set up authentication with Docker credentials fallback
helmCredentialsFile := filepath.Join(util.HomeDir(), HelmCredentialsFileBasename)
storeOpts := credentials.StoreOptions{}
// Try to load credentials from Helm config first, fall back to Docker config
var credStore credentials.Store
helmStore, helmErr := credentials.NewStore(helmCredentialsFile, storeOpts)
if helmErr == nil {
credStore = helmStore
} else {
// Fall back to Docker credentials if helm credentials are not available
dockerStore, dockerErr := credentials.NewStoreFromDocker(storeOpts)
if dockerErr != nil {
return nil, errors.Wrap(dockerErr, "failed to create credential store")
}
credStore = dockerStore
}
// Configure the repository client with authentication and custom headers
repo.Client = &auth.Client{
Client: retry.DefaultClient,
Cache: auth.NewCache(),
Credential: credentials.Credential(credStore),
Header: http.Header{
"User-Agent": []string{version.GetUserAgent()},
},
}
// Create in-memory storage for the pulled content
memoryStore := memory.New()
// Track layers for filtering
var layers []ocispec.Descriptor
// Set up copy options to capture layer descriptors
copyOpts := oras.CopyOptions{}
copyOpts.CopyGraphOptions.PreCopy = func(ctx context.Context, desc ocispec.Descriptor) error {
// Filter by media type - only copy layers with the specified media type
if desc.MediaType == mediaType {
layers = append(layers, desc)
return nil
}
// Allow manifest and other necessary descriptors
if strings.Contains(desc.MediaType, "manifest") || strings.Contains(desc.MediaType, "config") {
return nil
}
// Skip other media types
return oras.SkipNode
}
// Copy from the repository to memory
tag := repo.Reference.Reference
if tag == "" {
tag = "latest"
}
manifest, err := oras.Copy(ctx, repo, tag, memoryStore, tag, copyOpts)
if err != nil {
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "manifest unknown") {
return nil, ErrNoRelease
}
return nil, errors.Wrap(err, "failed to copy")
}
descriptors = append(descriptors, manifest)
descriptors := []ocispec.Descriptor{manifest}
descriptors = append(descriptors, layers...)
// expect 2 descriptors
// expect 2 descriptors (manifest + one layer)
if len(descriptors) != 2 {
return nil, fmt.Errorf("expected 2 descriptor, got %d", len(descriptors))
return nil, fmt.Errorf("expected 2 descriptors, got %d", len(descriptors))
}
var matchingDescriptor *ocispec.Descriptor
for _, descriptor := range descriptors {
d := descriptor
switch d.MediaType {
case mediaType:
if d.MediaType == mediaType {
matchingDescriptor = &d
}
}
@@ -143,9 +175,17 @@ func pullFromOCI(ctx context.Context, uri string, mediaType string, imageName st
return nil, fmt.Errorf("no descriptor found with media type: %s", mediaType)
}
_, matchingSpec, ok := memoryStore.Get(*matchingDescriptor)
if !ok {
return nil, fmt.Errorf("failed to get matching descriptor")
// Fetch the content from memory store
reader, err := memoryStore.Fetch(ctx, *matchingDescriptor)
if err != nil {
return nil, errors.Wrap(err, "failed to fetch matching descriptor")
}
defer reader.Close()
// Read all content
matchingSpec := make([]byte, matchingDescriptor.Size)
if _, err := reader.Read(matchingSpec); err != nil {
return nil, errors.Wrap(err, "failed to read content")
}
return matchingSpec, nil
@@ -172,12 +212,9 @@ func parseURI(in, imageName string) (string, error) {
tag = uriParts[1]
}
uri := fmt.Sprintf("%s%s/%s:%s", u.Host, uriParts[0], imageName, tag) // <host>:<port>/path/<imageName>:tag
// Format as: <host>:<port>/path/<imageName>:tag
// The remote.NewRepository() function in v2 can handle this format directly
uri := fmt.Sprintf("%s%s/%s:%s", u.Host, uriParts[0], imageName, tag)
parsedRef, err := registry.ParseReference(uri)
if err != nil {
return "", errors.Wrap(err, "failed to parse OCI uri reference")
}
return parsedRef.String(), nil
return uri, nil
}
+7 -1
View File
@@ -183,6 +183,7 @@ func CollectWithContext(ctx context.Context, opts CollectOpts, p *troubleshootv1
}
allCollectorsMap := make(map[reflect.Type][]collect.Collector)
collectorTypeOrder := make([]reflect.Type, 0) // Preserve order of collector types
allCollectedData := make(map[string][]byte)
for _, desiredCollector := range collectSpecs {
@@ -193,6 +194,9 @@ func CollectWithContext(ctx context.Context, opts CollectOpts, p *troubleshootv1
return nil, errors.Wrap(err, "failed to check RBAC for collectors")
}
collectorType := reflect.TypeOf(collector)
if _, exists := allCollectorsMap[collectorType]; !exists {
collectorTypeOrder = append(collectorTypeOrder, collectorType)
}
allCollectorsMap[collectorType] = append(allCollectorsMap[collectorType], collector)
}
}
@@ -200,7 +204,9 @@ func CollectWithContext(ctx context.Context, opts CollectOpts, p *troubleshootv1
collectorList := map[string]CollectorStatus{}
for _, collectors := range allCollectorsMap {
// Iterate over collector types in the order they appeared in collectSpecs
for _, collectorType := range collectorTypeOrder {
collectors := allCollectorsMap[collectorType]
if mergeCollector, ok := collectors[0].(collect.MergeableCollector); ok {
mergedCollectors, err := mergeCollector.Merge(collectors)
if err != nil {
+211
View File
@@ -0,0 +1,211 @@
package preflight
import (
"reflect"
"testing"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/collect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/client-go/kubernetes/fake"
"k8s.io/client-go/rest"
)
// TestCollectWithContext_ClusterResourcesFirst verifies that clusterResources
// collector runs first, even when it's not first in the spec.
func TestCollectWithContext_ClusterResourcesFirst(t *testing.T) {
// Create a preflight spec with collectors in a specific order
// where clusterResources is NOT first
preflight := &troubleshootv1beta2.Preflight{
Spec: troubleshootv1beta2.PreflightSpec{
Collectors: []*troubleshootv1beta2.Collect{
{
Data: &troubleshootv1beta2.Data{
CollectorMeta: troubleshootv1beta2.CollectorMeta{
CollectorName: "test-data",
},
Name: "test.json",
Data: `{"test": "data"}`,
},
},
{
ClusterResources: &troubleshootv1beta2.ClusterResources{},
},
},
},
}
// Use a fake Kubernetes client to avoid network calls
fakeClient := fake.NewSimpleClientset()
restConfig := &rest.Config{
Host: "https://fake-host",
}
opts := CollectOpts{
Namespace: "default",
KubernetesRestConfig: restConfig,
ProgressChan: make(chan interface{}, 100),
BundlePath: t.TempDir(),
IgnorePermissionErrors: true, // Ignore RBAC errors in tests
}
// Manually test the ordering logic by simulating what CollectWithContext does
collectSpecs := make([]*troubleshootv1beta2.Collect, 0)
if preflight.Spec.Collectors != nil {
collectSpecs = append(collectSpecs, preflight.Spec.Collectors...)
}
collectSpecs = collect.EnsureCollectorInList(
collectSpecs, troubleshootv1beta2.Collect{ClusterInfo: &troubleshootv1beta2.ClusterInfo{}},
)
collectSpecs = collect.EnsureCollectorInList(
collectSpecs, troubleshootv1beta2.Collect{ClusterResources: &troubleshootv1beta2.ClusterResources{}},
)
collectSpecs = collect.DedupCollectors(collectSpecs)
collectSpecs = collect.EnsureClusterResourcesFirst(collectSpecs)
// Verify clusterResources is first in the specs
require.NotEmpty(t, collectSpecs, "should have collectors")
require.NotNil(t, collectSpecs[0].ClusterResources, "first collector should be clusterResources")
// Now simulate the map grouping and order preservation
allCollectorsMap := make(map[reflect.Type][]collect.Collector)
collectorTypeOrder := make([]reflect.Type, 0)
for _, desiredCollector := range collectSpecs {
if collectorInterface, ok := collect.GetCollector(desiredCollector, opts.BundlePath, opts.Namespace, opts.KubernetesRestConfig, fakeClient, nil); ok {
if collector, ok := collectorInterface.(collect.Collector); ok {
// Skip RBAC check for this unit test
collectorType := reflect.TypeOf(collector)
if _, exists := allCollectorsMap[collectorType]; !exists {
collectorTypeOrder = append(collectorTypeOrder, collectorType)
}
allCollectorsMap[collectorType] = append(allCollectorsMap[collectorType], collector)
}
}
}
// Verify that clusterResources type is first in the order
require.NotEmpty(t, collectorTypeOrder, "should have collector types")
// Find the clusterResources type by checking the actual collectors
var clusterResourcesType reflect.Type
for collectorType, collectors := range allCollectorsMap {
if len(collectors) > 0 {
if _, ok := collectors[0].(*collect.CollectClusterResources); ok {
clusterResourcesType = collectorType
break
}
}
}
require.NotNil(t, clusterResourcesType, "should find clusterResources type")
assert.Equal(t, clusterResourcesType, collectorTypeOrder[0], "clusterResources type should be first in collectorTypeOrder")
}
// TestCollectWithContext_PreservesOrderAfterClusterResources verifies that
// after clusterResources, other collectors maintain their relative order.
func TestCollectWithContext_PreservesOrderAfterClusterResources(t *testing.T) {
// Create a preflight spec with multiple collectors in a specific order
preflight := &troubleshootv1beta2.Preflight{
Spec: troubleshootv1beta2.PreflightSpec{
Collectors: []*troubleshootv1beta2.Collect{
{
Data: &troubleshootv1beta2.Data{
CollectorMeta: troubleshootv1beta2.CollectorMeta{
CollectorName: "data-first",
},
Name: "first.json",
Data: `{"first": "data"}`,
},
},
{
Secret: &troubleshootv1beta2.Secret{
CollectorMeta: troubleshootv1beta2.CollectorMeta{
CollectorName: "secret-second",
},
},
},
},
},
}
// Use a fake Kubernetes client
fakeClient := fake.NewSimpleClientset()
restConfig := &rest.Config{
Host: "https://fake-host",
}
opts := CollectOpts{
Namespace: "default",
KubernetesRestConfig: restConfig,
ProgressChan: make(chan interface{}, 100),
BundlePath: t.TempDir(),
IgnorePermissionErrors: true,
}
// Simulate the ordering logic
collectSpecs := make([]*troubleshootv1beta2.Collect, 0)
if preflight.Spec.Collectors != nil {
collectSpecs = append(collectSpecs, preflight.Spec.Collectors...)
}
collectSpecs = collect.EnsureCollectorInList(
collectSpecs, troubleshootv1beta2.Collect{ClusterInfo: &troubleshootv1beta2.ClusterInfo{}},
)
collectSpecs = collect.EnsureCollectorInList(
collectSpecs, troubleshootv1beta2.Collect{ClusterResources: &troubleshootv1beta2.ClusterResources{}},
)
collectSpecs = collect.DedupCollectors(collectSpecs)
collectSpecs = collect.EnsureClusterResourcesFirst(collectSpecs)
// Group collectors by type and track order
allCollectorsMap := make(map[reflect.Type][]collect.Collector)
collectorTypeOrder := make([]reflect.Type, 0)
for _, desiredCollector := range collectSpecs {
if collectorInterface, ok := collect.GetCollector(desiredCollector, opts.BundlePath, opts.Namespace, opts.KubernetesRestConfig, fakeClient, nil); ok {
if collector, ok := collectorInterface.(collect.Collector); ok {
collectorType := reflect.TypeOf(collector)
if _, exists := allCollectorsMap[collectorType]; !exists {
collectorTypeOrder = append(collectorTypeOrder, collectorType)
}
allCollectorsMap[collectorType] = append(allCollectorsMap[collectorType], collector)
}
}
}
// Verify clusterResources is first
require.NotEmpty(t, collectorTypeOrder, "should have collector types")
// Find the actual types from the collectors
var clusterResourcesType, dataType, secretType reflect.Type
for collectorType, collectors := range allCollectorsMap {
if len(collectors) > 0 {
switch collectors[0].(type) {
case *collect.CollectClusterResources:
clusterResourcesType = collectorType
case *collect.CollectData:
dataType = collectorType
case *collect.CollectSecret:
secretType = collectorType
}
}
}
require.NotNil(t, clusterResourcesType, "should find clusterResources type")
assert.Equal(t, clusterResourcesType, collectorTypeOrder[0], "clusterResources should be first")
dataIndex := -1
secretIndex := -1
for i, ct := range collectorTypeOrder {
if ct == dataType {
dataIndex = i
}
if ct == secretType {
secretIndex = i
}
}
if dataIndex >= 0 && secretIndex >= 0 {
assert.Less(t, dataIndex, secretIndex, "data collectors should come before secret collectors, preserving relative order")
}
}
+47 -1
View File
@@ -88,6 +88,52 @@ func preprocessV1Beta3Specs(args []string) ([]string, []string, error) {
valuesFiles := viper.GetStringSlice("values")
setValues := viper.GetStringSlice("set")
// Check if any args contain v1beta3 specs
hasV1Beta3 := false
for _, arg := range args {
// Skip non-file arguments
if arg == "-" || strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://") ||
strings.HasPrefix(arg, "secret/") || strings.HasPrefix(arg, "configmap/") {
continue
}
// Check if file exists
if _, err := os.Stat(arg); err != nil {
continue
}
// Read the file
content, err := os.ReadFile(arg)
if err != nil {
continue
}
// Check if it's a v1beta3 spec with templates
contentStr := string(content)
var parsed map[string]interface{}
if err := yaml.Unmarshal(content, &parsed); err == nil {
if apiVersion, ok := parsed["apiVersion"]; ok && apiVersion == constants.Troubleshootv1beta3Kind {
// Only require values if the spec has Helm templates
if strings.Contains(contentStr, "{{") && strings.Contains(contentStr, "}}") {
hasV1Beta3 = true
break
}
}
} else {
// If YAML parsing fails, check raw content for v1beta3 with templates
if strings.Contains(contentStr, "apiVersion: troubleshoot.sh/v1beta3") &&
strings.Contains(contentStr, "{{") && strings.Contains(contentStr, "}}") {
hasV1Beta3 = true
break
}
}
}
// If v1beta3 spec with templates found but no values provided, return error
if hasV1Beta3 && len(valuesFiles) == 0 && len(setValues) == 0 {
return nil, nil, errors.New("v1beta3 specs with Helm templates require a values file. Please provide values using --values or --set flags")
}
// If no values provided, return args unchanged
if len(valuesFiles) == 0 && len(setValues) == 0 {
return args, nil, nil
@@ -109,7 +155,7 @@ func preprocessV1Beta3Specs(args []string) ([]string, []string, error) {
return nil, nil, errors.Wrapf(err, "failed to parse values file %s", valuesFile)
}
values = mergeMaps(values, fileValues)
values = MergeMaps(values, fileValues)
}
// Apply --set values
+73
View File
@@ -8,6 +8,7 @@ import (
"github.com/replicatedhq/troubleshoot/internal/testutils"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/loader"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -410,3 +411,75 @@ func singleTestPreflightSpecsRead(t *testing.T, tt *PreflightSpecsReadTest) (*lo
return kinds, err
}
func TestPreprocessV1Beta3Specs_RequiresValues(t *testing.T) {
// Save and restore viper state
oldValues := viper.Get("values")
oldSet := viper.Get("set")
defer func() {
viper.Set("values", oldValues)
viper.Set("set", oldSet)
}()
t.Run("v1beta3 without values should error", func(t *testing.T) {
// Clear viper values
viper.Set("values", []string{})
viper.Set("set", []string{})
v1beta3File := filepath.Join(testutils.FileDir(), "../../examples/preflight/simple-v1beta3.yaml")
_, _, err := preprocessV1Beta3Specs([]string{v1beta3File})
require.Error(t, err)
assert.Contains(t, err.Error(), "v1beta3 specs with Helm templates require a values file")
})
t.Run("v1beta3 with values file should succeed", func(t *testing.T) {
valuesFile := filepath.Join(testutils.FileDir(), "../../examples/preflight/values-v1beta3-1.yaml")
viper.Set("values", []string{valuesFile})
viper.Set("set", []string{})
v1beta3File := filepath.Join(testutils.FileDir(), "../../examples/preflight/simple-v1beta3.yaml")
processedArgs, tempFiles, err := preprocessV1Beta3Specs([]string{v1beta3File})
// Clean up temp files
defer func() {
for _, f := range tempFiles {
_ = os.Remove(f)
}
}()
require.NoError(t, err)
assert.NotNil(t, processedArgs)
})
t.Run("v1beta3 with --set values should succeed", func(t *testing.T) {
viper.Set("values", []string{})
viper.Set("set", []string{"kubernetes.enabled=true"})
v1beta3File := filepath.Join(testutils.FileDir(), "../../examples/preflight/simple-v1beta3.yaml")
processedArgs, tempFiles, err := preprocessV1Beta3Specs([]string{v1beta3File})
// Clean up temp files
defer func() {
for _, f := range tempFiles {
_ = os.Remove(f)
}
}()
require.NoError(t, err)
assert.NotNil(t, processedArgs)
})
t.Run("v1beta2 without values should succeed", func(t *testing.T) {
viper.Set("values", []string{})
viper.Set("set", []string{})
v1beta2File := filepath.Join(testutils.FileDir(), "../../testdata/preflightspec/troubleshoot_v1beta2_preflight_gotest.yaml")
processedArgs, tempFiles, err := preprocessV1Beta3Specs([]string{v1beta2File})
require.NoError(t, err)
assert.NotNil(t, processedArgs)
assert.Empty(t, tempFiles)
assert.Equal(t, []string{v1beta2File}, processedArgs)
})
}
+4 -4
View File
@@ -33,7 +33,7 @@ func RunTemplate(templateFile string, valuesFiles []string, setValues []string,
if err != nil {
return errors.Wrapf(err, "failed to load values file %s", valuesFile)
}
values = mergeMaps(values, fileValues)
values = MergeMaps(values, fileValues)
}
// Apply --set values (Helm semantics)
@@ -165,8 +165,8 @@ func cleanRenderedYAML(content string) string {
return strings.Join(cleaned, "\n") + "\n"
}
// mergeMaps recursively merges two maps
func mergeMaps(base, overlay map[string]interface{}) map[string]interface{} {
// MergeMaps recursively merges two maps, with overlay taking precedence
func MergeMaps(base, overlay map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})
// Copy base map
@@ -180,7 +180,7 @@ func mergeMaps(base, overlay map[string]interface{}) map[string]interface{} {
// If both are maps, merge recursively
if baseMap, ok := baseVal.(map[string]interface{}); ok {
if overlayMap, ok := v.(map[string]interface{}); ok {
result[k] = mergeMaps(baseMap, overlayMap)
result[k] = MergeMaps(baseMap, overlayMap)
continue
}
}
+1 -1
View File
@@ -430,7 +430,7 @@ func TestRender_V1Beta3_MergeMultipleValuesFiles_And_SetPrecedence(t *testing.T)
for _, f := range []string{minimalFile, file1, file3} {
m, err := loadValuesFile(f)
require.NoError(t, err)
vals = mergeMaps(vals, m)
vals = MergeMaps(vals, m)
}
// First render without --set; expect NO kubernetes analyzer
+104
View File
@@ -0,0 +1,104 @@
package redact
import (
"bufio"
"io"
"github.com/replicatedhq/troubleshoot/pkg/constants"
)
// LineReader reads lines from an io.Reader while tracking whether each line
// ended with a newline character. This is essential for preserving the exact
// structure of input files during redaction - binary files and text files
// without trailing newlines should not have newlines added to them.
//
// Unlike bufio.Scanner which strips newlines and requires the caller to add
// them back, LineReader explicitly tracks the presence of newlines so callers
// can conditionally restore them only when they were originally present.
type LineReader struct {
reader *bufio.Reader
}
// NewLineReader creates a new LineReader that reads from the given io.Reader.
// The reader is wrapped in a bufio.Reader for efficient byte-by-byte reading.
func NewLineReader(r io.Reader) *LineReader {
return &LineReader{
reader: bufio.NewReader(r),
}
}
// ReadLine reads the next line from the reader and returns:
// - line content (without the newline character if present)
// - whether the line ended with a newline (\n)
// - any error encountered
//
// Return values:
// - (content, true, nil) - line ended with \n, more content may follow
// - (content, false, io.EOF) - last line without \n (file doesn't end with newline)
// - (nil, false, io.EOF) - reached EOF with no content (empty file or end of file)
// - (content, false, error) - encountered a non-EOF error
//
// The function respects constants.SCANNER_MAX_SIZE and returns an error if a single
// line exceeds this limit. This prevents memory exhaustion on files with extremely
// long lines or binary files without newlines that are larger than the limit.
//
// Example usage:
//
// lr := NewLineReader(input)
// for {
// line, hadNewline, err := lr.ReadLine()
// if err == io.EOF && len(line) == 0 {
// break // End of file, no more content
// }
//
// // Process line...
// fmt.Print(string(line))
// if hadNewline {
// fmt.Print("\n")
// }
//
// if err == io.EOF {
// break // Last line processed
// }
// if err != nil {
// return err
// }
// }
func (lr *LineReader) ReadLine() ([]byte, bool, error) {
// Initialize line as empty slice (not nil) to ensure consistent return values
// Empty lines (just \n) should return []byte{}, not nil
line := []byte{}
for {
b, err := lr.reader.ReadByte()
// Handle errors
if err == io.EOF {
if len(line) > 0 {
// Last line without newline - return the content we have
return line, false, io.EOF
}
// Nothing left to read - empty file or end of content
return nil, false, io.EOF
}
if err != nil {
// Non-EOF error encountered
return line, false, err
}
// Found newline character
if b == '\n' {
// Return the line (may be empty for blank lines)
return line, true, nil
}
// Accumulate byte into line buffer
line = append(line, b)
// Check buffer limit to prevent memory exhaustion
// This is especially important for binary files without newlines
if len(line) > constants.SCANNER_MAX_SIZE {
return nil, false, bufio.ErrTooLong
}
}
}
+302
View File
@@ -0,0 +1,302 @@
package redact
import (
"bufio"
"bytes"
"io"
"strings"
"testing"
"github.com/replicatedhq/troubleshoot/pkg/constants"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Test 1.2 & 1.3: NewLineReader creates instance correctly
func TestNewLineReader(t *testing.T) {
input := strings.NewReader("test")
lr := NewLineReader(input)
require.NotNil(t, lr)
require.NotNil(t, lr.reader)
}
// Test 1.8: Empty file → (nil, false, io.EOF)
func TestLineReader_EmptyFile(t *testing.T) {
lr := NewLineReader(strings.NewReader(""))
line, hadNewline, err := lr.ReadLine()
assert.Nil(t, line)
assert.False(t, hadNewline)
assert.Equal(t, io.EOF, err)
}
// Test 1.9: Single line with \n → (content, true, nil)
func TestLineReader_SingleLineWithNewline(t *testing.T) {
lr := NewLineReader(strings.NewReader("hello world\n"))
line, hadNewline, err := lr.ReadLine()
assert.Equal(t, []byte("hello world"), line)
assert.True(t, hadNewline)
assert.NoError(t, err)
// Second read should return EOF
line, hadNewline, err = lr.ReadLine()
assert.Nil(t, line)
assert.False(t, hadNewline)
assert.Equal(t, io.EOF, err)
}
// Test 1.10: Single line without \n → (content, false, io.EOF)
func TestLineReader_SingleLineWithoutNewline(t *testing.T) {
lr := NewLineReader(strings.NewReader("hello world"))
line, hadNewline, err := lr.ReadLine()
assert.Equal(t, []byte("hello world"), line)
assert.False(t, hadNewline)
assert.Equal(t, io.EOF, err)
}
// Test 1.11: Multiple lines with \n → correct for each
func TestLineReader_MultipleLinesWithNewlines(t *testing.T) {
input := "line1\nline2\nline3\n"
lr := NewLineReader(strings.NewReader(input))
// First line
line, hadNewline, err := lr.ReadLine()
assert.Equal(t, []byte("line1"), line)
assert.True(t, hadNewline)
assert.NoError(t, err)
// Second line
line, hadNewline, err = lr.ReadLine()
assert.Equal(t, []byte("line2"), line)
assert.True(t, hadNewline)
assert.NoError(t, err)
// Third line
line, hadNewline, err = lr.ReadLine()
assert.Equal(t, []byte("line3"), line)
assert.True(t, hadNewline)
assert.NoError(t, err)
// EOF
line, hadNewline, err = lr.ReadLine()
assert.Nil(t, line)
assert.False(t, hadNewline)
assert.Equal(t, io.EOF, err)
}
// Test 1.12: Last line without \n → (content, false, io.EOF)
func TestLineReader_LastLineWithoutNewline(t *testing.T) {
input := "line1\nline2\nline3"
lr := NewLineReader(strings.NewReader(input))
// First line
line, hadNewline, err := lr.ReadLine()
assert.Equal(t, []byte("line1"), line)
assert.True(t, hadNewline)
assert.NoError(t, err)
// Second line
line, hadNewline, err = lr.ReadLine()
assert.Equal(t, []byte("line2"), line)
assert.True(t, hadNewline)
assert.NoError(t, err)
// Third line (no trailing newline)
line, hadNewline, err = lr.ReadLine()
assert.Equal(t, []byte("line3"), line)
assert.False(t, hadNewline)
assert.Equal(t, io.EOF, err)
}
// Test 1.13: Binary data (no \n) → (all content, false, io.EOF)
func TestLineReader_BinaryData(t *testing.T) {
binaryData := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0xFF, 0xFE}
lr := NewLineReader(bytes.NewReader(binaryData))
line, hadNewline, err := lr.ReadLine()
assert.Equal(t, binaryData, line)
assert.False(t, hadNewline)
assert.Equal(t, io.EOF, err)
}
// Test 1.14: Line exceeding max size → error
func TestLineReader_LineExceedingMaxSize(t *testing.T) {
// Create a line that exceeds SCANNER_MAX_SIZE
largeData := make([]byte, constants.SCANNER_MAX_SIZE+100)
for i := range largeData {
largeData[i] = 'a'
}
lr := NewLineReader(bytes.NewReader(largeData))
line, hadNewline, err := lr.ReadLine()
assert.Nil(t, line)
assert.False(t, hadNewline)
assert.Error(t, err)
assert.ErrorIs(t, err, bufio.ErrTooLong)
}
// Test 1.15: File with only \n → ([], true, nil)
func TestLineReader_OnlyNewline(t *testing.T) {
lr := NewLineReader(strings.NewReader("\n"))
line, hadNewline, err := lr.ReadLine()
assert.Equal(t, []byte{}, line) // Empty line
assert.True(t, hadNewline)
assert.NoError(t, err)
// Second read should return EOF
line, hadNewline, err = lr.ReadLine()
assert.Nil(t, line)
assert.False(t, hadNewline)
assert.Equal(t, io.EOF, err)
}
// Additional test: File with empty lines (multiple newlines)
func TestLineReader_EmptyLines(t *testing.T) {
input := "\n\n\n"
lr := NewLineReader(strings.NewReader(input))
// First empty line
line, hadNewline, err := lr.ReadLine()
assert.Equal(t, []byte{}, line)
assert.True(t, hadNewline)
assert.NoError(t, err)
// Second empty line
line, hadNewline, err = lr.ReadLine()
assert.Equal(t, []byte{}, line)
assert.True(t, hadNewline)
assert.NoError(t, err)
// Third empty line
line, hadNewline, err = lr.ReadLine()
assert.Equal(t, []byte{}, line)
assert.True(t, hadNewline)
assert.NoError(t, err)
// EOF
line, hadNewline, err = lr.ReadLine()
assert.Nil(t, line)
assert.False(t, hadNewline)
assert.Equal(t, io.EOF, err)
}
// Additional test: Mixed content with and without newlines
func TestLineReader_MixedContent(t *testing.T) {
input := "line1\n\nline3"
lr := NewLineReader(strings.NewReader(input))
// First line
line, hadNewline, err := lr.ReadLine()
assert.Equal(t, []byte("line1"), line)
assert.True(t, hadNewline)
assert.NoError(t, err)
// Empty line
line, hadNewline, err = lr.ReadLine()
assert.Equal(t, []byte{}, line)
assert.True(t, hadNewline)
assert.NoError(t, err)
// Last line without newline
line, hadNewline, err = lr.ReadLine()
assert.Equal(t, []byte("line3"), line)
assert.False(t, hadNewline)
assert.Equal(t, io.EOF, err)
}
// Additional test: Large but valid file (under max size)
func TestLineReader_LargeValidFile(t *testing.T) {
// Create a line that's large but under the limit
largeData := make([]byte, constants.SCANNER_MAX_SIZE-100)
for i := range largeData {
largeData[i] = 'x'
}
largeData = append(largeData, '\n')
lr := NewLineReader(bytes.NewReader(largeData))
line, hadNewline, err := lr.ReadLine()
assert.Equal(t, constants.SCANNER_MAX_SIZE-100, len(line))
assert.True(t, hadNewline)
assert.NoError(t, err)
}
// Additional test: Binary file with embedded newlines
func TestLineReader_BinaryWithEmbeddedNewlines(t *testing.T) {
binaryData := []byte{0x01, 0x02, '\n', 0x03, 0x04, '\n', 0x05}
lr := NewLineReader(bytes.NewReader(binaryData))
// First "line" (up to first \n)
line, hadNewline, err := lr.ReadLine()
assert.Equal(t, []byte{0x01, 0x02}, line)
assert.True(t, hadNewline)
assert.NoError(t, err)
// Second "line"
line, hadNewline, err = lr.ReadLine()
assert.Equal(t, []byte{0x03, 0x04}, line)
assert.True(t, hadNewline)
assert.NoError(t, err)
// Last "line" without newline
line, hadNewline, err = lr.ReadLine()
assert.Equal(t, []byte{0x05}, line)
assert.False(t, hadNewline)
assert.Equal(t, io.EOF, err)
}
// Test edge case: Very small reads
func TestLineReader_SingleByteReads(t *testing.T) {
input := "a\nb\nc"
lr := NewLineReader(strings.NewReader(input))
line, hadNewline, err := lr.ReadLine()
assert.Equal(t, []byte("a"), line)
assert.True(t, hadNewline)
assert.NoError(t, err)
line, hadNewline, err = lr.ReadLine()
assert.Equal(t, []byte("b"), line)
assert.True(t, hadNewline)
assert.NoError(t, err)
line, hadNewline, err = lr.ReadLine()
assert.Equal(t, []byte("c"), line)
assert.False(t, hadNewline)
assert.Equal(t, io.EOF, err)
}
// Benchmark: LineReader vs bufio.Scanner performance
func BenchmarkLineReader(b *testing.B) {
// Create test data
var buf bytes.Buffer
for i := 0; i < 1000; i++ {
buf.WriteString("This is line number ")
buf.WriteString(string(rune(i)))
buf.WriteString(" with some content\n")
}
data := buf.Bytes()
b.ResetTimer()
for i := 0; i < b.N; i++ {
lr := NewLineReader(bytes.NewReader(data))
for {
_, _, err := lr.ReadLine()
if err == io.EOF {
break
}
}
}
}
+44 -14
View File
@@ -3,6 +3,7 @@ package redact
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
@@ -25,6 +26,12 @@ func literalString(match []byte, path, name string) Redactor {
}
}
// Redact processes the input reader line-by-line, replacing literal string matches.
// Unlike the previous implementation using bufio.Scanner, this now uses LineReader
// to preserve the exact newline structure of the input file. Lines that originally
// ended with \n will have \n added back, while lines without \n (like the last line
// of a file without a trailing newline, or binary files) will not have \n added.
// This ensures binary files and text files without trailing newlines are not corrupted.
func (r literalRedactor) Redact(input io.Reader, path string) io.Reader {
out, writer := io.Pipe()
@@ -34,7 +41,8 @@ func (r literalRedactor) Redact(input io.Reader, path string) io.Reader {
if err == nil || err == io.EOF {
writer.Close()
} else {
if err == bufio.ErrTooLong {
// Check if error is about line exceeding maximum size
if errors.Is(err, bufio.ErrTooLong) {
s := fmt.Sprintf("Error redacting %q. A line in the file exceeded %d MB max length", path, constants.SCANNER_MAX_SIZE/1024/1024)
klog.V(2).Info(s)
} else {
@@ -44,17 +52,24 @@ func (r literalRedactor) Redact(input io.Reader, path string) io.Reader {
}
}()
buf := make([]byte, constants.BUF_INIT_SIZE)
scanner := bufio.NewScanner(input)
scanner.Buffer(buf, constants.SCANNER_MAX_SIZE)
// Use LineReader instead of bufio.Scanner to track newline presence
lineReader := NewLineReader(input)
tokenizer := GetGlobalTokenizer()
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Bytes()
for {
line, hadNewline, readErr := lineReader.ReadLine()
// Handle EOF with no content - we're done
if readErr == io.EOF && len(line) == 0 {
break
}
// We have content to process
lineNum++
// Perform literal string replacement
var clean []byte
tokenizer := GetGlobalTokenizer()
if tokenizer.IsEnabled() {
// For literal redaction, we tokenize the matched value
matchStr := string(r.match)
@@ -66,12 +81,20 @@ func (r literalRedactor) Redact(input io.Reader, path string) io.Reader {
clean = bytes.ReplaceAll(line, r.match, maskTextBytes)
}
// Append newline since scanner strips it
err = writeBytes(writer, clean, NEW_LINE)
// Write the line (redacted or original)
err = writeBytes(writer, clean)
if err != nil {
return
}
// Only add newline if original line had one
if hadNewline {
err = writeBytes(writer, NEW_LINE)
if err != nil {
return
}
}
// Track redaction if content changed
if !bytes.Equal(clean, line) {
addRedaction(Redaction{
RedactorName: r.redactName,
@@ -81,9 +104,16 @@ func (r literalRedactor) Redact(input io.Reader, path string) io.Reader {
IsDefaultRedactor: r.isDefault,
})
}
}
if scanErr := scanner.Err(); scanErr != nil {
err = scanErr
// Check if we hit EOF after processing this line
if readErr == io.EOF {
break
}
// Check for non-EOF errors
if readErr != nil {
err = readErr
return
}
}
}()
return out
+386
View File
@@ -0,0 +1,386 @@
package redact
import (
"bytes"
"io"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// Test basic literal redaction functionality
func TestLiteralRedactor_BasicRedaction(t *testing.T) {
tests := []struct {
name string
match string
inputString string
wantString string
}{
{
name: "Simple literal match",
match: "secret123",
inputString: "password=secret123",
wantString: "password=***HIDDEN***", // No trailing newline in input
},
{
name: "Multiple occurrences",
match: "secret",
inputString: "secret is secret here secret",
wantString: "***HIDDEN*** is ***HIDDEN*** here ***HIDDEN***",
},
{
name: "No match",
match: "xyz",
inputString: "no match here",
wantString: "no match here",
},
{
name: "With trailing newline",
match: "secret",
inputString: "secret\n",
wantString: "***HIDDEN***\n",
},
{
name: "Multiline with newlines",
match: "secret",
inputString: "line1 secret\nline2 secret\n",
wantString: "line1 ***HIDDEN***\nline2 ***HIDDEN***\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
redactor := literalString([]byte(tt.match), "testfile", tt.name)
out := redactor.Redact(bytes.NewReader([]byte(tt.inputString)), "")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, tt.wantString, string(result))
})
}
}
// Test 4.12: Binary file → unchanged
func TestLiteralRedactor_BinaryFile(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
// Binary content with no newlines and no match
binaryData := []byte{0x01, 0x02, 0x03, 0x04, 0x00, 0xFF, 0xFE, 0xAB, 0xCD}
redactor := literalString([]byte("notfound"), "testfile", t.Name())
out := redactor.Redact(bytes.NewReader(binaryData), "test.bin")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, binaryData, result, "Binary file should be unchanged")
}
// Test: Binary file with every single byte value (0x00 -> 0xFF)
func TestLiteralRedactor_AllSingleByteValues(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
// Create binary data with every possible byte value
binaryData := make([]byte, 256)
for i := 0; i < 256; i++ {
binaryData[i] = byte(i)
}
redactor := literalString([]byte("notfound"), "testfile", t.Name())
out := redactor.Redact(bytes.NewReader(binaryData), "test.bin")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, binaryData, result, "Binary file with all byte values should be unchanged")
require.Len(t, result, 256, "Should preserve all 256 bytes")
}
// Test: Binary file with every two-byte combination (0x00+0x00 -> 0xFF+0xFF)
func TestLiteralRedactor_AllTwoByteValues(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
// Create binary data with all 65536 two-byte combinations (128KB)
binaryData := make([]byte, 256*256*2)
pos := 0
for i := 0; i < 256; i++ {
for j := 0; j < 256; j++ {
binaryData[pos] = byte(i)
binaryData[pos+1] = byte(j)
pos += 2
}
}
redactor := literalString([]byte("notfound"), "testfile", t.Name())
out := redactor.Redact(bytes.NewReader(binaryData), "test.bin")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, binaryData, result, "Binary file with all two-byte combinations should be unchanged")
require.Len(t, result, 256*256*2, "Should preserve all 131072 bytes")
}
// Test 4.12 (variant): Binary file with literal match → redacted, no extra newlines
func TestLiteralRedactor_BinaryFileWithMatch(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
// Binary content with a literal match (0xFF 0xFE sequence)
binaryData := []byte{0x01, 0x02, 0xFF, 0xFE, 0x03, 0x04}
redactor := literalString([]byte{0xFF, 0xFE}, "testfile", t.Name())
// We need to mock maskTextBytes for this test to work predictably
// For now, test that no newlines are added
out := redactor.Redact(bytes.NewReader(binaryData), "test.bin")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.NotEqual(t, binaryData, result, "Binary should be redacted")
require.NotContains(t, result, []byte{0xFF, 0xFE}, "Match should be replaced")
// Most importantly: no trailing newline added to binary file
require.NotEqual(t, byte('\n'), result[len(result)-1], "Should not add trailing newline")
}
// Test 4.13: Text with trailing \n → preserved
func TestLiteralRedactor_TextWithTrailingNewline(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := "hello world\n"
redactor := literalString([]byte("xyz"), "testfile", t.Name())
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, "hello world\n", string(result), "Trailing newline should be preserved")
}
// Test 4.14: Text without trailing \n → preserved
func TestLiteralRedactor_TextWithoutTrailingNewline(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := "hello world"
redactor := literalString([]byte("xyz"), "testfile", t.Name())
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, "hello world", string(result), "No newline should be added")
}
// Test 4.15: Empty file → unchanged
func TestLiteralRedactor_EmptyFile(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := ""
redactor := literalString([]byte("secret"), "testfile", t.Name())
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, "", string(result), "Empty file should remain empty")
}
// Test 4.16: Literal match and replacement works
func TestLiteralRedactor_LiteralMatch(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := "password=secret123"
redactor := literalString([]byte("secret123"), "testfile", t.Name())
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, "password=***HIDDEN***", string(result))
}
// Test 4.17: Multiple occurrences replaced
func TestLiteralRedactor_MultipleOccurrences(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := "secret here and secret there and secret everywhere"
redactor := literalString([]byte("secret"), "testfile", t.Name())
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, "***HIDDEN*** here and ***HIDDEN*** there and ***HIDDEN*** everywhere", string(result))
}
// Test 4.17 (variant): Multiple occurrences across lines
func TestLiteralRedactor_MultipleOccurrencesMultiline(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := "line1 secret\nline2 secret\nline3 secret\n"
redactor := literalString([]byte("secret"), "testfile", t.Name())
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
expected := "line1 ***HIDDEN***\nline2 ***HIDDEN***\nline3 ***HIDDEN***\n"
require.Equal(t, expected, string(result))
}
// Test 4.18: Tokenization works
func TestLiteralRedactor_Tokenization(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
// Enable tokenization for this test
EnableTokenization()
defer DisableTokenization()
input := "password=secret123"
redactor := literalString([]byte("secret123"), "testfile", t.Name())
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
// Result should contain a token, not the original or ***HIDDEN***
require.NotContains(t, string(result), "secret123")
require.NotContains(t, string(result), "***HIDDEN***")
require.Contains(t, string(result), "password=")
}
// Test 4.19: Redaction count accurate
func TestLiteralRedactor_RedactionCount(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := "secret here\nsecret there"
// Use unique redactor name and filename to avoid pollution from parallel tests
uniqueFile := "TestLiteralRedactor_RedactionCount_file"
uniqueRedactor := "TestLiteralRedactor_RedactionCount_redactor"
redactor := literalString([]byte("secret"), uniqueFile, uniqueRedactor)
out := redactor.Redact(bytes.NewReader([]byte(input)), "")
_, err := io.ReadAll(out)
require.NoError(t, err)
redactions := GetRedactionList()
// Two lines, each with one match = 2 redaction events
require.Len(t, redactions.ByRedactor[uniqueRedactor], 2, "Should record 2 redactions (one per line)")
require.Len(t, redactions.ByFile[uniqueFile], 2, "Should record 2 redactions for file")
}
// Test 4.20: Backward compatibility - existing behavior preserved for text with newlines
func TestLiteralRedactor_BackwardCompatibility(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := "line1 secret\nline2 secret\nline3\n"
redactor := literalString([]byte("secret"), "testfile", t.Name())
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
expected := "line1 ***HIDDEN***\nline2 ***HIDDEN***\nline3\n"
require.Equal(t, expected, string(result), "Behavior for text with newlines should be unchanged")
}
// Test 4.20 (variant): Literal match on last line without \n
func TestLiteralRedactor_LastLineWithoutNewline(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := "line1\nline2 secret"
redactor := literalString([]byte("secret"), "testfile", t.Name())
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
expected := "line1\nline2 ***HIDDEN***"
require.Equal(t, expected, string(result), "Should not add newline to last line")
}
// Additional test: Empty line handling
func TestLiteralRedactor_EmptyLines(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := "\n\n\n"
redactor := literalString([]byte("secret"), "testfile", t.Name())
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, "\n\n\n", string(result), "Empty lines should be preserved")
}
// Additional test: Large file with many matches
func TestLiteralRedactor_LargeFile(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
// Create large file with many occurrences
var input strings.Builder
for i := 0; i < 1000; i++ {
input.WriteString("line ")
input.WriteString("secret")
input.WriteString(" here\n")
}
redactor := literalString([]byte("secret"), "testfile", t.Name())
out := redactor.Redact(strings.NewReader(input.String()), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.NotContains(t, string(result), "secret", "All secrets should be redacted")
require.Contains(t, string(result), "***HIDDEN***")
}
// Additional test: Partial match should not be replaced
func TestLiteralRedactor_PartialMatchNotReplaced(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := "secret secretive secrets"
// Should only replace exact literal "secret", not "secretive" or "secrets"
redactor := literalString([]byte("secret"), "testfile", t.Name())
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, "***HIDDEN*** ***HIDDEN***ive ***HIDDEN***s", string(result))
}
+129 -36
View File
@@ -1,12 +1,18 @@
package redact
import (
"bufio"
"bytes"
"io"
"regexp"
)
// lineState represents a line and whether it ended with a newline character.
// This is used by MultiLineRedactor to track newline state for line pairs.
type lineState struct {
content []byte
hadNewline bool
}
type MultiLineRedactor struct {
scan *regexp.Regexp
re1 *regexp.Regexp
@@ -39,6 +45,16 @@ func NewMultiLineRedactor(re1 LineRedactor, re2 string, maskText, path, name str
return &MultiLineRedactor{scan: scanCompiled, re1: compiled1, re2: compiled2, maskText: maskText, filePath: path, redactName: name, isDefault: isDefault}, nil
}
// Redact processes the input reader in pairs of lines, applying redaction patterns.
// Unlike the previous implementation using bufio.Reader with readLine(), this now
// uses LineReader to preserve the exact newline structure of the input file.
//
// The MultiLineRedactor works by:
// 1. Reading pairs of lines (line1, line2)
// 2. If line1 matches the selector pattern (re1), redact line2 using re2
// 3. Write both lines with their original newline structure preserved
//
// This ensures binary files and text files without trailing newlines are not corrupted.
func (r *MultiLineRedactor) Redact(input io.Reader, path string) io.Reader {
out, writer := io.Pipe()
go func() {
@@ -48,51 +64,74 @@ func (r *MultiLineRedactor) Redact(input io.Reader, path string) io.Reader {
}()
tokenizer := GetGlobalTokenizer()
lineReader := NewLineReader(input)
reader := bufio.NewReader(input)
line1, line2, err := getNextTwoLines(reader, nil)
if err != nil {
// this will print 2 blank lines for empty input...
// Append newlines since scanner strips them
err = writeBytes(writer, line1, NEW_LINE, line2, NEW_LINE)
if err != nil {
return
// Try to read first two lines
line1, nl1, line2, nl2, readErr := getNextTwoLines(lineReader, nil)
// Handle case where we can't read 2 lines (empty file or single line)
// Note: We check line1 == nil (not len(line1) == 0) because:
// - nil means truly empty file with no content
// - []byte{} (len==0) means an empty line that had a newline (e.g., "\n")
if readErr != nil && line1 == nil {
// Empty file - nothing to write
// Propagate non-EOF errors (EOF is expected for empty files)
if readErr != io.EOF {
err = readErr
}
return
}
if readErr != nil {
// Only 1 line available (or empty line with newline) - write it and exit
// FIX: This is the bug fix - only add newline if original had one
// Also handles empty lines (line1 == []byte{} with nl1 == true)
err = writeLine(writer, line1, nl1)
if err != nil {
return
}
// Propagate non-EOF errors (EOF is expected for single-line files)
if readErr != io.EOF {
err = readErr
}
return
}
// Process line pairs
flushLastLine := false
lineNum := 1
for err == nil {
for readErr == nil {
lineNum++ // the first line that can be redacted is line 2
// is scan is not nil, then check if line1 matches scan by lowercasing it
// Pre-filter: if scan is not nil, check if line1 matches scan by lowercasing it
if r.scan != nil {
lowerLine1 := bytes.ToLower(line1)
if !r.scan.Match(lowerLine1) {
// Append newline since scanner strips it
err = writeBytes(writer, line1, NEW_LINE)
// No match - write line1 and advance
err = writeLine(writer, line1, nl1)
if err != nil {
return
}
line1, line2, err = getNextTwoLines(reader, &line2)
line1, nl1, line2, nl2, readErr = getNextTwoLines(lineReader, &lineState{line2, nl2})
flushLastLine = true
continue
}
}
// If line1 matches re1, then transform line2 using re2
// Check if line1 matches the selector pattern (re1)
if !r.re1.Match(line1) {
// Append newline since scanner strips it
err = writeBytes(writer, line1, NEW_LINE)
// No match - write line1 and advance
err = writeLine(writer, line1, nl1)
if err != nil {
return
}
line1, line2, err = getNextTwoLines(reader, &line2)
line1, nl1, line2, nl2, readErr = getNextTwoLines(lineReader, &lineState{line2, nl2})
flushLastLine = true
continue
}
// line1 matched selector - redact line2
flushLastLine = false
var clean []byte
if tokenizer.IsEnabled() {
@@ -105,13 +144,17 @@ func (r *MultiLineRedactor) Redact(input io.Reader, path string) io.Reader {
clean = r.re2.ReplaceAll(line2, substStr)
}
// Append newlines since scanner strips them
err = writeBytes(writer, line1, NEW_LINE, clean, NEW_LINE)
// Write line1 (selector line) and line2 (redacted line)
err = writeLine(writer, line1, nl1)
if err != nil {
return
}
err = writeLine(writer, clean, nl2)
if err != nil {
return
}
// if clean is not equal to line2, a redaction was performed
// Track redaction if content changed
if !bytes.Equal(clean, line2) {
addRedaction(Redaction{
RedactorName: r.redactName,
@@ -122,42 +165,92 @@ func (r *MultiLineRedactor) Redact(input io.Reader, path string) io.Reader {
})
}
line1, line2, err = getNextTwoLines(reader, nil)
// Get next pair
line1, nl1, line2, nl2, readErr = getNextTwoLines(lineReader, nil)
}
if flushLastLine {
// Append newline since scanner strip it
err = writeBytes(writer, line1, NEW_LINE)
// After loop exits (readErr != nil), check if we have an unwritten line1
// This happens in two cases:
// 1. flushLastLine=true: line1 was advanced but not written (scan/re1 didn't match)
// 2. line1 != nil: we read line1 but couldn't get line2 (unpaired line at end)
// Note: We check line1 != nil (not len(line1) > 0) to handle empty lines ([]byte{})
if flushLastLine || line1 != nil {
err = writeLine(writer, line1, nl1)
if err != nil {
return
}
}
// Propagate non-EOF read errors to the caller
// EOF is expected (end of file) and not an error condition
// Note: readErr is always non-nil here (loop exited), but we only propagate non-EOF errors
if readErr != io.EOF {
err = readErr
}
}()
return out
}
func getNextTwoLines(reader *bufio.Reader, curLine2 *[]byte) (line1 []byte, line2 []byte, err error) {
line2 = []byte{}
// getNextTwoLines reads the next pair of lines from the LineReader.
// It returns the content and newline state for both lines.
//
// If curLine2 is provided, it's used as line1 (optimization for advancing through file).
// Otherwise, both lines are read fresh from the reader.
//
// Returns:
// - line1, hadNewline1: First line content and newline state
// - line2, hadNewline2: Second line content and newline state
// - err: Error only if we couldn't read line1, or if line2 read failed with non-EOF error
//
// Note: If line2 returns (content, false, io.EOF), we treat this as SUCCESS because
// we got the content. The EOF just means it didn't have a trailing newline.
func getNextTwoLines(lr *LineReader, curLine2 *lineState) (
line1 []byte, hadNewline1 bool,
line2 []byte, hadNewline2 bool,
err error,
) {
if curLine2 == nil {
line1, err = readLine(reader)
// Read both lines fresh
line1, hadNewline1, err = lr.ReadLine()
if err != nil {
return
}
line2, err = readLine(reader)
line2, hadNewline2, err = lr.ReadLine()
// If we got line2 content but hit EOF, that's OK - it just means no trailing newline
if err == io.EOF && len(line2) > 0 {
err = nil // Clear the error - we successfully read both lines
}
return
}
line1 = *curLine2
line2, err = readLine(reader)
if err != nil {
return
}
// Use cached line2 as new line1 (optimization)
line1 = curLine2.content
hadNewline1 = curLine2.hadNewline
// Read new line2
line2, hadNewline2, err = lr.ReadLine()
// If we got line2 content but hit EOF, that's OK - it just means no trailing newline
if err == io.EOF && len(line2) > 0 {
err = nil // Clear the error - we successfully read both lines
}
return
}
// writeLine writes a line to the writer, optionally adding a newline if hadNewline is true.
// This helper reduces code duplication in the Redact function.
func writeLine(w io.Writer, line []byte, hadNewline bool) error {
if err := writeBytes(w, line); err != nil {
return err
}
if hadNewline {
if err := writeBytes(w, NEW_LINE); err != nil {
return err
}
}
return nil
}
// writeBytes writes all byte slices to the writer
// in the order they are passed in the variadic argument
func writeBytes(w io.Writer, bs ...[]byte) error {
+285 -13
View File
@@ -28,8 +28,7 @@ func Test_NewMultiLineRedactor(t *testing.T) {
inputString: `"name": "secret_access_key"
"value": "dfeadsfsdfe"`,
wantString: `"name": "secret_access_key"
"value": "***HIDDEN***"
`,
"value": "***HIDDEN***"`, // No trailing newline in input, so none in output
},
{
name: "Redact multiline with AWS secret id",
@@ -40,8 +39,7 @@ func Test_NewMultiLineRedactor(t *testing.T) {
inputString: `"name": "ACCESS_KEY_ID"
"value": "dfeadsfsdfe"`,
wantString: `"name": "ACCESS_KEY_ID"
"value": "***HIDDEN***"
`,
"value": "***HIDDEN***"`, // No trailing newline in input, so none in output
},
{
name: "Redact multiline with OSD",
@@ -52,8 +50,7 @@ func Test_NewMultiLineRedactor(t *testing.T) {
inputString: `"entity": "osd.1abcdef"
"key": "Gjt8s0WkfPtxZUo7gI8a0awbQGHgzuprdaedfb=="`,
wantString: `"entity": "osd.1abcdef"
"key": "***HIDDEN***"
`,
"key": "***HIDDEN***"`, // No trailing newline in input, so none in output
},
{
name: "Redact multiline with AWS secret access key and scan regex",
@@ -65,8 +62,7 @@ func Test_NewMultiLineRedactor(t *testing.T) {
inputString: `"name": "secret_access_key"
"value": "dfeadsfsdfe"`,
wantString: `"name": "secret_access_key"
"value": "***HIDDEN***"
`,
"value": "***HIDDEN***"`, // No trailing newline in input, so none in output
},
{
name: "Redact multiline with AWS secret id and scan regex",
@@ -78,8 +74,7 @@ func Test_NewMultiLineRedactor(t *testing.T) {
inputString: `"name": "ACCESS_KEY_ID"
"value": "dfeadsfsdfe"`,
wantString: `"name": "ACCESS_KEY_ID"
"value": "***HIDDEN***"
`,
"value": "***HIDDEN***"`, // No trailing newline in input, so none in output
},
{
name: "Redact multiline with OSD and scan regex",
@@ -91,8 +86,7 @@ func Test_NewMultiLineRedactor(t *testing.T) {
inputString: `"entity": "osd.1abcdef"
"key": "Gjt8s0WkfPtxZUo7gI8a0awbQGHgzuprdaedfb=="`,
wantString: `"entity": "osd.1abcdef"
"key": "***HIDDEN***"
`,
"key": "***HIDDEN***"`, // No trailing newline in input, so none in output
},
{
name: "Multiple newlines with no match",
@@ -102,7 +96,7 @@ func Test_NewMultiLineRedactor(t *testing.T) {
},
redactor: `(?i)("value": *")(?P<mask>.*[^\"]*)(")`,
inputString: "no match\n\n no match \n\n",
wantString: "no match\n\n no match \n\n",
wantString: "no match\n\n no match \n\n", // Input has trailing newline, should be preserved
},
}
for _, tt := range tests {
@@ -158,3 +152,281 @@ func Test_writeBytes(t *testing.T) {
})
}
}
// Test 3.16: Binary file (no newlines) → unchanged
func TestMultiLineRedactor_BinaryFile(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
// Binary content with no newlines - the bug that caused 2 extra bytes
binaryData := []byte{0x01, 0x02, 0x03, 0x04, 0x00, 0xFF, 0xFE, 0xAB, 0xCD}
redactor, err := NewMultiLineRedactor(
LineRedactor{regex: `"name":`},
`"value":`,
MASK_TEXT, "testfile", "test", false,
)
require.NoError(t, err)
out := redactor.Redact(bytes.NewReader(binaryData), "test.bin")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, binaryData, result, "Binary file should be unchanged (no extra newlines)")
}
// Test: Binary file with every single byte value (0x00 -> 0xFF)
func TestMultiLineRedactor_AllSingleByteValues(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
// Create binary data with every possible byte value
binaryData := make([]byte, 256)
for i := 0; i < 256; i++ {
binaryData[i] = byte(i)
}
redactor, err := NewMultiLineRedactor(
LineRedactor{regex: `"name":`},
`"value":`,
MASK_TEXT, "testfile", t.Name(), false,
)
require.NoError(t, err)
out := redactor.Redact(bytes.NewReader(binaryData), "test.bin")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, binaryData, result, "Binary file with all byte values should be unchanged")
require.Len(t, result, 256, "Should preserve all 256 bytes")
}
// Test: Binary file with every two-byte combination (0x00+0x00 -> 0xFF+0xFF)
func TestMultiLineRedactor_AllTwoByteValues(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
// Create binary data with all 65,536 two-byte combinations (128KB)
binaryData := make([]byte, 256*256*2)
pos := 0
for i := 0; i < 256; i++ {
for j := 0; j < 256; j++ {
binaryData[pos] = byte(i)
binaryData[pos+1] = byte(j)
pos += 2
}
}
redactor, err := NewMultiLineRedactor(
LineRedactor{regex: `"name":`},
`"value":`,
MASK_TEXT, "testfile", t.Name(), false,
)
require.NoError(t, err)
out := redactor.Redact(bytes.NewReader(binaryData), "test.bin")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, binaryData, result, "Binary file with all two-byte combinations should be unchanged")
require.Len(t, result, 256*256*2, "Should preserve all 131,072 bytes (64k combinations)")
}
// Test 3.17: Single line with \n → unchanged
func TestMultiLineRedactor_SingleLineWithNewline(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := "single line\n"
redactor, err := NewMultiLineRedactor(
LineRedactor{regex: `"name":`},
`"value":`,
MASK_TEXT, "testfile", "test", false,
)
require.NoError(t, err)
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, "single line\n", string(result))
}
// Test 3.18: Single line without \n → unchanged
func TestMultiLineRedactor_SingleLineWithoutNewline(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := "single line"
redactor, err := NewMultiLineRedactor(
LineRedactor{regex: `"name":`},
`"value":`,
MASK_TEXT, "testfile", "test", false,
)
require.NoError(t, err)
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, "single line", string(result), "No newline should be added")
}
// Test 3.19: Empty file → unchanged
func TestMultiLineRedactor_EmptyFile(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := ""
redactor, err := NewMultiLineRedactor(
LineRedactor{regex: `"name":`},
`"value":`,
MASK_TEXT, "testfile", "test", false,
)
require.NoError(t, err)
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
require.Equal(t, "", string(result), "Empty file should remain empty")
}
// Test 3.20: Two lines, matches selector → line2 redacted
func TestMultiLineRedactor_TwoLinesMatch(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := `"name": "PASSWORD"
"value": "secret123"`
redactor, err := NewMultiLineRedactor(
LineRedactor{regex: `(?i)"name": *"PASSWORD"`},
`(?i)("value": *")(?P<mask>[^"]*)(")`,
MASK_TEXT, "testfile", "test", false,
)
require.NoError(t, err)
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
expected := `"name": "PASSWORD"
"value": "***HIDDEN***"`
require.Equal(t, expected, string(result))
}
// Test 3.21: Two lines, no selector match → unchanged
func TestMultiLineRedactor_TwoLinesNoMatch(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := `"name": "USERNAME"
"value": "admin"`
redactor, err := NewMultiLineRedactor(
LineRedactor{regex: `(?i)"name": *"PASSWORD"`},
`(?i)("value": *")(?P<mask>[^"]*)(")`,
MASK_TEXT, "testfile", "test", false,
)
require.NoError(t, err)
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
expected := `"name": "USERNAME"
"value": "admin"`
require.Equal(t, expected, string(result))
}
// Test 3.22: Multiple line pairs → correct redactions
func TestMultiLineRedactor_MultiplePairs(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := `"name": "PASSWORD"
"value": "secret1"
"name": "TOKEN"
"value": "secret2"
"name": "USERNAME"
"value": "admin"
`
redactor, err := NewMultiLineRedactor(
LineRedactor{regex: `(?i)"name": *"(PASSWORD|TOKEN)"`},
`(?i)("value": *")(?P<mask>[^"]*)(")`,
MASK_TEXT, "testfile", "test", false,
)
require.NoError(t, err)
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
expected := `"name": "PASSWORD"
"value": "***HIDDEN***"
"name": "TOKEN"
"value": "***HIDDEN***"
"name": "USERNAME"
"value": "admin"
`
require.Equal(t, expected, string(result))
}
// Test 3.23: Three lines (pair + unpaired)
func TestMultiLineRedactor_ThreeLines(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
input := `"name": "PASSWORD"
"value": "secret"
unpaired line`
redactor, err := NewMultiLineRedactor(
LineRedactor{regex: `(?i)"name": *"PASSWORD"`},
`(?i)("value": *")(?P<mask>[^"]*)(")`,
MASK_TEXT, "testfile", "test", false,
)
require.NoError(t, err)
out := redactor.Redact(bytes.NewReader([]byte(input)), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
expected := `"name": "PASSWORD"
"value": "***HIDDEN***"
unpaired line`
require.Equal(t, expected, string(result))
}
// Test 3.24: Large file with selector matches
func TestMultiLineRedactor_LargeFile(t *testing.T) {
ResetRedactionList()
defer ResetRedactionList()
var input strings.Builder
for i := 0; i < 1000; i++ {
input.WriteString(`"name": "PASSWORD"` + "\n")
input.WriteString(`"value": "secret"` + "\n")
}
redactor, err := NewMultiLineRedactor(
LineRedactor{regex: `(?i)"name": *"PASSWORD"`},
`(?i)("value": *")(?P<mask>[^"]*)(")`,
MASK_TEXT, "testfile", "test", false,
)
require.NoError(t, err)
out := redactor.Redact(strings.NewReader(input.String()), "test.txt")
result, err := io.ReadAll(out)
require.NoError(t, err)
// Verify all secrets were redacted
require.NotContains(t, string(result), `"value": "secret"`)
require.Contains(t, string(result), `"value": "***HIDDEN***"`)
}

Some files were not shown because too many files have changed in this diff Show More