mirror of
https://github.com/replicatedhq/troubleshoot.git
synced 2026-08-27 00:37:20 +00:00
Compare commits
@@ -0,0 +1,22 @@
|
||||
# Code Review Guidelines
|
||||
|
||||
## Basic Review
|
||||
|
||||
- **Breaking API changes** — Check for removed/renamed fields in `pkg/apis/`, changed function signatures in public packages, or modified CLI flags and output formats.
|
||||
- **Pattern violations** — New code should follow existing patterns in the codebase (e.g., collector/analyzer structure, error handling conventions, interface usage).
|
||||
- **Security** — Watch for command injection in exec-based collectors, path traversal in file operations, unsanitized user input in specs, and leaked credentials in collected data.
|
||||
- **Go standards** — Issues that linters like `go vet`, `staticcheck`, and `modernize` would catch: deprecated API usage, unnecessary allocations, error shadowing, unchecked errors.
|
||||
- **Test coverage** — New functionality should have tests. Changes to existing code should not reduce coverage compared to the last test run on `main`.
|
||||
- **Error handling** — Errors should wrap context (`fmt.Errorf("... : %w", err)`), not be silently swallowed, and provide actionable messages for operator-facing output.
|
||||
- **Concurrency safety** — Collectors run concurrently. Shared state must be protected. `CollectorResult` map writes from goroutines need synchronization.
|
||||
- **Bundle storage** — Collectors must save data using `CollectorResult.SaveResult` and related methods (`SaveResults`, `SymLinkResult`). Never write files directly — `CollectorResult` handles dual-mode storage (in-memory for preflights, on-disk for support bundles). See `pkg/collect/result.go`.
|
||||
|
||||
## Advanced Review
|
||||
|
||||
- **Cross-feature impact** — Consider whether a change to one collector/analyzer could affect the broader collection pipeline, redaction, or output archive structure.
|
||||
- **CLI vs SDK consumers** — This project is consumed both as CLI tools and as Go packages (SDK). Changes targeting a CLI use case must not break SDK consumers who import `pkg/collect`, `pkg/analyze`, or API types directly.
|
||||
- **Documentation** — Does the change add, modify, or remove user-facing behavior? Check whether https://troubleshoot.sh needs updates. Use https://troubleshoot.sh/llms.txt or https://troubleshoot.sh/llms-full.txt to review current docs.
|
||||
- **Dedicated documentation needs** — For large or complex changes, consider whether CLI users or SDK consumers need standalone documentation (migration guides, new feature walkthroughs, updated examples).
|
||||
- **Backwards compatibility** — Spec changes must consider existing specs in the wild. New fields should have sensible zero-value defaults. Removed fields should not cause parse failures.
|
||||
- **Downstream impact on sbctl** — Changes to public Go packages (`pkg/collect`, `pkg/analyze`, API types, etc.) may require follow-up changes in [replicatedhq/sbctl](https://github.com/replicatedhq/sbctl), which imports this project as a dependency. Flag any breaking or behavioral changes that could affect sbctl.
|
||||
- **Repository docs** — If your changes affect build commands, architecture, project conventions, or review guidelines, update `CLAUDE.md`, `README.md`, and this file accordingly.
|
||||
@@ -0,0 +1,180 @@
|
||||
schema_version: v1
|
||||
name: dependency-update-troubleshoot
|
||||
enabled: true
|
||||
|
||||
trigger:
|
||||
cron:
|
||||
schedule: "0 21 * * *"
|
||||
timezone: "America/Chicago"
|
||||
overlap_policy: skip
|
||||
timeout: 2h
|
||||
|
||||
enable_manual_trigger: true
|
||||
|
||||
tags: ["cron", "dependencies"]
|
||||
color: cyan
|
||||
|
||||
stages:
|
||||
- id: start
|
||||
label: Start
|
||||
entry: true
|
||||
on_enter:
|
||||
inject: |
|
||||
Workflow task: run the Go dependency update maintenance workflow for Troubleshoot.
|
||||
|
||||
This is an unattended dependency maintenance workflow. Scope: Go
|
||||
dependency updates for the top-level go.mod/go.sum plus examples/sdk/helm-template. After updates
|
||||
are applied, you will review the changes, fix any compatibility issues,
|
||||
validate with make build and make test, and open one grouped PR.
|
||||
|
||||
Do not ask for permission to start. Immediately say exactly [PROCEED]
|
||||
to run the dependency updater. Detailed post-update instructions will
|
||||
follow.
|
||||
|
||||
- id: working
|
||||
label: Working
|
||||
triggers:
|
||||
- message_contains: "[PROCEED]"
|
||||
on_enter:
|
||||
run:
|
||||
command: |
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
nix profile install nixpkgs#go_1_26 || nix-env -iA nixpkgs.go_1_26
|
||||
fi
|
||||
|
||||
go_path="$(command -v go)"
|
||||
go_version="$(go version)"
|
||||
printf "{\"status\":\"ready\",\"go_path\":\"%s\",\"go_version\":\"%s\"}\n" "$go_path" "$go_version"
|
||||
'
|
||||
output: go_toolchain
|
||||
timeout: 10m
|
||||
dependency_updates:
|
||||
ecosystems: [go]
|
||||
paths: ["."]
|
||||
output: dependency_updates
|
||||
timeout: 30m
|
||||
inject: |
|
||||
Dependency updates were applied for the root Go module.
|
||||
|
||||
Scope this workflow to Go dependency updates only. Focus on the
|
||||
top-level go.mod/go.sum. Exclude Node dependencies and any non-Go
|
||||
modules.
|
||||
|
||||
Treat compatibility fixes as part of this workflow. If an updated
|
||||
dependency causes build, lint, type, or test failures, first attempt
|
||||
reasonable scoped code changes within the affected Go module to support
|
||||
the new version. Do not downgrade, pin, or skip an update just because
|
||||
it has breaking API changes unless the required changes are broad,
|
||||
risky, unrelated to this workflow's scope, or cannot be completed within
|
||||
reasonable effort. If you defer an update, explain exactly why.
|
||||
|
||||
Review {{ .Outputs.dependency_updates.files_changed }}.
|
||||
Before opening a PR, validate using the repo make targets in the Nix
|
||||
development environment (nix develop -c). Do not run ad-hoc go
|
||||
commands; use exactly these targets:
|
||||
|
||||
- make build
|
||||
- make test
|
||||
|
||||
Note: make test-integration requires a Kubernetes cluster. Only add it
|
||||
to the validation if the workflow environment provides one.
|
||||
|
||||
Fix any failures and open one grouped PR.
|
||||
Say [DONE] with the PR URL only when the PR is open.
|
||||
If {{ .Outputs.dependency_updates.files_changed }} is empty and no PR is
|
||||
needed, say [NO_CHANGES] instead of [DONE].
|
||||
|
||||
- id: no-changes
|
||||
label: No Changes
|
||||
triggers:
|
||||
- message_contains: "[NO_CHANGES]"
|
||||
- message_contains: "[DONE] No changes"
|
||||
terminal: true
|
||||
|
||||
- id: validate
|
||||
label: Validate
|
||||
triggers:
|
||||
- message_contains: "[DONE]"
|
||||
on_enter:
|
||||
run:
|
||||
command: |
|
||||
nix develop -c bash -lc '
|
||||
set +e
|
||||
tmpdir="$(mktemp -d)"
|
||||
|
||||
make build >"$tmpdir/build.log" 2>&1
|
||||
build_status=$?
|
||||
|
||||
make test >"$tmpdir/test.log" 2>&1
|
||||
test_status=$?
|
||||
|
||||
python3 - "$tmpdir" "$build_status" "$test_status" <<PY
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
tmpdir = pathlib.Path(sys.argv[1])
|
||||
statuses = {
|
||||
"build": int(sys.argv[2]),
|
||||
"test": int(sys.argv[3]),
|
||||
}
|
||||
|
||||
checks = []
|
||||
for name, code in statuses.items():
|
||||
log = (tmpdir / f"{name}.log").read_text(errors="replace")
|
||||
checks.append({
|
||||
"name": name,
|
||||
"status": "passed" if code == 0 else "failed",
|
||||
"exit_code": code,
|
||||
"log_tail": log[-12000:],
|
||||
})
|
||||
|
||||
failed = [check for check in checks if check["exit_code"] != 0]
|
||||
print(json.dumps({
|
||||
"status": "passed" if not failed else "failed",
|
||||
"reason": "all validation checks passed" if not failed else "one or more validation checks failed",
|
||||
"checks": checks,
|
||||
}))
|
||||
PY
|
||||
'
|
||||
output: validation
|
||||
timeout: 45m
|
||||
gate:
|
||||
output: validation
|
||||
pass:
|
||||
path: status
|
||||
values: [passed]
|
||||
fail:
|
||||
path: status
|
||||
values: [failed, error]
|
||||
required: true
|
||||
|
||||
- id: fix-validation
|
||||
label: Fix Validation
|
||||
triggers:
|
||||
- gate_result:
|
||||
stage: validate
|
||||
verdict: fail
|
||||
on_enter:
|
||||
inject: |
|
||||
Validation failed before completion.
|
||||
|
||||
Status: {{ .Outputs.validation.status }}
|
||||
Reason: {{ .Outputs.validation.reason }}
|
||||
|
||||
Checks:
|
||||
{{ .Outputs.validation.checks }}
|
||||
|
||||
Fix the failures above, commit the changes to the same PR branch, and
|
||||
say [DONE] with the PR URL again when the PR is ready for validation.
|
||||
|
||||
- id: complete
|
||||
label: Complete
|
||||
triggers:
|
||||
- gate_result:
|
||||
stage: validate
|
||||
verdict: pass
|
||||
terminal: true
|
||||
@@ -30,8 +30,8 @@ jobs:
|
||||
tidy-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- run: make tidy-diff
|
||||
@@ -39,8 +39,8 @@ jobs:
|
||||
test-integration:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- uses: replicatedhq/action-k3s@main
|
||||
@@ -54,8 +54,8 @@ jobs:
|
||||
compile-preflight:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- run: make generate preflight
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: compile-preflight
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: replicatedhq/action-k3s@main
|
||||
id: k3s
|
||||
with:
|
||||
@@ -84,8 +84,8 @@ jobs:
|
||||
compile-supportbundle:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- run: make generate support-bundle
|
||||
@@ -97,8 +97,8 @@ jobs:
|
||||
compile-collect:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- run: make generate collect
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: compile-supportbundle
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: replicatedhq/action-k3s@main
|
||||
id: k3s
|
||||
with:
|
||||
@@ -127,9 +127,9 @@ jobs:
|
||||
# Additional e2e tests for support bundle that run in Go, these create a Kind cluster
|
||||
validate-supportbundle-e2e-go:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compile-supportbundle, compile-collect]
|
||||
needs: [compile-supportbundle, compile-collect, compile-preflight]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Download support bundle binary
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
support-bundle: ${{ steps.filter.outputs.support-bundle }}
|
||||
examples: ${{ steps.filter.outputs.examples }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
with:
|
||||
@@ -44,8 +44,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
@@ -73,8 +73,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
@@ -93,8 +93,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- run: make build
|
||||
@@ -104,6 +104,134 @@ jobs:
|
||||
path: bin/
|
||||
retention-days: 1
|
||||
|
||||
# Minimal linux architecture smoke tests
|
||||
linux-arch-smoke:
|
||||
if: needs.changes.outputs.go-files == 'true'
|
||||
needs: [changes, lint]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goarch: amd64
|
||||
docker_platform: linux/amd64
|
||||
expected_machine: x86_64
|
||||
- goarch: arm64
|
||||
docker_platform: linux/arm64
|
||||
expected_machine: aarch64
|
||||
- goarch: arm
|
||||
goarm: "7"
|
||||
docker_platform: linux/arm/v7
|
||||
expected_machine: armv7l
|
||||
- goarch: riscv64
|
||||
docker_platform: linux/riscv64
|
||||
expected_machine: riscv64
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- name: Setup K3s
|
||||
uses: replicatedhq/action-k3s@main
|
||||
with:
|
||||
version: v1.31.2-k3s1
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm64,arm,riscv64
|
||||
|
||||
- name: Build linux/${{ matrix.goarch }} binaries
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
GOOS: linux
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
GOARM: ${{ matrix.goarm }}
|
||||
run: |
|
||||
if [ -n "${GOARM}" ]; then
|
||||
echo "Building for GOARCH=${GOARCH} GOARM=${GOARM}"
|
||||
else
|
||||
echo "Building for GOARCH=${GOARCH}"
|
||||
fi
|
||||
make build
|
||||
|
||||
- name: Collect a minimal support bundle on ${{ matrix.docker_platform }}
|
||||
env:
|
||||
PREFLIGHT_AUTO_UPDATE: "false"
|
||||
TROUBLESHOOT_AUTO_UPDATE: "false"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tmpdir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmpdir"' EXIT
|
||||
kubeconfig_path="${KUBECONFIG:-$HOME/.kube/config}"
|
||||
|
||||
assert_tar_member() {
|
||||
local pattern="$1"
|
||||
local label="$2"
|
||||
local member
|
||||
member="$(grep -m1 "$pattern" "$tmpdir/archive-members.txt" || true)"
|
||||
if [ -z "$member" ]; then
|
||||
echo "Expected ${label} was not found in ${archive}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found ${label}: ${member}"
|
||||
}
|
||||
|
||||
echo "Using kubeconfig at ${kubeconfig_path}"
|
||||
test -f "${kubeconfig_path}"
|
||||
cp "${kubeconfig_path}" "$tmpdir/kubeconfig"
|
||||
|
||||
echo "Verifying K3s cluster is reachable on the host"
|
||||
kubectl --kubeconfig "$tmpdir/kubeconfig" get nodes -o wide
|
||||
|
||||
docker run --rm \
|
||||
--platform ${{ matrix.docker_platform }} \
|
||||
--network host \
|
||||
-v "$PWD:/src" \
|
||||
-v "$tmpdir:/tmp/linux-arch-smoke" \
|
||||
-w /src \
|
||||
alpine:3.23 \
|
||||
sh -ec '
|
||||
export PREFLIGHT_AUTO_UPDATE=false
|
||||
export TROUBLESHOOT_AUTO_UPDATE=false
|
||||
/src/bin/support-bundle \
|
||||
--kubeconfig /tmp/linux-arch-smoke/kubeconfig \
|
||||
--interactive=false \
|
||||
/src/test/e2e/support-bundle/spec/linuxArchSmokeLocalHostCollectors.yaml \
|
||||
--output /tmp/linux-arch-smoke/linux-arch-support-bundle.tar.gz
|
||||
'
|
||||
|
||||
archive="$tmpdir/linux-arch-support-bundle.tar.gz"
|
||||
test -f "$archive"
|
||||
echo "Generated support bundle archive: $archive"
|
||||
tar -tf "$archive" > "$tmpdir/archive-members.txt"
|
||||
|
||||
assert_tar_member '/host-collectors/system/cpu.json$' 'cpu.json'
|
||||
assert_tar_member '/host-collectors/system/memory.json$' 'memory.json'
|
||||
assert_tar_member '/host-collectors/run-host/uname.txt$' 'uname.txt'
|
||||
assert_tar_member '/cluster-resources/nodes.json$' 'cluster-resources nodes.json'
|
||||
assert_tar_member '/cluster-resources/resources.json$' 'cluster-resources resources.json'
|
||||
assert_tar_member '/analysis.json$' 'analysis.json'
|
||||
|
||||
echo "Checking uname -m output"
|
||||
uname_path="$(grep -m1 '/host-collectors/run-host/uname.txt$' "$tmpdir/archive-members.txt")"
|
||||
uname_machine="$(tar -xOf "$archive" "$uname_path" | tr -d '\r\n')"
|
||||
if [ "$uname_machine" != '${{ matrix.expected_machine }}' ]; then
|
||||
echo "Unexpected uname -m output: ${uname_machine}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Analyzer input check passed: uname -m reported ${uname_machine}"
|
||||
|
||||
echo "Checking analysis.json"
|
||||
analysis_path="$(grep -m1 '/analysis.json$' "$tmpdir/archive-members.txt")"
|
||||
tar -xOf "$archive" "$analysis_path" > "$tmpdir/analysis.json"
|
||||
jq -e '
|
||||
.[]
|
||||
| select(.insight.primary == "Linux arch smoke node count")
|
||||
| select(.insight.detail == "This cluster has exactly 1 node")
|
||||
| select(.severity == "debug")
|
||||
' "$tmpdir/analysis.json" >/dev/null
|
||||
echo "Found expected analyzer result: Linux arch smoke node count -> This cluster has exactly 1 node"
|
||||
|
||||
# E2E tests
|
||||
e2e:
|
||||
if: needs.changes.outputs.go-files == 'true' || github.event_name == 'push'
|
||||
@@ -124,7 +252,7 @@ jobs:
|
||||
target: support-bundle-e2e-go-test
|
||||
needs-k3s: false
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Setup K3s
|
||||
if: matrix.needs-k3s
|
||||
@@ -143,7 +271,7 @@ jobs:
|
||||
# Success summary
|
||||
success:
|
||||
if: always()
|
||||
needs: [lint, test, build, e2e]
|
||||
needs: [lint, test, build, linux-arch-smoke, e2e]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check results
|
||||
@@ -152,6 +280,7 @@ jobs:
|
||||
if [[ "${{ needs.lint.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.test.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.build.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.linux-arch-smoke.result }}" == "failure" ]] || \
|
||||
[[ "${{ needs.e2e.result }}" == "failure" ]]; then
|
||||
echo "::error::Some jobs failed or were cancelled"
|
||||
exit 1
|
||||
@@ -161,6 +290,7 @@ jobs:
|
||||
if [[ "${{ needs.lint.result }}" == "cancelled" ]] || \
|
||||
[[ "${{ needs.test.result }}" == "cancelled" ]] || \
|
||||
[[ "${{ needs.build.result }}" == "cancelled" ]] || \
|
||||
[[ "${{ needs.linux-arch-smoke.result }}" == "cancelled" ]] || \
|
||||
[[ "${{ needs.e2e.result }}" == "cancelled" ]]; then
|
||||
echo "::error::Some jobs failed or were cancelled"
|
||||
exit 1
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Publish ElasticClaw Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".factory-workflows/dependency-update.yaml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish workflow (${{ matrix.workflow }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
workflow:
|
||||
- .factory-workflows/dependency-update.yaml
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Publish workflow (dependency-update)
|
||||
uses: elasticclaw/actions/publish-workflow@main
|
||||
with:
|
||||
hub-endpoint: https://factory.repldev.com
|
||||
token: ${{ secrets.ELASTICCLAW_TOKEN }}
|
||||
workspace: replicated-troubleshoot
|
||||
path: ${{ matrix.workflow }}
|
||||
@@ -0,0 +1,101 @@
|
||||
name: publish-securebuild
|
||||
|
||||
env:
|
||||
SECUREBUILD_CLI_VERSION: v0.0.498
|
||||
SECUREBUILD_CLI_SHA256: d259aa99d85aef957fe1e40c6f26e1983cc7fd72277e08599a82adc6681d3b37
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: Stable version tag to build (for example, v0.130.0)
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
SECUREBUILD_API_TOKEN:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Stable version tag to build (for example, v0.130.0)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
validate-version:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Require a stable release version
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if ! [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "SecureBuild only supports stable release versions (for example, v0.130.0): $VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-package:
|
||||
needs: validate-version
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Set version
|
||||
id: version
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install SecureBuild CLI
|
||||
run: |
|
||||
curl --fail --silent --show-error --location \
|
||||
"https://github.com/securebuildhq/securebuild/releases/download/${SECUREBUILD_CLI_VERSION}/securebuild-cli-linux-amd64" \
|
||||
--output securebuild
|
||||
echo "${SECUREBUILD_CLI_SHA256} securebuild" | sha256sum --check --strict
|
||||
chmod +x securebuild
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
SECUREBUILD_API_TOKEN: ${{ secrets.SECUREBUILD_API_TOKEN }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
./securebuild build package \
|
||||
--package-family-name troubleshoot \
|
||||
--tag "$VERSION" \
|
||||
--api-token "$SECUREBUILD_API_TOKEN"
|
||||
|
||||
build-image:
|
||||
needs: build-package
|
||||
if: ${{ !cancelled() && needs.build-package.result == 'success' }}
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
image-name:
|
||||
- troubleshoot
|
||||
- preflight
|
||||
steps:
|
||||
- name: Install SecureBuild CLI
|
||||
run: |
|
||||
curl --fail --silent --show-error --location \
|
||||
"https://github.com/securebuildhq/securebuild/releases/download/${SECUREBUILD_CLI_VERSION}/securebuild-cli-linux-amd64" \
|
||||
--output securebuild
|
||||
echo "${SECUREBUILD_CLI_SHA256} securebuild" | sha256sum --check --strict
|
||||
chmod +x securebuild
|
||||
|
||||
- name: Build image
|
||||
env:
|
||||
IMAGE_NAME: ${{ matrix.image-name }}
|
||||
SECUREBUILD_API_TOKEN: ${{ secrets.SECUREBUILD_API_TOKEN }}
|
||||
VERSION: ${{ needs.build-package.outputs.version }}
|
||||
run: |
|
||||
IMAGE_VERSION="${VERSION#v}"
|
||||
MAJOR_MINOR_VERSION="${IMAGE_VERSION%.*}"
|
||||
MAJOR_VERSION="${IMAGE_VERSION%%.*}"
|
||||
|
||||
./securebuild build image \
|
||||
--image-name "$IMAGE_NAME" \
|
||||
--tag "$VERSION" \
|
||||
--image-tag "$MAJOR_MINOR_VERSION" \
|
||||
--image-tag "$MAJOR_VERSION" \
|
||||
--image-tag latest \
|
||||
--api-token "$SECUREBUILD_API_TOKEN"
|
||||
@@ -21,12 +21,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Create output directory
|
||||
run: mkdir -p test/output
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
./bin/preflight version
|
||||
|
||||
- name: Setup Python for comparison
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Create output directory
|
||||
run: mkdir -p test/output
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
./bin/preflight version
|
||||
|
||||
- name: Setup Python for comparison
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -262,7 +262,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Create output directory
|
||||
run: mkdir -p test/output
|
||||
@@ -294,7 +294,7 @@ jobs:
|
||||
./bin/support-bundle version
|
||||
|
||||
- name: Setup Python for comparison
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -361,10 +361,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
|
||||
@@ -14,18 +14,25 @@ jobs:
|
||||
runs-on: troubleshoot_release
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: azure/docker-login@v2
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
go-version: '1.26'
|
||||
check-latest: true
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@v4.1.2
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
cosign-release: 'v3.1.3'
|
||||
|
||||
- name: Generate and sign SBOM
|
||||
run: make sbom
|
||||
env:
|
||||
COSIGN_KEY: ${{ secrets.COSIGN_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
@@ -46,3 +53,19 @@ jobs:
|
||||
uses: rajatjindal/krew-release-bot@v0.0.51
|
||||
with:
|
||||
krew_template_file: deploy/krew/support-bundle.yaml
|
||||
|
||||
securebuild:
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
uses: ./.github/workflows/publish-securebuild.yml
|
||||
with:
|
||||
version: ${{ github.ref_name }}
|
||||
secrets:
|
||||
SECUREBUILD_API_TOKEN: ${{ secrets.SECUREBUILD_API_TOKEN }}
|
||||
|
||||
notify:
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
uses: replicatedhq/reusable-workflows/.github/workflows/notify-release.yml@0432fb838fc83852b78be97a150b35991a85d56f
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
secrets:
|
||||
slack_webhook: ${{ secrets.RELEASE_SLACK_WEBHOOK }}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,3 @@
|
||||
# AGENTS.md
|
||||
|
||||
ALWAYS read and follow the instructions in CLAUDE.md before starting any work in this repository.
|
||||
@@ -0,0 +1,30 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Replicated Troubleshoot is a Kubernetes diagnostic framework providing two kubectl plugins: `preflight` (pre-installation cluster validation) and `support-bundle` (post-installation diagnostics with log collection, redaction, and analysis). Specs use the Kubernetes custom resource format (as a serialization convention, not installed in-cluster) and are defined by application vendors and executed by cluster operators.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
make build # Build bin/support-bundle and bin/preflight
|
||||
make test # Unit tests (includes generate, fmt, vet)
|
||||
make test RUN=TestMyFunction # Run a single test
|
||||
make test-integration # Integration tests (requires k8s cluster)
|
||||
make e2e # All e2e tests
|
||||
make generate # Regenerate types/clients after modifying pkg/apis/
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The core data flow is: **Spec loading → Collection → Redaction → Analysis → Results**. The two main workflows are orchestrated by `pkg/supportbundle/` and `pkg/preflight/`.
|
||||
|
||||
Three API versions coexist in `pkg/apis/troubleshoot/`: v1beta1, v1beta2 (primary, all types defined here), and v1beta3 (in-progress, adds `StringOrValueFrom` for Secret/ConfigMap references, converts to v1beta2 at runtime).
|
||||
|
||||
Collectors live in `pkg/collect/`, analyzers in `pkg/analyze/`. When adding either, follow the pattern of existing implementations and run `make generate` after modifying API types.
|
||||
|
||||
## Code Review
|
||||
|
||||
See [.cursor/BUGBOT.md](.cursor/BUGBOT.md) for the full review checklist covering basic checks (API breaks, pattern violations, security, test coverage) and advanced checks (cross-feature impact, CLI vs SDK consumers, documentation needs).
|
||||
@@ -61,6 +61,9 @@ test: generate fmt vet
|
||||
test-integration: generate fmt vet
|
||||
go test -v --tags="integration exclude_graphdriver_devicemapper exclude_graphdriver_btrfs" ${BUILDPATHS}
|
||||
|
||||
.PHONY: e2e
|
||||
e2e: preflight-e2e-test support-bundle-e2e-test support-bundle-e2e-go-test
|
||||
|
||||
.PHONY: preflight-e2e-test
|
||||
preflight-e2e-test:
|
||||
./test/validate-preflight-e2e.sh
|
||||
@@ -149,17 +152,8 @@ vet:
|
||||
|
||||
.PHONY: generate
|
||||
generate: controller-gen client-gen
|
||||
$(CONTROLLER_GEN) \
|
||||
object:headerFile=./hack/boilerplate.go.txt paths=./pkg/apis/...
|
||||
$(CLIENT_GEN) \
|
||||
--output-dir=. \
|
||||
--output-pkg=github.com/replicatedhq/troubleshoot/pkg/client \
|
||||
--clientset-name troubleshootclientset \
|
||||
--input-base github.com/replicatedhq/troubleshoot/pkg/apis \
|
||||
--input troubleshoot/v1beta1 \
|
||||
--input troubleshoot/v1beta2 \
|
||||
--input troubleshoot/v1beta3 \
|
||||
--go-header-file ./hack/boilerplate.go.txt
|
||||
$(CONTROLLER_GEN) object:headerFile=./hack/boilerplate.go.txt paths=./pkg/apis/...
|
||||
$(CLIENT_GEN) --output-dir=. --output-pkg=github.com/replicatedhq/troubleshoot/pkg/client --clientset-name troubleshootclientset --input-base github.com/replicatedhq/troubleshoot/pkg/apis --input troubleshoot/v1beta1 --input troubleshoot/v1beta2 --input troubleshoot/v1beta3 --go-header-file ./hack/boilerplate.go.txt
|
||||
cp -r troubleshootclientset pkg/client
|
||||
rm -rf troubleshootclientset
|
||||
|
||||
@@ -191,32 +185,18 @@ bin/docsgen:
|
||||
|
||||
controller-gen:
|
||||
go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.19.0
|
||||
CONTROLLER_GEN=$(shell which controller-gen)
|
||||
CONTROLLER_GEN=$(shell go env GOPATH)/bin/controller-gen
|
||||
|
||||
.PHONY: client-gen
|
||||
client-gen:
|
||||
go install k8s.io/code-generator/cmd/client-gen@v0.34.0
|
||||
CLIENT_GEN=$(shell which client-gen)
|
||||
CLIENT_GEN=$(shell go env GOPATH)/bin/client-gen
|
||||
|
||||
.PHONY: release
|
||||
release: export GITHUB_TOKEN = $(shell echo ${GITHUB_TOKEN_TROUBLESHOOT})
|
||||
release:
|
||||
curl -sL https://git.io/goreleaser | bash -s -- --rm-dist --config deploy/.goreleaser.yml
|
||||
|
||||
.PHONY: snapshot-release
|
||||
snapshot-release:
|
||||
curl -sL https://git.io/goreleaser | bash -s -- --rm-dist --snapshot --config deploy/.goreleaser.snapshot.yml
|
||||
docker push replicated/troubleshoot:alpha
|
||||
docker push replicated/preflight:alpha
|
||||
|
||||
.PHONY: local-release
|
||||
local-release:
|
||||
curl -sL https://git.io/goreleaser | bash -s -- --rm-dist --snapshot --config deploy/.goreleaser.yaml
|
||||
docker tag replicated/troubleshoot:alpha localhost:32000/troubleshoot:alpha
|
||||
docker tag replicated/preflight:alpha localhost:32000/preflight:alpha
|
||||
docker push localhost:32000/troubleshoot:alpha
|
||||
docker push localhost:32000/preflight:alpha
|
||||
|
||||
.PHONY: run-preflight
|
||||
run-preflight: bin/preflight
|
||||
./bin/preflight ./examples/preflight/sample-preflight.yaml
|
||||
@@ -252,7 +232,8 @@ sbom: sbom/assets/troubleshoot-sbom.tgz
|
||||
--tlog-upload \
|
||||
--yes \
|
||||
--rekor-url=https://rekor.sigstore.dev \
|
||||
sbom/assets/troubleshoot-sbom.tgz > sbom/assets/troubleshoot-sbom.tgz.sig
|
||||
--bundle sbom/assets/troubleshoot-sbom.tgz.bundle \
|
||||
sbom/assets/troubleshoot-sbom.tgz
|
||||
cosign public-key --key cosign.key --outfile sbom/assets/key.pub
|
||||
|
||||
.PHONY: get-govulncheck
|
||||
|
||||
@@ -56,19 +56,12 @@ For questions about using Troubleshoot, how to contribute and engaging with the
|
||||
# Software Bill of Materials
|
||||
A signed SBOM that includes Troubleshoot dependencies is included in each release.
|
||||
- **troubleshoot-sbom.tgz** contains a software bill of materials for Troubleshoot.
|
||||
- **troubleshoot-sbom.tgz.sig** is the digital signature for troubleshoot-sbom.tgz
|
||||
- **troubleshoot-sbom.tgz.bundle** contains the signature and transparency log material used by Cosign.
|
||||
- **key.pub** is the public key from the key pair used to sign troubleshoot-sbom.tgz
|
||||
|
||||
The following example illustrates using [cosign](https://github.com/sigstore/cosign) to verify that **troubleshoot-sbom.tgz** has
|
||||
not been tampered with.
|
||||
not been tampered with. Install [Cosign v3](https://github.com/sigstore/cosign/releases).
|
||||
```sh
|
||||
$ cosign verify-blob --key key.pub --signature troubleshoot-sbom.tgz.sig troubleshoot-sbom.tgz
|
||||
Verified OK
|
||||
```
|
||||
|
||||
If you were to get an error similar to the one below, it means you are verifying an SBOM signed using cosign `v1` using a newer `v2` of the binary. This version introduced [breaking changes](https://github.com/sigstore/cosign/blob/main/CHANGELOG.md#breaking-changes) which require an additional flag `--insecure-ignore-tlog=true` to successfully verify SBOMs like so.
|
||||
```sh
|
||||
$ cosign verify-blob --key key.pub --signature troubleshoot-sbom.tgz.sig troubleshoot-sbom.tgz --insecure-ignore-tlog=true
|
||||
WARNING: Skipping tlog verification is an insecure practice that lacks of transparency and auditability verification for the blob.
|
||||
$ cosign verify-blob --key key.pub --bundle troubleshoot-sbom.tgz.bundle troubleshoot-sbom.tgz
|
||||
Verified OK
|
||||
```
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/collect"
|
||||
"github.com/spf13/cobra"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
// ClickhouseCmd runs the clickhouse collector against a single database and
|
||||
// prints the native result JSON ({"isConnected":..,"version":..,"error":..}).
|
||||
func ClickhouseCmd() *cobra.Command {
|
||||
var (
|
||||
uri string
|
||||
skipVerify bool
|
||||
caCert string
|
||||
clientCert string
|
||||
clientKey string
|
||||
secretName string
|
||||
secretNamespace string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "clickhouse",
|
||||
Short: "Run the clickhouse collector against a database",
|
||||
Long: `Run the clickhouse collector: connect to a ClickHouse server, run "SELECT version()",
|
||||
and print the collector result as JSON.
|
||||
|
||||
--uri is a clickhouse-go DSN:
|
||||
clickhouse://user:password@host:9000/dbname
|
||||
|
||||
TLS material may be provided inline / by file path (--tls-cacert, --tls-client-cert,
|
||||
--tls-client-key) or sourced from a Secret (--tls-secret-name), which requires
|
||||
cluster access.
|
||||
|
||||
Example:
|
||||
collect clickhouse --uri "clickhouse://default:@ch.default.svc:9000/default"`,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
db := &troubleshootv1beta2.Database{
|
||||
CollectorMeta: troubleshootv1beta2.CollectorMeta{CollectorName: "clickhouse"},
|
||||
URI: uri,
|
||||
}
|
||||
|
||||
var (
|
||||
client kubernetes.Interface
|
||||
cfg *rest.Config
|
||||
)
|
||||
if skipVerify || caCert != "" || clientCert != "" || clientKey != "" || secretName != "" {
|
||||
tls := &troubleshootv1beta2.TLSParams{
|
||||
SkipVerify: skipVerify,
|
||||
CACert: caCert,
|
||||
ClientCert: clientCert,
|
||||
ClientKey: clientKey,
|
||||
}
|
||||
if secretName != "" {
|
||||
tls.Secret = &troubleshootv1beta2.TLSSecret{Name: secretName, Namespace: secretNamespace}
|
||||
var err error
|
||||
client, cfg, err = k8sClientForCollectors()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
db.TLS = tls
|
||||
}
|
||||
|
||||
c := &collect.CollectClickhouse{Collector: db, ClientConfig: cfg, Client: client, Context: cmd.Context()}
|
||||
res, err := c.Collect(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printCollectorResult(res)
|
||||
},
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&uri, "uri", "", "ClickHouse connection DSN, e.g. clickhouse://user:pass@host:9000/db (required)")
|
||||
cmd.MarkFlagRequired("uri")
|
||||
f.BoolVar(&skipVerify, "tls-skip-verify", false, "skip TLS certificate verification")
|
||||
f.StringVar(&caCert, "tls-cacert", "", "CA certificate (PEM contents or a file path)")
|
||||
f.StringVar(&clientCert, "tls-client-cert", "", "client certificate (PEM contents or a file path)")
|
||||
f.StringVar(&clientKey, "tls-client-key", "", "client key (PEM contents or a file path)")
|
||||
f.StringVar(&secretName, "tls-secret-name", "", "name of a Secret holding TLS material (requires cluster access)")
|
||||
f.StringVar(&secretNamespace, "tls-secret-namespace", "default", "namespace of the TLS Secret")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Package cli implements the `collect` command and its subcommands.
|
||||
//
|
||||
// The per-collector subcommands (http, postgres, mysql, mssql, redis) run a
|
||||
// single collector and print its native result JSON to stdout. This lets a
|
||||
// collector run inside a Pod using the troubleshoot image — e.g. as a runPod
|
||||
// collector — so the check executes from within the cluster instead of from
|
||||
// wherever the CLI happens to be invoked.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/replicatedhq/troubleshoot/pkg/collect"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/constants"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/version"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
// printCollectorResult writes the native collector result JSON to stdout.
|
||||
// The single-collector subcommands run with an empty BundlePath, so the result
|
||||
// is held in memory (map value = the JSON bytes) rather than written to disk.
|
||||
func printCollectorResult(res collect.CollectorResult) error {
|
||||
for _, b := range res {
|
||||
fmt.Println(string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// k8sClientForCollectors builds a Kubernetes client from the ambient kubeconfig
|
||||
// or in-cluster config. Only the collectors that resolve TLS material from a
|
||||
// Secret need this; plain connections and inline/file TLS do not.
|
||||
func k8sClientForCollectors() (kubernetes.Interface, *rest.Config, error) {
|
||||
cfg, err := k8sutil.GetRESTConfig()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cfg.QPS = constants.DEFAULT_CLIENT_QPS
|
||||
cfg.Burst = constants.DEFAULT_CLIENT_BURST
|
||||
cfg.UserAgent = fmt.Sprintf("%s/%s", constants.DEFAULT_CLIENT_USER_AGENT, version.Version())
|
||||
client, err := kubernetes.NewForConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return client, cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/collect"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// HTTPCmd runs the http collector against a single endpoint and prints the
|
||||
// native result JSON ({"response":{"status":...}} or {"error":{...}}).
|
||||
func HTTPCmd() *cobra.Command {
|
||||
var (
|
||||
method string
|
||||
url string
|
||||
headers map[string]string
|
||||
body string
|
||||
timeout string
|
||||
proxy string
|
||||
insecure bool
|
||||
caCert string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "http",
|
||||
Short: "Run the http collector against an endpoint",
|
||||
Long: `Run the http collector: issue an HTTP request and print the collector result as JSON.
|
||||
|
||||
The result contains the response status, body and headers, or an error object.
|
||||
|
||||
Examples:
|
||||
collect http --url http://myapp.default.svc:8080/healthz
|
||||
collect http --method POST --url https://api.internal/ping \
|
||||
--header 'Content-Type=application/json' --body '{"ping":true}'`,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var tls *troubleshootv1beta2.TLSParams
|
||||
if caCert != "" {
|
||||
tls = &troubleshootv1beta2.TLSParams{CACert: caCert}
|
||||
}
|
||||
|
||||
spec := &troubleshootv1beta2.HTTP{
|
||||
CollectorMeta: troubleshootv1beta2.CollectorMeta{CollectorName: "http"},
|
||||
}
|
||||
switch strings.ToUpper(method) {
|
||||
case "GET":
|
||||
spec.Get = &troubleshootv1beta2.Get{URL: url, Headers: headers, Timeout: timeout, Proxy: proxy, InsecureSkipVerify: insecure, TLS: tls}
|
||||
case "POST":
|
||||
spec.Post = &troubleshootv1beta2.Post{URL: url, Headers: headers, Body: body, Timeout: timeout, Proxy: proxy, InsecureSkipVerify: insecure, TLS: tls}
|
||||
case "PUT":
|
||||
spec.Put = &troubleshootv1beta2.Put{URL: url, Headers: headers, Body: body, Timeout: timeout, Proxy: proxy, InsecureSkipVerify: insecure, TLS: tls}
|
||||
default:
|
||||
return fmt.Errorf("unsupported --method %q (use GET, POST, or PUT)", method)
|
||||
}
|
||||
|
||||
c := &collect.CollectHTTP{Collector: spec}
|
||||
res, err := c.Collect(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printCollectorResult(res)
|
||||
},
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&url, "url", "", "request URL (required)")
|
||||
cmd.MarkFlagRequired("url")
|
||||
f.StringVar(&method, "method", "GET", "HTTP method: GET, POST, or PUT")
|
||||
f.StringToStringVar(&headers, "header", nil, "request header as key=value (repeatable)")
|
||||
f.StringVar(&body, "body", "", "request body (POST/PUT only)")
|
||||
f.StringVar(&timeout, "timeout", "", "request timeout, e.g. 15s (empty = no timeout)")
|
||||
f.StringVar(&proxy, "proxy", "", "proxy URL to use for the request")
|
||||
f.BoolVar(&insecure, "insecure-skip-verify", false, "do not verify the server's TLS certificate")
|
||||
f.StringVar(&caCert, "tls-cacert", "", "CA certificate to trust (PEM contents or a file path)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/collect"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// MssqlCmd runs the mssql collector against a single database and prints the
|
||||
// native result JSON ({"isConnected":..,"version":..,"error":..}).
|
||||
//
|
||||
// Unlike the other database collectors, the mssql collector does not honor a
|
||||
// separate TLS field — TLS is configured through the connection URI query
|
||||
// parameters (e.g. encrypt=true) — so this subcommand only takes --uri.
|
||||
func MssqlCmd() *cobra.Command {
|
||||
var uri string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "mssql",
|
||||
Short: "Run the mssql collector against a database",
|
||||
Long: `Run the mssql collector: connect to a Microsoft SQL Server, run "select @@VERSION",
|
||||
and print the collector result as JSON.
|
||||
|
||||
--uri is a go-mssqldb connection URL; TLS options are passed as query parameters:
|
||||
sqlserver://user:password@host:1433?database=app&encrypt=true
|
||||
|
||||
Example:
|
||||
collect mssql --uri "sqlserver://sa:pass@mssql.default.svc:1433?encrypt=true"`,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
db := &troubleshootv1beta2.Database{
|
||||
CollectorMeta: troubleshootv1beta2.CollectorMeta{CollectorName: "mssql"},
|
||||
URI: uri,
|
||||
}
|
||||
|
||||
c := &collect.CollectMssql{Collector: db, Context: cmd.Context()}
|
||||
res, err := c.Collect(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printCollectorResult(res)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&uri, "uri", "", "SQL Server connection URI, e.g. sqlserver://user:pass@host:1433 (required)")
|
||||
cmd.MarkFlagRequired("uri")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/collect"
|
||||
"github.com/spf13/cobra"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
// MysqlCmd runs the mysql collector against a single database and prints the
|
||||
// native result JSON ({"isConnected":..,"version":..,"variables":..,"error":..}).
|
||||
func MysqlCmd() *cobra.Command {
|
||||
var (
|
||||
uri string
|
||||
parameters []string
|
||||
skipVerify bool
|
||||
caCert string
|
||||
clientCert string
|
||||
clientKey string
|
||||
secretName string
|
||||
secretNamespace string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "mysql",
|
||||
Short: "Run the mysql collector against a database",
|
||||
Long: `Run the mysql collector: connect to a MySQL server, run "select version()",
|
||||
optionally collect server variables, and print the collector result as JSON.
|
||||
|
||||
--uri is a go-sql-driver DSN (note: not a URL):
|
||||
user:password@tcp(host:3306)/dbname
|
||||
|
||||
--parameters names server variables to collect via "SHOW VARIABLES"; the matching
|
||||
values are returned under "variables" in the result. This flag is unique to mysql.
|
||||
|
||||
Example:
|
||||
collect mysql --uri "root:pass@tcp(mysql.default.svc:3306)/" --parameters max_connections,version`,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
db := &troubleshootv1beta2.Database{
|
||||
CollectorMeta: troubleshootv1beta2.CollectorMeta{CollectorName: "mysql"},
|
||||
URI: uri,
|
||||
Parameters: parameters,
|
||||
}
|
||||
|
||||
var (
|
||||
client kubernetes.Interface
|
||||
cfg *rest.Config
|
||||
)
|
||||
if skipVerify || caCert != "" || clientCert != "" || clientKey != "" || secretName != "" {
|
||||
tls := &troubleshootv1beta2.TLSParams{
|
||||
SkipVerify: skipVerify,
|
||||
CACert: caCert,
|
||||
ClientCert: clientCert,
|
||||
ClientKey: clientKey,
|
||||
}
|
||||
if secretName != "" {
|
||||
tls.Secret = &troubleshootv1beta2.TLSSecret{Name: secretName, Namespace: secretNamespace}
|
||||
var err error
|
||||
client, cfg, err = k8sClientForCollectors()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
db.TLS = tls
|
||||
}
|
||||
|
||||
c := &collect.CollectMysql{Collector: db, ClientConfig: cfg, Client: client, Context: cmd.Context()}
|
||||
res, err := c.Collect(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printCollectorResult(res)
|
||||
},
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&uri, "uri", "", "MySQL connection DSN, e.g. user:pass@tcp(host:3306)/db (required)")
|
||||
cmd.MarkFlagRequired("uri")
|
||||
f.StringSliceVar(¶meters, "parameters", nil, "server variables to collect via SHOW VARIABLES (comma-separated or repeatable)")
|
||||
f.BoolVar(&skipVerify, "tls-skip-verify", false, "skip TLS certificate verification")
|
||||
f.StringVar(&caCert, "tls-cacert", "", "CA certificate (PEM contents or a file path)")
|
||||
f.StringVar(&clientCert, "tls-client-cert", "", "client certificate (PEM contents or a file path)")
|
||||
f.StringVar(&clientKey, "tls-client-key", "", "client key (PEM contents or a file path)")
|
||||
f.StringVar(&secretName, "tls-secret-name", "", "name of a Secret holding TLS material (requires cluster access)")
|
||||
f.StringVar(&secretNamespace, "tls-secret-namespace", "default", "namespace of the TLS Secret")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/collect"
|
||||
"github.com/spf13/cobra"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
// PostgresCmd runs the postgres collector against a single database and prints
|
||||
// the native result JSON ({"isConnected":..,"version":..,"error":..}).
|
||||
func PostgresCmd() *cobra.Command {
|
||||
var (
|
||||
uri string
|
||||
skipVerify bool
|
||||
caCert string
|
||||
clientCert string
|
||||
clientKey string
|
||||
secretName string
|
||||
secretNamespace string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "postgres",
|
||||
Short: "Run the postgres collector against a database",
|
||||
Long: `Run the postgres collector: connect to a PostgreSQL server, run "select version()",
|
||||
and print the collector result as JSON.
|
||||
|
||||
--uri is a libpq/pgx connection URI:
|
||||
postgres://user:password@host:5432/dbname?sslmode=disable
|
||||
|
||||
TLS material may be provided inline / by file path (--tls-cacert, --tls-client-cert,
|
||||
--tls-client-key) or sourced from a Secret (--tls-secret-name), which requires
|
||||
cluster access.
|
||||
|
||||
Example:
|
||||
collect postgres --uri "postgres://user:pass@pg.default.svc:5432/app?sslmode=disable"`,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
db := &troubleshootv1beta2.Database{
|
||||
CollectorMeta: troubleshootv1beta2.CollectorMeta{CollectorName: "postgres"},
|
||||
URI: uri,
|
||||
}
|
||||
|
||||
var (
|
||||
client kubernetes.Interface
|
||||
cfg *rest.Config
|
||||
)
|
||||
if skipVerify || caCert != "" || clientCert != "" || clientKey != "" || secretName != "" {
|
||||
tls := &troubleshootv1beta2.TLSParams{
|
||||
SkipVerify: skipVerify,
|
||||
CACert: caCert,
|
||||
ClientCert: clientCert,
|
||||
ClientKey: clientKey,
|
||||
}
|
||||
if secretName != "" {
|
||||
tls.Secret = &troubleshootv1beta2.TLSSecret{Name: secretName, Namespace: secretNamespace}
|
||||
var err error
|
||||
client, cfg, err = k8sClientForCollectors()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
db.TLS = tls
|
||||
}
|
||||
|
||||
c := &collect.CollectPostgres{Collector: db, ClientConfig: cfg, Client: client, Context: cmd.Context()}
|
||||
res, err := c.Collect(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printCollectorResult(res)
|
||||
},
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&uri, "uri", "", "PostgreSQL connection URI (required)")
|
||||
cmd.MarkFlagRequired("uri")
|
||||
f.BoolVar(&skipVerify, "tls-skip-verify", false, "skip TLS certificate verification")
|
||||
f.StringVar(&caCert, "tls-cacert", "", "CA certificate (PEM contents or a file path)")
|
||||
f.StringVar(&clientCert, "tls-client-cert", "", "client certificate (PEM contents or a file path)")
|
||||
f.StringVar(&clientKey, "tls-client-key", "", "client key (PEM contents or a file path)")
|
||||
f.StringVar(&secretName, "tls-secret-name", "", "name of a Secret holding TLS material (requires cluster access)")
|
||||
f.StringVar(&secretNamespace, "tls-secret-namespace", "default", "namespace of the TLS Secret")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/collect"
|
||||
"github.com/spf13/cobra"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
// RedisCmd runs the redis collector against a single instance and prints the
|
||||
// native result JSON ({"isConnected":..,"version":..,"error":..}).
|
||||
func RedisCmd() *cobra.Command {
|
||||
var (
|
||||
uri string
|
||||
skipVerify bool
|
||||
caCert string
|
||||
clientCert string
|
||||
clientKey string
|
||||
secretName string
|
||||
secretNamespace string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "redis",
|
||||
Short: "Run the redis collector against an instance",
|
||||
Long: `Run the redis collector: connect to a Redis server, read "INFO server" (server
|
||||
version), and print the collector result as JSON.
|
||||
|
||||
--uri is a go-redis connection URL:
|
||||
redis://host:6379 (plaintext)
|
||||
rediss://user:pass@host:6379 (TLS)
|
||||
|
||||
TLS material may be provided inline / by file path (--tls-cacert, --tls-client-cert,
|
||||
--tls-client-key) or sourced from a Secret (--tls-secret-name), which requires
|
||||
cluster access.
|
||||
|
||||
Example:
|
||||
collect redis --uri "redis://redis.default.svc:6379"`,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
db := &troubleshootv1beta2.Database{
|
||||
CollectorMeta: troubleshootv1beta2.CollectorMeta{CollectorName: "redis"},
|
||||
URI: uri,
|
||||
}
|
||||
|
||||
var (
|
||||
client kubernetes.Interface
|
||||
cfg *rest.Config
|
||||
)
|
||||
if skipVerify || caCert != "" || clientCert != "" || clientKey != "" || secretName != "" {
|
||||
tls := &troubleshootv1beta2.TLSParams{
|
||||
SkipVerify: skipVerify,
|
||||
CACert: caCert,
|
||||
ClientCert: clientCert,
|
||||
ClientKey: clientKey,
|
||||
}
|
||||
if secretName != "" {
|
||||
tls.Secret = &troubleshootv1beta2.TLSSecret{Name: secretName, Namespace: secretNamespace}
|
||||
var err error
|
||||
client, cfg, err = k8sClientForCollectors()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
db.TLS = tls
|
||||
}
|
||||
|
||||
c := &collect.CollectRedis{Collector: db, ClientConfig: cfg, Client: client, Context: cmd.Context()}
|
||||
res, err := c.Collect(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printCollectorResult(res)
|
||||
},
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&uri, "uri", "", "Redis connection URL, e.g. redis://host:6379 (required)")
|
||||
cmd.MarkFlagRequired("uri")
|
||||
f.BoolVar(&skipVerify, "tls-skip-verify", false, "skip TLS certificate verification")
|
||||
f.StringVar(&caCert, "tls-cacert", "", "CA certificate (PEM contents or a file path)")
|
||||
f.StringVar(&clientCert, "tls-client-cert", "", "client certificate (PEM contents or a file path)")
|
||||
f.StringVar(&clientKey, "tls-client-key", "", "client key (PEM contents or a file path)")
|
||||
f.StringVar(&secretName, "tls-secret-name", "", "name of a Secret holding TLS material (requires cluster access)")
|
||||
f.StringVar(&secretNamespace, "tls-secret-namespace", "default", "namespace of the TLS Secret")
|
||||
|
||||
return cmd
|
||||
}
|
||||
+17
-4
@@ -19,7 +19,9 @@ func RootCmd() *cobra.Command {
|
||||
Short: "Run a collector",
|
||||
Long: `Run a collector and output the results.`,
|
||||
SilenceUsage: true,
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
// PersistentPreRun/PersistentPostRun (rather than PreRun/PostRun) so this
|
||||
// setup also runs for the per-collector subcommands, not just `collect [url]`.
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
v := viper.GetViper()
|
||||
v.BindPFlags(cmd.Flags())
|
||||
|
||||
@@ -38,7 +40,7 @@ func RootCmd() *cobra.Command {
|
||||
|
||||
return runCollect(v, args[0])
|
||||
},
|
||||
PostRun: func(cmd *cobra.Command, args []string) {
|
||||
PersistentPostRun: func(cmd *cobra.Command, args []string) {
|
||||
if err := util.StopProfiling(); err != nil {
|
||||
klog.Errorf("Failed to stop profiling: %v", err)
|
||||
}
|
||||
@@ -49,6 +51,17 @@ func RootCmd() *cobra.Command {
|
||||
|
||||
cmd.AddCommand(util.VersionCmd())
|
||||
|
||||
// Per-collector subcommands: run a single collector and print its native
|
||||
// result JSON. Each has its own flags and help. These let a collector run
|
||||
// inside a Pod via the troubleshoot image (e.g. as a runPod collector), so
|
||||
// the check executes from within the cluster rather than wherever the CLI runs.
|
||||
cmd.AddCommand(HTTPCmd())
|
||||
cmd.AddCommand(PostgresCmd())
|
||||
cmd.AddCommand(MysqlCmd())
|
||||
cmd.AddCommand(MssqlCmd())
|
||||
cmd.AddCommand(RedisCmd())
|
||||
cmd.AddCommand(ClickhouseCmd())
|
||||
|
||||
cmd.Flags().StringSlice("redactors", []string{}, "names of the additional redactors to use")
|
||||
cmd.Flags().Bool("redact", true, "enable/disable default redactions")
|
||||
cmd.Flags().String("format", "json", "output format, one of json or raw.")
|
||||
@@ -56,7 +69,7 @@ func RootCmd() *cobra.Command {
|
||||
cmd.Flags().String("collector-pull-policy", "", "the pull policy of the collector image")
|
||||
cmd.Flags().String("selector", "", "selector (label query) to filter remote collection nodes on.")
|
||||
cmd.Flags().Bool("collect-without-permissions", false, "always generate a support bundle, even if it some require additional permissions")
|
||||
cmd.Flags().Bool("debug", false, "enable debug logging")
|
||||
cmd.PersistentFlags().Bool("debug", false, "enable debug logging")
|
||||
cmd.Flags().String("chroot", "", "Chroot to path")
|
||||
|
||||
// hidden in favor of the `insecure-skip-tls-verify` flag
|
||||
@@ -67,7 +80,7 @@ func RootCmd() *cobra.Command {
|
||||
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
|
||||
|
||||
k8sutil.AddFlags(cmd.Flags())
|
||||
k8sutil.AddFlags(cmd.PersistentFlags())
|
||||
|
||||
// Initialize klog flags
|
||||
logger.InitKlogFlags(cmd)
|
||||
|
||||
@@ -25,7 +25,13 @@ func RootCmd() *cobra.Command {
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Short: "Run and retrieve preflight checks in a cluster",
|
||||
Long: `A preflight check is a set of validations that can and should be run to ensure
|
||||
that a cluster meets the requirements to run an application.`,
|
||||
that a cluster meets the requirements to run an application.
|
||||
|
||||
Unlike support-bundle, preflight does not support --load-cluster-specs because
|
||||
preflight checks are designed to run before an application is installed or
|
||||
upgraded. Since no deployment has occurred yet, there are no in-cluster specs
|
||||
to discover. Preflight specs must be provided via a URL, local file path, or
|
||||
stdin (e.g. "helm template ... | kubectl preflight -").`,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
PreRun: func(cmd *cobra.Command, args []string) {
|
||||
|
||||
@@ -133,7 +133,7 @@ If no arguments are provided, specs are automatically loaded from the cluster by
|
||||
cmd.Flags().String("since-time", "", "force pod logs collectors to return logs after a specific date (RFC3339)")
|
||||
cmd.Flags().String("since", "", "force pod logs collectors to return logs newer than a relative duration like 5s, 2m, or 3h.")
|
||||
cmd.Flags().Int("remote-host-collect-timeout", 30, "timeout in seconds for remote host collect operations (e.g. waiting for pods/daemonsets)")
|
||||
cmd.Flags().StringP("output", "o", "", "specify the output file path for the support bundle")
|
||||
cmd.Flags().StringP("output", "o", "", "specify the output file path for the support bundle (.tar.gz extension is added automatically)")
|
||||
cmd.Flags().Bool("debug", false, "enable debug logging. This is equivalent to --v=0")
|
||||
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")
|
||||
|
||||
@@ -166,7 +166,18 @@ func runTroubleshoot(v *viper.Viper, args []string) error {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for msg := range progressChan {
|
||||
klog.Infof("Collecting support bundle: %v", msg)
|
||||
switch msg := msg.(type) {
|
||||
case error:
|
||||
klog.Warningf("Collecting support bundle: %v", msg)
|
||||
case string:
|
||||
if strings.Contains(msg, "skipping collector") {
|
||||
klog.Warningf("Collecting support bundle: %s", msg)
|
||||
} else {
|
||||
klog.Infof("Collecting support bundle: %s", msg)
|
||||
}
|
||||
default:
|
||||
klog.Infof("Collecting support bundle: %v", msg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
|
||||
@@ -1360,6 +1360,8 @@ spec:
|
||||
- key
|
||||
type: object
|
||||
type: object
|
||||
ignoreIfNoFiles:
|
||||
type: boolean
|
||||
outcomes:
|
||||
items:
|
||||
properties:
|
||||
@@ -1612,6 +1614,58 @@ spec:
|
||||
- outcomes
|
||||
- selector
|
||||
type: object
|
||||
s3Status:
|
||||
properties:
|
||||
annotations:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
checkName:
|
||||
type: string
|
||||
collectorName:
|
||||
type: string
|
||||
exclude:
|
||||
type: BoolString
|
||||
fileName:
|
||||
type: string
|
||||
outcomes:
|
||||
items:
|
||||
properties:
|
||||
fail:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
pass:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
warn:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
type: array
|
||||
strict:
|
||||
type: BoolString
|
||||
required:
|
||||
- collectorName
|
||||
- outcomes
|
||||
type: object
|
||||
secret:
|
||||
properties:
|
||||
annotations:
|
||||
@@ -2783,6 +2837,55 @@ spec:
|
||||
required:
|
||||
- outcomes
|
||||
type: object
|
||||
registryImages:
|
||||
properties:
|
||||
annotations:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
checkName:
|
||||
type: string
|
||||
collectorName:
|
||||
type: string
|
||||
exclude:
|
||||
type: BoolString
|
||||
outcomes:
|
||||
items:
|
||||
properties:
|
||||
fail:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
pass:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
warn:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
type: array
|
||||
strict:
|
||||
type: BoolString
|
||||
required:
|
||||
- outcomes
|
||||
type: object
|
||||
subnetAvailable:
|
||||
properties:
|
||||
annotations:
|
||||
|
||||
@@ -17969,6 +17969,29 @@ spec:
|
||||
required:
|
||||
- namespace
|
||||
type: object
|
||||
s3Status:
|
||||
properties:
|
||||
accessKeyID:
|
||||
type: string
|
||||
bucketName:
|
||||
type: string
|
||||
collectorName:
|
||||
type: string
|
||||
endpoint:
|
||||
type: string
|
||||
exclude:
|
||||
type: BoolString
|
||||
insecure:
|
||||
type: boolean
|
||||
region:
|
||||
type: string
|
||||
secretAccessKey:
|
||||
type: string
|
||||
usePathStyle:
|
||||
type: boolean
|
||||
required:
|
||||
- bucketName
|
||||
type: object
|
||||
secret:
|
||||
properties:
|
||||
collectorName:
|
||||
@@ -18462,6 +18485,27 @@ spec:
|
||||
- port
|
||||
- toCIDR
|
||||
type: object
|
||||
registryImages:
|
||||
description: |-
|
||||
HostRegistryImages checks whether images are accessible from the host,
|
||||
without requiring a Kubernetes cluster. Auth can be supplied inline via
|
||||
Username/Password or omitted to rely on ambient credentials (e.g. ~/.docker/config.json).
|
||||
properties:
|
||||
collectorName:
|
||||
type: string
|
||||
exclude:
|
||||
type: BoolString
|
||||
images:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
password:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
- images
|
||||
type: object
|
||||
run:
|
||||
properties:
|
||||
args:
|
||||
|
||||
@@ -855,6 +855,55 @@ spec:
|
||||
required:
|
||||
- outcomes
|
||||
type: object
|
||||
registryImages:
|
||||
properties:
|
||||
annotations:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
checkName:
|
||||
type: string
|
||||
collectorName:
|
||||
type: string
|
||||
exclude:
|
||||
type: BoolString
|
||||
outcomes:
|
||||
items:
|
||||
properties:
|
||||
fail:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
pass:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
warn:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
type: array
|
||||
strict:
|
||||
type: BoolString
|
||||
required:
|
||||
- outcomes
|
||||
type: object
|
||||
subnetAvailable:
|
||||
properties:
|
||||
annotations:
|
||||
@@ -1784,6 +1833,27 @@ spec:
|
||||
- port
|
||||
- toCIDR
|
||||
type: object
|
||||
registryImages:
|
||||
description: |-
|
||||
HostRegistryImages checks whether images are accessible from the host,
|
||||
without requiring a Kubernetes cluster. Auth can be supplied inline via
|
||||
Username/Password or omitted to rely on ambient credentials (e.g. ~/.docker/config.json).
|
||||
properties:
|
||||
collectorName:
|
||||
type: string
|
||||
exclude:
|
||||
type: BoolString
|
||||
images:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
password:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
- images
|
||||
type: object
|
||||
run:
|
||||
properties:
|
||||
args:
|
||||
|
||||
@@ -855,6 +855,55 @@ spec:
|
||||
required:
|
||||
- outcomes
|
||||
type: object
|
||||
registryImages:
|
||||
properties:
|
||||
annotations:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
checkName:
|
||||
type: string
|
||||
collectorName:
|
||||
type: string
|
||||
exclude:
|
||||
type: BoolString
|
||||
outcomes:
|
||||
items:
|
||||
properties:
|
||||
fail:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
pass:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
warn:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
type: array
|
||||
strict:
|
||||
type: BoolString
|
||||
required:
|
||||
- outcomes
|
||||
type: object
|
||||
subnetAvailable:
|
||||
properties:
|
||||
annotations:
|
||||
@@ -1784,6 +1833,27 @@ spec:
|
||||
- port
|
||||
- toCIDR
|
||||
type: object
|
||||
registryImages:
|
||||
description: |-
|
||||
HostRegistryImages checks whether images are accessible from the host,
|
||||
without requiring a Kubernetes cluster. Auth can be supplied inline via
|
||||
Username/Password or omitted to rely on ambient credentials (e.g. ~/.docker/config.json).
|
||||
properties:
|
||||
collectorName:
|
||||
type: string
|
||||
exclude:
|
||||
type: BoolString
|
||||
images:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
password:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
- images
|
||||
type: object
|
||||
run:
|
||||
properties:
|
||||
args:
|
||||
|
||||
@@ -1360,6 +1360,8 @@ spec:
|
||||
- key
|
||||
type: object
|
||||
type: object
|
||||
ignoreIfNoFiles:
|
||||
type: boolean
|
||||
outcomes:
|
||||
items:
|
||||
properties:
|
||||
@@ -1612,6 +1614,58 @@ spec:
|
||||
- outcomes
|
||||
- selector
|
||||
type: object
|
||||
s3Status:
|
||||
properties:
|
||||
annotations:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
checkName:
|
||||
type: string
|
||||
collectorName:
|
||||
type: string
|
||||
exclude:
|
||||
type: BoolString
|
||||
fileName:
|
||||
type: string
|
||||
outcomes:
|
||||
items:
|
||||
properties:
|
||||
fail:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
pass:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
warn:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
type: array
|
||||
strict:
|
||||
type: BoolString
|
||||
required:
|
||||
- collectorName
|
||||
- outcomes
|
||||
type: object
|
||||
secret:
|
||||
properties:
|
||||
annotations:
|
||||
@@ -19866,6 +19920,29 @@ spec:
|
||||
required:
|
||||
- namespace
|
||||
type: object
|
||||
s3Status:
|
||||
properties:
|
||||
accessKeyID:
|
||||
type: string
|
||||
bucketName:
|
||||
type: string
|
||||
collectorName:
|
||||
type: string
|
||||
endpoint:
|
||||
type: string
|
||||
exclude:
|
||||
type: BoolString
|
||||
insecure:
|
||||
type: boolean
|
||||
region:
|
||||
type: string
|
||||
secretAccessKey:
|
||||
type: string
|
||||
usePathStyle:
|
||||
type: boolean
|
||||
required:
|
||||
- bucketName
|
||||
type: object
|
||||
secret:
|
||||
properties:
|
||||
collectorName:
|
||||
|
||||
@@ -1391,6 +1391,8 @@ spec:
|
||||
- key
|
||||
type: object
|
||||
type: object
|
||||
ignoreIfNoFiles:
|
||||
type: boolean
|
||||
outcomes:
|
||||
items:
|
||||
properties:
|
||||
@@ -1643,6 +1645,58 @@ spec:
|
||||
- outcomes
|
||||
- selector
|
||||
type: object
|
||||
s3Status:
|
||||
properties:
|
||||
annotations:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
checkName:
|
||||
type: string
|
||||
collectorName:
|
||||
type: string
|
||||
exclude:
|
||||
type: BoolString
|
||||
fileName:
|
||||
type: string
|
||||
outcomes:
|
||||
items:
|
||||
properties:
|
||||
fail:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
pass:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
warn:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
type: array
|
||||
strict:
|
||||
type: BoolString
|
||||
required:
|
||||
- collectorName
|
||||
- outcomes
|
||||
type: object
|
||||
secret:
|
||||
properties:
|
||||
annotations:
|
||||
@@ -19897,6 +19951,29 @@ spec:
|
||||
required:
|
||||
- namespace
|
||||
type: object
|
||||
s3Status:
|
||||
properties:
|
||||
accessKeyID:
|
||||
type: string
|
||||
bucketName:
|
||||
type: string
|
||||
collectorName:
|
||||
type: string
|
||||
endpoint:
|
||||
type: string
|
||||
exclude:
|
||||
type: BoolString
|
||||
insecure:
|
||||
type: boolean
|
||||
region:
|
||||
type: string
|
||||
secretAccessKey:
|
||||
type: string
|
||||
usePathStyle:
|
||||
type: boolean
|
||||
required:
|
||||
- bucketName
|
||||
type: object
|
||||
secret:
|
||||
properties:
|
||||
collectorName:
|
||||
@@ -20787,6 +20864,55 @@ spec:
|
||||
required:
|
||||
- outcomes
|
||||
type: object
|
||||
registryImages:
|
||||
properties:
|
||||
annotations:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
checkName:
|
||||
type: string
|
||||
collectorName:
|
||||
type: string
|
||||
exclude:
|
||||
type: BoolString
|
||||
outcomes:
|
||||
items:
|
||||
properties:
|
||||
fail:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
pass:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
warn:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
uri:
|
||||
type: string
|
||||
when:
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
type: array
|
||||
strict:
|
||||
type: BoolString
|
||||
required:
|
||||
- outcomes
|
||||
type: object
|
||||
subnetAvailable:
|
||||
properties:
|
||||
annotations:
|
||||
@@ -21716,6 +21842,27 @@ spec:
|
||||
- port
|
||||
- toCIDR
|
||||
type: object
|
||||
registryImages:
|
||||
description: |-
|
||||
HostRegistryImages checks whether images are accessible from the host,
|
||||
without requiring a Kubernetes cluster. Auth can be supplied inline via
|
||||
Username/Password or omitted to rely on ambient credentials (e.g. ~/.docker/config.json).
|
||||
properties:
|
||||
collectorName:
|
||||
type: string
|
||||
exclude:
|
||||
type: BoolString
|
||||
images:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
password:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
- images
|
||||
type: object
|
||||
run:
|
||||
properties:
|
||||
args:
|
||||
|
||||
+10
-47
@@ -9,10 +9,14 @@ builds:
|
||||
main: ./cmd/preflight/main.go
|
||||
env: [CGO_ENABLED=0]
|
||||
goos: [linux, darwin, windows]
|
||||
goarch: [amd64, arm, arm64]
|
||||
goarch: [amd64, arm, arm64, riscv64]
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
- goos: darwin
|
||||
goarch: riscv64
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X github.com/replicatedhq/troubleshoot/pkg/version.version={{ .Version }}
|
||||
@@ -32,10 +36,14 @@ builds:
|
||||
main: ./cmd/troubleshoot/main.go
|
||||
env: [CGO_ENABLED=0]
|
||||
goos: [linux, darwin, windows]
|
||||
goarch: [amd64, arm, arm64]
|
||||
goarch: [amd64, arm, arm64, riscv64]
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
- goos: darwin
|
||||
goarch: riscv64
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X github.com/replicatedhq/troubleshoot/pkg/version.version={{ .Version }}
|
||||
@@ -51,29 +59,6 @@ builds:
|
||||
- -installsuffix=netgo
|
||||
binary: support-bundle
|
||||
|
||||
- id: collect
|
||||
main: ./cmd/collect/main.go
|
||||
env: [CGO_ENABLED=0]
|
||||
goos: [linux, darwin, windows]
|
||||
goarch: [amd64, arm, arm64]
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X github.com/replicatedhq/troubleshoot/pkg/version.version={{ .Version }}
|
||||
- -X github.com/replicatedhq/troubleshoot/pkg/version.gitSHA={{ .Commit }}
|
||||
- -X github.com/replicatedhq/troubleshoot/pkg/version.buildTime={{ .Date }}
|
||||
- -extldflags "-static"
|
||||
flags:
|
||||
- -tags=netgo
|
||||
- -tags=containers_image_ostree_stub
|
||||
- -tags=exclude_graphdriver_devicemapper
|
||||
- -tags=exclude_graphdriver_btrfs
|
||||
- -tags=containers_image_openpgp
|
||||
- -installsuffix=netgo
|
||||
binary: collect
|
||||
|
||||
archives:
|
||||
- id: preflight
|
||||
ids: [preflight]
|
||||
@@ -148,28 +133,6 @@ archives:
|
||||
dst: .
|
||||
strip_parent: true
|
||||
|
||||
dockers:
|
||||
- dockerfile: ./deploy/Dockerfile.troubleshoot
|
||||
image_templates:
|
||||
- "replicated/troubleshoot:latest"
|
||||
- "replicated/troubleshoot:{{ .Major }}"
|
||||
- "replicated/troubleshoot:{{ .Major }}.{{ .Minor }}"
|
||||
- "replicated/troubleshoot:{{ .Major }}.{{ .Minor }}.{{ .Patch }}"
|
||||
ids:
|
||||
- support-bundle
|
||||
- preflight
|
||||
- collect
|
||||
- dockerfile: ./deploy/Dockerfile.troubleshoot
|
||||
image_templates:
|
||||
- "replicated/preflight:latest"
|
||||
- "replicated/preflight:{{ .Major }}"
|
||||
- "replicated/preflight:{{ .Major }}.{{ .Minor }}"
|
||||
- "replicated/preflight:{{ .Major }}.{{ .Minor }}.{{ .Patch }}"
|
||||
ids:
|
||||
- support-bundle
|
||||
- preflight
|
||||
- collect
|
||||
|
||||
universal_binaries:
|
||||
- id: preflight-universal
|
||||
ids: [preflight] # refers to the build id above
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
FROM debian:bookworm
|
||||
WORKDIR /
|
||||
|
||||
RUN apt-get -qq update \
|
||||
&& apt-get -qq -y install \
|
||||
ca-certificates kmod
|
||||
|
||||
COPY support-bundle /troubleshoot/support-bundle
|
||||
COPY preflight /troubleshoot/preflight
|
||||
COPY collect /troubleshoot/collect
|
||||
|
||||
ENV PATH="/troubleshoot:${PATH}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module helm-template
|
||||
|
||||
go 1.26.1
|
||||
go 1.26.5
|
||||
|
||||
// Always use the local version of troubleshoot so as to build using
|
||||
// the latest version of the library. This will ensure the example
|
||||
@@ -9,7 +9,7 @@ replace github.com/replicatedhq/troubleshoot v0.0.0 => ../../../
|
||||
|
||||
require (
|
||||
github.com/replicatedhq/troubleshoot v0.0.0
|
||||
helm.sh/helm/v3 v3.20.1
|
||||
helm.sh/helm/v3 v3.21.4
|
||||
sigs.k8s.io/yaml v1.6.0
|
||||
)
|
||||
|
||||
@@ -17,24 +17,33 @@ require (
|
||||
dario.cat/mergo v1.0.2 // 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/semver/v3 v3.5.0 // indirect
|
||||
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.7.0 // 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
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
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/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.0 // indirect
|
||||
github.com/go-openapi/swag v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/pools v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/google/gnostic-models v0.7.1 // 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
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
@@ -45,29 +54,28 @@ 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.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/oauth2 v0.33.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // 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.35.2 // indirect
|
||||
k8s.io/apiextensions-apiserver v0.35.2 // indirect
|
||||
k8s.io/apimachinery v0.35.2 // indirect
|
||||
k8s.io/client-go v0.35.2 // indirect
|
||||
k8s.io/api v0.36.3 // indirect
|
||||
k8s.io/apiextensions-apiserver v0.36.3 // indirect
|
||||
k8s.io/apimachinery v0.36.3 // indirect
|
||||
k8s.io/client-go v0.36.3 // indirect
|
||||
k8s.io/klog/v2 v2.140.0 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||
sigs.k8s.io/controller-runtime v0.23.3 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect
|
||||
sigs.k8s.io/controller-runtime v0.24.1 // 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.2-0.20260122202528-d9cc6641c482 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
|
||||
)
|
||||
|
||||
@@ -6,38 +6,66 @@ 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/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.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.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE=
|
||||
github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4=
|
||||
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/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
||||
github.com/emicklei/go-restful/v3 v3.13.0/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=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
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/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
|
||||
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
|
||||
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
|
||||
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
|
||||
github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw=
|
||||
github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg=
|
||||
github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q=
|
||||
github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
|
||||
github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8=
|
||||
github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k=
|
||||
github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU=
|
||||
github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
|
||||
github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4=
|
||||
github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
|
||||
github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU=
|
||||
github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k=
|
||||
github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM=
|
||||
github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
|
||||
github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k=
|
||||
github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
|
||||
github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU=
|
||||
github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
|
||||
github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU=
|
||||
github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
|
||||
github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY=
|
||||
github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY=
|
||||
github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
|
||||
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
|
||||
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
|
||||
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/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
|
||||
github.com/google/gnostic-models v0.7.1/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=
|
||||
@@ -49,16 +77,12 @@ 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=
|
||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
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/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=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
@@ -71,17 +95,17 @@ 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.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y=
|
||||
github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
|
||||
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||
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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
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=
|
||||
@@ -98,32 +122,32 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
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=
|
||||
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
|
||||
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/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=
|
||||
@@ -135,29 +159,29 @@ 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.20.1 h1:T8PodUaH1UwNvE+imUA2mIKjJItY8g7CVvLVP5g4NzI=
|
||||
helm.sh/helm/v3 v3.20.1/go.mod h1:Fl1kBaWCpkUrM6IYXPjQ3bdZQfFrogKArqptvueZ6Ww=
|
||||
k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw=
|
||||
k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60=
|
||||
k8s.io/apiextensions-apiserver v0.35.2 h1:iyStXHoJZsUXPh/nFAsjC29rjJWdSgUmG1XpApE29c0=
|
||||
k8s.io/apiextensions-apiserver v0.35.2/go.mod h1:OdyGvcO1FtMDWQ+rRh/Ei3b6X3g2+ZDHd0MSRGeS8rU=
|
||||
k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8=
|
||||
k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
||||
k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o=
|
||||
k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g=
|
||||
helm.sh/helm/v3 v3.21.4 h1:T/GcIEXU/gNjJnkITlIZ3e9xqkZjhFTmISuStTZ6+Qg=
|
||||
helm.sh/helm/v3 v3.21.4/go.mod h1:cS2FBb+xfLuaSqvEmbqIeKUVFgHdHVHtVeXb2epof3M=
|
||||
k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w=
|
||||
k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg=
|
||||
k8s.io/apiextensions-apiserver v0.36.3 h1:dPmOAPhwTtqb1bTxbFPsy18KHPhktQeO3WUPXunZIB0=
|
||||
k8s.io/apiextensions-apiserver v0.36.3/go.mod h1:KTXFqgXiuw2pRoL+Wpmttqc+up9Xt/GohadPWeLLOa4=
|
||||
k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM=
|
||||
k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE=
|
||||
k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg=
|
||||
k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30=
|
||||
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
|
||||
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
|
||||
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/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80=
|
||||
sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0=
|
||||
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
|
||||
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE=
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM=
|
||||
sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4=
|
||||
sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
|
||||
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.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
Generated
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1783224372,
|
||||
"narHash": "sha256-8i/87eeoqiGE4yOTjwSA3Eh/ziJRQEmd/unYU+K27sk=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d407951447dcd00442e97087bf374aad70c04cea",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
description = "Troubleshoot development environment";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in {
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
go_1_26
|
||||
git # required for go VCS stamping during build
|
||||
python3 # required by the dependency-update validation stage
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "Troubleshoot dev — $(go version)" # go 1.26.x required
|
||||
'';
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,280 +1,279 @@
|
||||
module github.com/replicatedhq/troubleshoot
|
||||
|
||||
go 1.26.1
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.48.0
|
||||
github.com/Masterminds/sprig/v3 v3.3.0
|
||||
github.com/ahmetalpbalkan/go-cursor v0.0.0-20131010032410-8136607ea412
|
||||
github.com/apparentlymart/go-cidr v1.1.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0
|
||||
github.com/apparentlymart/go-cidr v1.1.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.6
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.36
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.2
|
||||
github.com/blang/semver/v4 v4.0.0
|
||||
github.com/casbin/govaluate v1.10.0
|
||||
github.com/cilium/ebpf v0.21.0
|
||||
github.com/cilium/ebpf v0.22.0
|
||||
github.com/containerd/cgroups/v3 v3.1.3
|
||||
github.com/distribution/distribution/v3 v3.1.0
|
||||
github.com/fatih/color v1.19.0
|
||||
github.com/go-logr/logr v1.4.3
|
||||
github.com/go-logr/logr v1.4.4
|
||||
github.com/go-redis/redis/v7 v7.4.1
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/gobwas/glob v0.2.3
|
||||
github.com/godbus/dbus/v5 v5.2.2
|
||||
github.com/google/go-containerregistry v0.21.9
|
||||
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.6
|
||||
github.com/hashicorp/go-getter v1.8.8
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/longhorn/go-iscsi-helper v0.0.0-20210330030558-49a327fb024e
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/longhorn/go-common-libs v0.0.0-20260730002911-add09e6eb92c
|
||||
github.com/manifoldco/promptui v0.9.0
|
||||
github.com/mattn/go-isatty v0.0.21
|
||||
github.com/microsoft/go-mssqldb v1.9.8
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/mattn/go-isatty v0.0.24
|
||||
github.com/microsoft/go-mssqldb v1.10.0
|
||||
github.com/miekg/dns v1.1.73
|
||||
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.26.3
|
||||
github.com/shirou/gopsutil/v4 v4.26.7
|
||||
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/stretchr/testify v1.12.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.18.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.podman.io/image/v5 v5.39.2
|
||||
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/sync v0.20.0
|
||||
github.com/vmware-tanzu/velero v1.18.2
|
||||
go.opentelemetry.io/otel v1.45.0
|
||||
go.opentelemetry.io/otel/sdk v1.45.0
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
|
||||
golang.org/x/mod v0.40.0
|
||||
golang.org/x/sync v0.22.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
k8s.io/api v0.35.3
|
||||
k8s.io/apiextensions-apiserver v0.35.3
|
||||
k8s.io/apimachinery v0.35.3
|
||||
k8s.io/apiserver v0.35.3
|
||||
k8s.io/cli-runtime v0.35.3
|
||||
k8s.io/client-go v0.35.3
|
||||
k8s.io/api v0.36.3
|
||||
k8s.io/apiextensions-apiserver v0.36.3
|
||||
k8s.io/apimachinery v0.36.3
|
||||
k8s.io/apiserver v0.36.3
|
||||
k8s.io/cli-runtime v0.36.3
|
||||
k8s.io/client-go v0.36.3
|
||||
k8s.io/klog/v2 v2.140.0
|
||||
oras.land/oras-go/v2 v2.6.0
|
||||
sigs.k8s.io/controller-runtime v0.23.3
|
||||
sigs.k8s.io/e2e-framework v0.6.0
|
||||
k8s.io/streaming v0.36.3
|
||||
oras.land/oras-go/v2 v2.6.2
|
||||
sigs.k8s.io/controller-runtime v0.24.1
|
||||
sigs.k8s.io/e2e-framework v0.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.25.1 // indirect
|
||||
cloud.google.com/go/auth v0.18.2 // indirect
|
||||
cel.dev/expr v0.25.2 // indirect
|
||||
cloud.google.com/go/auth v0.22.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||
cyphar.com/go-pathrs v0.2.1 // indirect
|
||||
cloud.google.com/go/monitoring v1.30.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
filippo.io/edwards25519 v1.1.1 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/ClickHouse/ch-go v0.74.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.35.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.59.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.59.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/semver/v3 v3.5.0 // indirect
|
||||
github.com/Masterminds/squirrel v1.5.4 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.4.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/chai2010/gettext-go v1.0.2 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/avast/retry-go/v4 v4.7.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.35 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.38 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 // indirect
|
||||
github.com/aws/smithy-go v1.27.8 // indirect
|
||||
github.com/chai2010/gettext-go v1.0.3 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
|
||||
github.com/cockroachdb/errors v1.14.0 // indirect
|
||||
github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 // indirect
|
||||
github.com/cockroachdb/redact v1.1.8 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // 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.10.0 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.7.0 // indirect
|
||||
github.com/docker/cli v29.7.1+incompatible // indirect
|
||||
github.com/ebitengine/purego v0.10.2 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
|
||||
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
||||
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/getsentry/sentry-go v0.48.0 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.8.0 // indirect
|
||||
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/pools v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // 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.6 // indirect
|
||||
github.com/google/gnostic-models v0.7.1 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.19 // 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.72 // indirect
|
||||
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.74 // 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
|
||||
github.com/jmoiron/sqlx v1.4.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||
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/v4 v4.0.0 // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20260801144041-2fc331e7910f // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/capability v0.4.0 // indirect
|
||||
github.com/moby/sys/user v0.4.0 // indirect
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
|
||||
github.com/paulmach/orb v0.13.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.27 // 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/rogpeppe/go-internal v1.15.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/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // 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.6.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // 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/spiffe/go-spiffe/v2 v2.8.1 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/ulikunitz/xz v0.5.16 // indirect
|
||||
github.com/vladimirvivien/gexe v0.5.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.39.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.67.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.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.43.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
k8s.io/component-base v0.35.3 // indirect
|
||||
k8s.io/kubectl v0.35.1 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
k8s.io/component-base v0.36.3 // indirect
|
||||
k8s.io/kubectl v0.36.3 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/storage v1.61.3 // indirect
|
||||
cloud.google.com/go/iam v1.12.0 // indirect
|
||||
cloud.google.com/go/storage v1.64.0 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // 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/c9s/goprocinfo v0.0.0-20210130143923-c95fcf8c64a8 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/chzyer/readline v1.5.1 // 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/docker/docker v28.5.1+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.5 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.8 // indirect
|
||||
github.com/evanphx/json-patch v5.9.11+incompatible // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-errors/errors v1.4.2 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
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/go-openapi/jsonpointer v1.0.0 // indirect
|
||||
github.com/go-openapi/jsonreference v1.0.0 // indirect
|
||||
github.com/go-openapi/swag v0.28.0 // 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.17.0 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.23.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-version v1.9.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.5 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/klauspost/compress v1.19.2 // indirect
|
||||
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.27 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1
|
||||
github.com/moby/spdystream v0.5.0 // indirect
|
||||
github.com/moby/sys/mountinfo v0.7.2 // indirect
|
||||
github.com/moby/spdystream v0.5.1 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d // indirect
|
||||
github.com/nsf/termbox-go v1.1.1 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.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/pelletier/go-toml/v2 v2.4.3 // 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.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.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.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/vbatts/tar-split v0.12.2 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||
github.com/xlab/treeprint v1.2.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
go.podman.io/storage v1.62.1-0.20260218215809-4bd29ff8b87e // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.41.0
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/api v0.271.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
google.golang.org/api v0.292.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260729162451-8efbd57d26e0 // indirect
|
||||
google.golang.org/grpc v1.83.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
helm.sh/helm/v3 v3.20.1
|
||||
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
|
||||
k8s.io/kubelet v0.35.3
|
||||
k8s.io/metrics v0.35.3
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4
|
||||
helm.sh/helm/v3 v3.21.4
|
||||
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
|
||||
k8s.io/kubelet v0.36.3
|
||||
k8s.io/metrics v0.36.3
|
||||
k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3
|
||||
periph.io/x/host/v3 v3.8.5
|
||||
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/kustomize/api v0.21.1 // indirect
|
||||
sigs.k8s.io/kustomize/kyaml v0.21.1 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0
|
||||
)
|
||||
|
||||
|
||||
@@ -238,6 +238,8 @@ func GetAnalyzer(analyzer *troubleshootv1beta2.Analyze) Analyzer {
|
||||
return &AnalyzeMssql{analyzer: analyzer.Mssql}
|
||||
case analyzer.Redis != nil:
|
||||
return &AnalyzeRedis{analyzer: analyzer.Redis}
|
||||
case analyzer.ClickHouse != nil:
|
||||
return &AnalyzeClickhouse{analyzer: analyzer.ClickHouse}
|
||||
case analyzer.CephStatus != nil:
|
||||
return &AnalyzeCephStatus{analyzer: analyzer.CephStatus}
|
||||
case analyzer.Velero != nil:
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
"github.com/pkg/errors"
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/collect"
|
||||
)
|
||||
|
||||
type AnalyzeClickhouse struct {
|
||||
analyzer *troubleshootv1beta2.DatabaseAnalyze
|
||||
}
|
||||
|
||||
func (a *AnalyzeClickhouse) Title() string {
|
||||
title := a.analyzer.CheckName
|
||||
if title == "" {
|
||||
title = a.collectorName()
|
||||
}
|
||||
|
||||
return title
|
||||
}
|
||||
|
||||
func (a *AnalyzeClickhouse) IsExcluded() (bool, error) {
|
||||
return isExcluded(a.analyzer.Exclude)
|
||||
}
|
||||
|
||||
func (a *AnalyzeClickhouse) Analyze(getFile getCollectedFileContents, findFiles getChildCollectedFileContents) ([]*AnalyzeResult, error) {
|
||||
result, err := a.analyze(a.analyzer, getFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Strict = a.analyzer.Strict.BoolOrDefaultFalse()
|
||||
return []*AnalyzeResult{result}, nil
|
||||
}
|
||||
|
||||
func (a *AnalyzeClickhouse) collectorName() string {
|
||||
if a.analyzer.CollectorName != "" {
|
||||
return a.analyzer.CollectorName
|
||||
}
|
||||
return "clickhouse"
|
||||
}
|
||||
|
||||
func compareClickhouseConditionalToActual(conditional string, result *collect.DatabaseConnection) (bool, error) {
|
||||
parts := strings.Split(strings.TrimSpace(conditional), " ")
|
||||
|
||||
if len(parts) != 3 {
|
||||
return false, errors.New("unable to parse conditional")
|
||||
}
|
||||
|
||||
switch parts[0] {
|
||||
case "connected":
|
||||
expected, err := strconv.ParseBool(parts[2])
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "failed to parse bool")
|
||||
}
|
||||
|
||||
switch parts[1] {
|
||||
case "=", "==", "===":
|
||||
return expected == result.IsConnected, nil
|
||||
case "!=", "!==":
|
||||
return expected != result.IsConnected, nil
|
||||
}
|
||||
|
||||
return false, errors.New("unable to parse ClickHouse connected analyzer")
|
||||
|
||||
case "version":
|
||||
expected, err := version.NewVersion(strings.ReplaceAll(parts[2], "x", "0"))
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "failed to parse expected version")
|
||||
}
|
||||
|
||||
operation := parts[1]
|
||||
switch operation {
|
||||
case "=", "==", "===":
|
||||
operation = "="
|
||||
case "!=", "!==":
|
||||
operation = "!="
|
||||
}
|
||||
|
||||
actual, err := version.NewVersion(strings.ReplaceAll(result.Version, "x", "0"))
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "failed to parse ClickHouse db actual version")
|
||||
}
|
||||
|
||||
constraints, err := version.NewConstraint(fmt.Sprintf("%s %s", operation, expected))
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "failed to create constraint")
|
||||
}
|
||||
return constraints.Check(actual), nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (a *AnalyzeClickhouse) analyze(analyzer *troubleshootv1beta2.DatabaseAnalyze, getCollectedFileContents func(string) ([]byte, error)) (*AnalyzeResult, error) {
|
||||
fullPath := path.Join("", fmt.Sprintf("clickhouse/%s.json", a.collectorName()))
|
||||
|
||||
collected, err := getCollectedFileContents(fullPath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to read collected file name: %s", fullPath)
|
||||
}
|
||||
|
||||
databaseConnection := collect.DatabaseConnection{}
|
||||
if err := json.Unmarshal(collected, &databaseConnection); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to unmarshal database connection result")
|
||||
}
|
||||
|
||||
result := &AnalyzeResult{
|
||||
Title: a.Title(),
|
||||
IconKey: "kubernetes_clickhouse_analyze",
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/clickhouse-analyze.svg",
|
||||
}
|
||||
|
||||
for _, outcome := range analyzer.Outcomes {
|
||||
if outcome.Fail != nil {
|
||||
if outcome.Fail.When == "" {
|
||||
result.IsFail = true
|
||||
result.Message = outcome.Fail.Message
|
||||
result.URI = outcome.Fail.URI
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
isMatch, err := compareClickhouseConditionalToActual(outcome.Fail.When, &databaseConnection)
|
||||
if err != nil {
|
||||
return result, errors.Wrap(err, "failed to compare ClickHouse database conditional")
|
||||
}
|
||||
|
||||
if isMatch {
|
||||
if databaseConnection.Error != "" {
|
||||
result.Message = outcome.Fail.Message + " " + databaseConnection.Error
|
||||
} else {
|
||||
result.Message = outcome.Fail.Message
|
||||
}
|
||||
|
||||
result.IsFail = true
|
||||
result.URI = outcome.Fail.URI
|
||||
|
||||
return result, nil
|
||||
}
|
||||
} else if outcome.Warn != nil {
|
||||
if outcome.Warn.When == "" {
|
||||
result.IsWarn = true
|
||||
result.Message = outcome.Warn.Message
|
||||
result.URI = outcome.Warn.URI
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
isMatch, err := compareClickhouseConditionalToActual(outcome.Warn.When, &databaseConnection)
|
||||
if err != nil {
|
||||
return result, errors.Wrap(err, "failed to compare ClickHouse database conditional")
|
||||
}
|
||||
|
||||
if isMatch {
|
||||
result.IsWarn = true
|
||||
result.Message = outcome.Warn.Message
|
||||
result.URI = outcome.Warn.URI
|
||||
|
||||
return result, nil
|
||||
}
|
||||
} else if outcome.Pass != nil {
|
||||
if outcome.Pass.When == "" {
|
||||
result.IsPass = true
|
||||
result.Message = outcome.Pass.Message
|
||||
result.URI = outcome.Pass.URI
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
isMatch, err := compareClickhouseConditionalToActual(outcome.Pass.When, &databaseConnection)
|
||||
if err != nil {
|
||||
return result, errors.Wrap(err, "failed to compare ClickHouse database conditional")
|
||||
}
|
||||
|
||||
if isMatch {
|
||||
result.IsPass = true
|
||||
result.Message = outcome.Pass.Message
|
||||
result.URI = outcome.Pass.URI
|
||||
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -699,6 +699,10 @@ func (e *DefaultAnalysisEngine) convertAnalyzerToSpec(analyzer *troubleshootv1be
|
||||
spec.Name = "redis"
|
||||
spec.Type = "database"
|
||||
spec.Config["analyzer"] = analyzer.Redis
|
||||
case analyzer.ClickHouse != nil:
|
||||
spec.Name = "clickhouse"
|
||||
spec.Type = "database"
|
||||
spec.Config["analyzer"] = analyzer.ClickHouse
|
||||
|
||||
// ✅ Storage analyzers
|
||||
case analyzer.CephStatus != nil:
|
||||
|
||||
@@ -67,6 +67,8 @@ func GetHostAnalyzer(analyzer *troubleshootv1beta2.HostAnalyze) (HostAnalyzer, b
|
||||
return &AnalyzeHostNetworkNamespaceConnectivity{analyzer.NetworkNamespaceConnectivity}, true
|
||||
case analyzer.Sysctl != nil:
|
||||
return &AnalyzeHostSysctl{analyzer.Sysctl}, true
|
||||
case analyzer.RegistryImages != nil:
|
||||
return &AnalyzeHostRegistryImages{analyzer.RegistryImages}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/replicatedhq/troubleshoot/internal/util"
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/collect"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
// RegistryImagesSummary is passed as template data when rendering outcome messages.
|
||||
// Fields are exported so Go templates can reference them.
|
||||
//
|
||||
// - Verified: images confirmed to exist in the registry.
|
||||
// - Missing: images confirmed not to exist in the registry.
|
||||
// - Errors: images that could not be checked (parse failures, timeouts, auth errors, etc).
|
||||
// - UnverifiedReasons: map of image name to reason string for every unverified image
|
||||
// (union of Missing and Errors).
|
||||
//
|
||||
// The `when` conditions follow the existing registry images analyzer nomenclature:
|
||||
// "verified", "missing", and "errors" (see https://troubleshoot.sh/docs/analyze/registry-images).
|
||||
type RegistryImagesSummary struct {
|
||||
Verified []string
|
||||
Missing []string
|
||||
Errors []string
|
||||
UnverifiedReasons map[string]string
|
||||
}
|
||||
|
||||
type AnalyzeHostRegistryImages struct {
|
||||
hostAnalyzer *troubleshootv1beta2.HostRegistryImagesAnalyze
|
||||
}
|
||||
|
||||
func (a *AnalyzeHostRegistryImages) Title() string {
|
||||
return hostAnalyzerTitleOrDefault(a.hostAnalyzer.AnalyzeMeta, "Registry Images")
|
||||
}
|
||||
|
||||
func (a *AnalyzeHostRegistryImages) IsExcluded() (bool, error) {
|
||||
return isExcluded(a.hostAnalyzer.Exclude)
|
||||
}
|
||||
|
||||
func (a *AnalyzeHostRegistryImages) Analyze(
|
||||
getCollectedFileContents func(string) ([]byte, error), findFiles getChildCollectedFileContents,
|
||||
) ([]*AnalyzeResult, error) {
|
||||
collectorName := a.hostAnalyzer.CollectorName
|
||||
if collectorName == "" {
|
||||
collectorName = "images"
|
||||
}
|
||||
|
||||
const nodeBaseDir = "host-collectors/registry-images"
|
||||
localPath := fmt.Sprintf("%s/%s.json", nodeBaseDir, collectorName)
|
||||
fileName := fmt.Sprintf("%s.json", collectorName)
|
||||
|
||||
collectedContents, err := retrieveCollectedContents(
|
||||
getCollectedFileContents,
|
||||
localPath,
|
||||
nodeBaseDir,
|
||||
fileName,
|
||||
)
|
||||
if err != nil {
|
||||
return []*AnalyzeResult{{Title: a.Title()}}, err
|
||||
}
|
||||
|
||||
var results []*AnalyzeResult
|
||||
for _, content := range collectedContents {
|
||||
currentTitle := a.Title()
|
||||
if content.NodeName != "" {
|
||||
currentTitle = fmt.Sprintf("%s - Node %s", a.Title(), content.NodeName)
|
||||
}
|
||||
|
||||
result, err := a.evaluateOutcomesWithTemplate(content.Data, currentTitle)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to analyze host registry images")
|
||||
}
|
||||
if result != nil {
|
||||
klog.V(2).Infof("registry images analysis result: title=%q pass=%t warn=%t fail=%t message=%q",
|
||||
result.Title, result.IsPass, result.IsWarn, result.IsFail, result.Message)
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (a *AnalyzeHostRegistryImages) evaluateOutcomesWithTemplate(data []byte, title string) (*AnalyzeResult, error) {
|
||||
summary, err := buildRegistryImagesSummary(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, outcome := range a.hostAnalyzer.Outcomes {
|
||||
result := &AnalyzeResult{Title: title}
|
||||
|
||||
switch {
|
||||
case outcome.Fail != nil:
|
||||
if outcome.Fail.When == "" {
|
||||
result.IsFail = true
|
||||
result.Message = renderRegistryMessage(outcome.Fail.Message, summary)
|
||||
result.URI = outcome.Fail.URI
|
||||
return result, nil
|
||||
}
|
||||
isMatch, err := compareRegistryConditionalToActual(outcome.Fail.When, len(summary.Verified), len(summary.Missing), len(summary.Errors))
|
||||
if err != nil {
|
||||
return result, errors.Wrapf(err, "failed to compare %s", outcome.Fail.When)
|
||||
}
|
||||
if isMatch {
|
||||
result.IsFail = true
|
||||
result.Message = renderRegistryMessage(outcome.Fail.Message, summary)
|
||||
result.URI = outcome.Fail.URI
|
||||
return result, nil
|
||||
}
|
||||
|
||||
case outcome.Warn != nil:
|
||||
if outcome.Warn.When == "" {
|
||||
result.IsWarn = true
|
||||
result.Message = renderRegistryMessage(outcome.Warn.Message, summary)
|
||||
result.URI = outcome.Warn.URI
|
||||
return result, nil
|
||||
}
|
||||
isMatch, err := compareRegistryConditionalToActual(outcome.Warn.When, len(summary.Verified), len(summary.Missing), len(summary.Errors))
|
||||
if err != nil {
|
||||
return result, errors.Wrapf(err, "failed to compare %s", outcome.Warn.When)
|
||||
}
|
||||
if isMatch {
|
||||
result.IsWarn = true
|
||||
result.Message = renderRegistryMessage(outcome.Warn.Message, summary)
|
||||
result.URI = outcome.Warn.URI
|
||||
return result, nil
|
||||
}
|
||||
|
||||
case outcome.Pass != nil:
|
||||
if outcome.Pass.When == "" {
|
||||
result.IsPass = true
|
||||
result.Message = renderRegistryMessage(outcome.Pass.Message, summary)
|
||||
result.URI = outcome.Pass.URI
|
||||
return result, nil
|
||||
}
|
||||
isMatch, err := compareRegistryConditionalToActual(outcome.Pass.When, len(summary.Verified), len(summary.Missing), len(summary.Errors))
|
||||
if err != nil {
|
||||
return result, errors.Wrapf(err, "failed to compare %s", outcome.Pass.When)
|
||||
}
|
||||
if isMatch {
|
||||
result.IsPass = true
|
||||
result.Message = renderRegistryMessage(outcome.Pass.Message, summary)
|
||||
result.URI = outcome.Pass.URI
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func buildRegistryImagesSummary(data []byte) (*RegistryImagesSummary, error) {
|
||||
var registryInfo collect.RegistryInfo
|
||||
if err := json.Unmarshal(data, ®istryInfo); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to unmarshal registry info")
|
||||
}
|
||||
|
||||
summary := &RegistryImagesSummary{
|
||||
UnverifiedReasons: map[string]string{},
|
||||
}
|
||||
for image, info := range registryInfo.Images {
|
||||
if info.Error != "" {
|
||||
summary.Errors = append(summary.Errors, image)
|
||||
summary.UnverifiedReasons[image] = info.Error
|
||||
} else if !info.Exists {
|
||||
summary.Missing = append(summary.Missing, image)
|
||||
summary.UnverifiedReasons[image] = "image not found in registry"
|
||||
} else {
|
||||
summary.Verified = append(summary.Verified, image)
|
||||
}
|
||||
}
|
||||
slices.Sort(summary.Verified)
|
||||
slices.Sort(summary.Missing)
|
||||
slices.Sort(summary.Errors)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func renderRegistryMessage(message string, summary *RegistryImagesSummary) string {
|
||||
rendered, err := util.RenderTemplate(message, summary)
|
||||
if err != nil {
|
||||
klog.V(2).Infof("Failed to render registry message template: %v", err)
|
||||
return message
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
func (a *AnalyzeHostRegistryImages) CheckCondition(when string, data []byte) (bool, error) {
|
||||
summary, err := buildRegistryImagesSummary(data)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return compareRegistryConditionalToActual(when, len(summary.Verified), len(summary.Missing), len(summary.Errors))
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
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"
|
||||
)
|
||||
|
||||
func TestAnalyzeHostRegistryImagesCheckCondition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
conditional string
|
||||
data collect.RegistryInfo
|
||||
expected bool
|
||||
expectErr string
|
||||
}{
|
||||
{
|
||||
name: "all images found",
|
||||
conditional: "missing == 0",
|
||||
data: collect.RegistryInfo{
|
||||
Images: map[string]collect.RegistryImage{
|
||||
"registry.example.com/app:v1": {Exists: true},
|
||||
"registry.example.com/app:v2": {Exists: true},
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "some images not found",
|
||||
conditional: "missing > 0",
|
||||
data: collect.RegistryInfo{
|
||||
Images: map[string]collect.RegistryImage{
|
||||
"registry.example.com/app:v1": {Exists: true},
|
||||
"registry.example.com/app:v2": {Exists: false},
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "verified count matches found",
|
||||
conditional: "verified == 2",
|
||||
data: collect.RegistryInfo{
|
||||
Images: map[string]collect.RegistryImage{
|
||||
"registry.example.com/app:v1": {Exists: true},
|
||||
"registry.example.com/app:v2": {Exists: true},
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "errored images counted under errors",
|
||||
conditional: "errors > 0",
|
||||
data: collect.RegistryInfo{
|
||||
Images: map[string]collect.RegistryImage{
|
||||
"registry.example.com/app:v1": {Error: "connection refused"},
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no errors when all found",
|
||||
conditional: "missing == 0",
|
||||
data: collect.RegistryInfo{
|
||||
Images: map[string]collect.RegistryImage{
|
||||
"registry.example.com/app:v1": {Exists: true},
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "mixed results - missing and errors counted separately",
|
||||
conditional: "missing == 1",
|
||||
data: collect.RegistryInfo{
|
||||
Images: map[string]collect.RegistryImage{
|
||||
"registry.example.com/app:v1": {Exists: true},
|
||||
"registry.example.com/app:v2": {Exists: false},
|
||||
"registry.example.com/app:v3": {Error: "timeout"},
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "invalid conditional format",
|
||||
conditional: "missing",
|
||||
data: collect.RegistryInfo{
|
||||
Images: map[string]collect.RegistryImage{},
|
||||
},
|
||||
expected: false,
|
||||
expectErr: "unable to parse conditional",
|
||||
},
|
||||
{
|
||||
name: "unmarshal error",
|
||||
conditional: "missing == 0",
|
||||
expected: false,
|
||||
expectErr: "failed to unmarshal registry info",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
a := &AnalyzeHostRegistryImages{}
|
||||
|
||||
var data []byte
|
||||
if test.expectErr == "failed to unmarshal registry info" {
|
||||
data = []byte(`{not valid json}`)
|
||||
} else {
|
||||
var err error
|
||||
data, err = json.Marshal(test.data)
|
||||
req.NoError(err)
|
||||
}
|
||||
|
||||
result, err := a.CheckCondition(test.conditional, data)
|
||||
if test.expectErr != "" {
|
||||
req.ErrorContains(err, test.expectErr)
|
||||
} else {
|
||||
req.NoError(err)
|
||||
}
|
||||
assert.Equal(t, test.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeHostRegistryImages(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hostAnalyzer *troubleshootv1beta2.HostRegistryImagesAnalyze
|
||||
getCollectedFileContents func(string) ([]byte, error)
|
||||
expectedResults []*AnalyzeResult
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "pass when all images found",
|
||||
hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Pass: &troubleshootv1beta2.SingleOutcome{
|
||||
When: "missing == 0",
|
||||
Message: "All images are available",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
getCollectedFileContents: func(path string) ([]byte, error) {
|
||||
if path == "host-collectors/registry-images/images.json" {
|
||||
return json.Marshal(collect.RegistryInfo{
|
||||
Images: map[string]collect.RegistryImage{
|
||||
"registry.example.com/app:v1": {Exists: true},
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, errors.New("file not found")
|
||||
},
|
||||
expectedResults: []*AnalyzeResult{
|
||||
{
|
||||
Title: "Registry Images",
|
||||
IsPass: true,
|
||||
Message: "All images are available",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail when images not found",
|
||||
hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Fail: &troubleshootv1beta2.SingleOutcome{
|
||||
When: "missing > 0",
|
||||
Message: "Some images are not available",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
getCollectedFileContents: func(path string) ([]byte, error) {
|
||||
if path == "host-collectors/registry-images/images.json" {
|
||||
return json.Marshal(collect.RegistryInfo{
|
||||
Images: map[string]collect.RegistryImage{
|
||||
"registry.example.com/app:v1": {Exists: false},
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, errors.New("file not found")
|
||||
},
|
||||
expectedResults: []*AnalyzeResult{
|
||||
{
|
||||
Title: "Registry Images",
|
||||
IsFail: true,
|
||||
Message: "Some images are not available",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "errored images matched by errors condition",
|
||||
hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Fail: &troubleshootv1beta2.SingleOutcome{
|
||||
When: "errors > 0",
|
||||
Message: "Some images are not available",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
getCollectedFileContents: func(path string) ([]byte, error) {
|
||||
if path == "host-collectors/registry-images/images.json" {
|
||||
return json.Marshal(collect.RegistryInfo{
|
||||
Images: map[string]collect.RegistryImage{
|
||||
"registry.example.com/app:v1": {Error: "connection refused"},
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, errors.New("file not found")
|
||||
},
|
||||
expectedResults: []*AnalyzeResult{
|
||||
{
|
||||
Title: "Registry Images",
|
||||
IsFail: true,
|
||||
Message: "Some images are not available",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom collector name used in path",
|
||||
hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{
|
||||
CollectorName: "my-registry",
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Pass: &troubleshootv1beta2.SingleOutcome{
|
||||
When: "missing == 0",
|
||||
Message: "All images are available",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
getCollectedFileContents: func(path string) ([]byte, error) {
|
||||
if path == "host-collectors/registry-images/my-registry.json" {
|
||||
return json.Marshal(collect.RegistryInfo{
|
||||
Images: map[string]collect.RegistryImage{
|
||||
"registry.example.com/app:v1": {Exists: true},
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, errors.New("file not found")
|
||||
},
|
||||
expectedResults: []*AnalyzeResult{
|
||||
{
|
||||
Title: "Registry Images",
|
||||
IsPass: true,
|
||||
Message: "All images are available",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "return error when collection data missing",
|
||||
hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Pass: &troubleshootv1beta2.SingleOutcome{
|
||||
When: "missing == 0",
|
||||
Message: "All images are available",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
getCollectedFileContents: func(path string) ([]byte, error) {
|
||||
return nil, errors.New("file not found")
|
||||
},
|
||||
expectedResults: []*AnalyzeResult{
|
||||
{
|
||||
Title: "Registry Images",
|
||||
},
|
||||
},
|
||||
expectedError: "file not found",
|
||||
},
|
||||
{
|
||||
name: "template rendering with NotFound list",
|
||||
hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Fail: &troubleshootv1beta2.SingleOutcome{
|
||||
When: "missing > 0",
|
||||
Message: "Missing: {{ .Missing | join \", \" }}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
getCollectedFileContents: func(path string) ([]byte, error) {
|
||||
if path == "host-collectors/registry-images/images.json" {
|
||||
return json.Marshal(collect.RegistryInfo{
|
||||
Images: map[string]collect.RegistryImage{
|
||||
"registry.example.com/app:v1": {Exists: false},
|
||||
"registry.example.com/app:v2": {Exists: true},
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, errors.New("file not found")
|
||||
},
|
||||
expectedResults: []*AnalyzeResult{
|
||||
{
|
||||
Title: "Registry Images",
|
||||
IsFail: true,
|
||||
Message: "Missing: registry.example.com/app:v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template rendering with NotFoundReasons map",
|
||||
hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Fail: &troubleshootv1beta2.SingleOutcome{
|
||||
When: "errors > 0",
|
||||
Message: `{{ range $image, $reason := .UnverifiedReasons }}{{ $image }}: {{ $reason }}; {{ end }}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
getCollectedFileContents: func(path string) ([]byte, error) {
|
||||
if path == "host-collectors/registry-images/images.json" {
|
||||
return json.Marshal(collect.RegistryInfo{
|
||||
Images: map[string]collect.RegistryImage{
|
||||
"registry.example.com/app:v1": {Error: "connection refused"},
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, errors.New("file not found")
|
||||
},
|
||||
expectedResults: []*AnalyzeResult{
|
||||
{
|
||||
Title: "Registry Images",
|
||||
IsFail: true,
|
||||
Message: "registry.example.com/app:v1: connection refused; ",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template rendering with Found count",
|
||||
hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Pass: &troubleshootv1beta2.SingleOutcome{
|
||||
When: "missing == 0",
|
||||
Message: "All {{ len .Verified }} images are available",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
getCollectedFileContents: func(path string) ([]byte, error) {
|
||||
if path == "host-collectors/registry-images/images.json" {
|
||||
return json.Marshal(collect.RegistryInfo{
|
||||
Images: map[string]collect.RegistryImage{
|
||||
"registry.example.com/app:v1": {Exists: true},
|
||||
"registry.example.com/app:v2": {Exists: true},
|
||||
},
|
||||
})
|
||||
}
|
||||
return nil, errors.New("file not found")
|
||||
},
|
||||
expectedResults: []*AnalyzeResult{
|
||||
{
|
||||
Title: "Registry Images",
|
||||
IsPass: true,
|
||||
Message: "All 2 images are available",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
|
||||
a := &AnalyzeHostRegistryImages{
|
||||
hostAnalyzer: test.hostAnalyzer,
|
||||
}
|
||||
|
||||
results, err := a.Analyze(test.getCollectedFileContents, nil)
|
||||
|
||||
if test.expectedError != "" {
|
||||
req.ErrorContains(err, test.expectedError)
|
||||
} else {
|
||||
req.NoError(err)
|
||||
}
|
||||
req.Equal(test.expectedResults, results)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeHostRegistryImagesTitle(t *testing.T) {
|
||||
t.Run("default title", func(t *testing.T) {
|
||||
a := &AnalyzeHostRegistryImages{
|
||||
hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{},
|
||||
}
|
||||
assert.Equal(t, "Registry Images", a.Title())
|
||||
})
|
||||
|
||||
t.Run("custom title", func(t *testing.T) {
|
||||
a := &AnalyzeHostRegistryImages{
|
||||
hostAnalyzer: &troubleshootv1beta2.HostRegistryImagesAnalyze{
|
||||
AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{
|
||||
CheckName: "My Registry Check",
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.Equal(t, "My Registry Check", a.Title())
|
||||
})
|
||||
}
|
||||
@@ -17,24 +17,26 @@ import (
|
||||
)
|
||||
|
||||
var Filemap = map[string]string{
|
||||
"deployment": constants.CLUSTER_RESOURCES_DEPLOYMENTS,
|
||||
"daemonset": constants.CLUSTER_RESOURCES_DAEMONSETS,
|
||||
"statefulset": constants.CLUSTER_RESOURCES_STATEFULSETS,
|
||||
"networkpolicy": constants.CLUSTER_RESOURCES_NETWORK_POLICY,
|
||||
"pod": constants.CLUSTER_RESOURCES_PODS,
|
||||
"ingress": constants.CLUSTER_RESOURCES_INGRESS,
|
||||
"service": constants.CLUSTER_RESOURCES_SERVICES,
|
||||
"resourcequota": constants.CLUSTER_RESOURCES_RESOURCE_QUOTA,
|
||||
"job": constants.CLUSTER_RESOURCES_JOBS,
|
||||
"persistentvolumeclaim": constants.CLUSTER_RESOURCES_PVCS,
|
||||
"pvc": constants.CLUSTER_RESOURCES_PVCS,
|
||||
"replicaset": constants.CLUSTER_RESOURCES_REPLICASETS,
|
||||
"configmap": constants.CLUSTER_RESOURCES_CONFIGMAPS,
|
||||
"namespace": fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_NAMESPACES),
|
||||
"persistentvolume": fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_PVS),
|
||||
"pv": fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_PVS),
|
||||
"node": fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_NODES),
|
||||
"storageclass": fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_STORAGE_CLASS),
|
||||
"deployment": constants.CLUSTER_RESOURCES_DEPLOYMENTS,
|
||||
"daemonset": constants.CLUSTER_RESOURCES_DAEMONSETS,
|
||||
"statefulset": constants.CLUSTER_RESOURCES_STATEFULSETS,
|
||||
"networkpolicy": constants.CLUSTER_RESOURCES_NETWORK_POLICY,
|
||||
"pod": constants.CLUSTER_RESOURCES_PODS,
|
||||
"ingress": constants.CLUSTER_RESOURCES_INGRESS,
|
||||
"service": constants.CLUSTER_RESOURCES_SERVICES,
|
||||
"resourcequota": constants.CLUSTER_RESOURCES_RESOURCE_QUOTA,
|
||||
"job": constants.CLUSTER_RESOURCES_JOBS,
|
||||
"persistentvolumeclaim": constants.CLUSTER_RESOURCES_PVCS,
|
||||
"pvc": constants.CLUSTER_RESOURCES_PVCS,
|
||||
"replicaset": constants.CLUSTER_RESOURCES_REPLICASETS,
|
||||
"configmap": constants.CLUSTER_RESOURCES_CONFIGMAPS,
|
||||
"validatingwebhookconfiguration": fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_VALIDATING_WEBHOOK_CONFIGURATIONS),
|
||||
"mutatingwebhookconfiguration": fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_MUTATING_WEBHOOK_CONFIGURATIONS),
|
||||
"namespace": fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_NAMESPACES),
|
||||
"persistentvolume": fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_PVS),
|
||||
"pv": fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_PVS),
|
||||
"node": fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_NODES),
|
||||
"storageclass": fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_STORAGE_CLASS),
|
||||
}
|
||||
|
||||
type AnalyzeClusterResource struct {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/constants"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/types"
|
||||
)
|
||||
|
||||
type AnalyzeNodeResources struct {
|
||||
@@ -45,6 +46,9 @@ func (a *AnalyzeNodeResources) Analyze(getFile getCollectedFileContents, findFil
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
return nil, nil
|
||||
}
|
||||
result.Strict = a.analyzer.Strict.BoolOrDefaultFalse()
|
||||
return []*AnalyzeResult{result}, nil
|
||||
}
|
||||
@@ -53,7 +57,19 @@ func (a *AnalyzeNodeResources) analyzeNodeResources(analyzer *troubleshootv1beta
|
||||
|
||||
collected, err := getCollectedFileContents(fmt.Sprintf("%s/%s.json", constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_NODES))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get contents of nodes.json")
|
||||
if _, ok := err.(*types.NotFoundError); !ok {
|
||||
return nil, errors.Wrap(err, "failed to get contents of nodes.json")
|
||||
}
|
||||
if analyzer.IgnoreIfNoFiles {
|
||||
return nil, nil
|
||||
}
|
||||
return &AnalyzeResult{
|
||||
Title: a.Title(),
|
||||
IconKey: "kubernetes_node_resources",
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/node-resources.svg?w=16&h=18",
|
||||
IsWarn: true,
|
||||
Message: "No node resources were collected, unable to analyze node resources",
|
||||
}, nil
|
||||
}
|
||||
|
||||
var nodes corev1.NodeList
|
||||
@@ -203,6 +219,8 @@ func compareNodeResourceConditionalToActual(conditional string, matchingNodes []
|
||||
switch function {
|
||||
case "count":
|
||||
actualValue = len(matchingNodes)
|
||||
case "countDistinct":
|
||||
actualValue = countDistinctLabelValues(matchingNodes, property)
|
||||
case "min":
|
||||
actualValue = findMin(matchingNodes, property, resourceName)
|
||||
case "max":
|
||||
@@ -352,6 +370,19 @@ func getQuantity(node corev1.Node, property string, resourceName string) *resour
|
||||
return nil
|
||||
}
|
||||
|
||||
// countDistinctLabelValues returns the number of distinct values of labelKey
|
||||
// across the given nodes. Nodes missing the label are ignored, so an absent
|
||||
// label yields 0.
|
||||
func countDistinctLabelValues(nodes []corev1.Node, labelKey string) int {
|
||||
seen := map[string]struct{}{}
|
||||
for _, node := range nodes {
|
||||
if v, ok := node.Labels[labelKey]; ok {
|
||||
seen[v] = struct{}{}
|
||||
}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
func findSum(nodes []corev1.Node, property string, resourceName string) *resource.Quantity {
|
||||
sum := resource.Quantity{}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/types"
|
||||
)
|
||||
|
||||
func Test_compareNodeResourceConditionalToActual(t *testing.T) {
|
||||
@@ -1664,6 +1666,116 @@ func Test_analyzeNodeResources(t *testing.T) {
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/node-resources.svg?w=16&h=18",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "countDistinct spans at least 3 instance types", // countDistinct pass path across all nodes
|
||||
analyzer: &troubleshootv1beta2.NodeResources{
|
||||
AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{
|
||||
CheckName: "instance-type spread",
|
||||
},
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Warn: &troubleshootv1beta2.SingleOutcome{
|
||||
When: "countDistinct(node.kubernetes.io/instance-type) < 3",
|
||||
Message: "Fewer than 3 distinct instance types.",
|
||||
URI: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
Pass: &troubleshootv1beta2.SingleOutcome{
|
||||
Message: "At least 3 distinct instance types.",
|
||||
URI: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: &AnalyzeResult{
|
||||
IsPass: true,
|
||||
IsFail: false,
|
||||
IsWarn: false,
|
||||
Title: "instance-type spread",
|
||||
Message: "At least 3 distinct instance types.",
|
||||
URI: "",
|
||||
IconKey: "kubernetes_node_resources",
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/node-resources.svg?w=16&h=18",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "countDistinct only counts filtered nodes", // filtering to one pool leaves a single distinct value
|
||||
analyzer: &troubleshootv1beta2.NodeResources{
|
||||
AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{
|
||||
CheckName: "filtered instance-type spread",
|
||||
},
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Warn: &troubleshootv1beta2.SingleOutcome{
|
||||
When: "countDistinct(node.kubernetes.io/instance-type) < 3",
|
||||
Message: "Fewer than 3 distinct instance types.",
|
||||
URI: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
Pass: &troubleshootv1beta2.SingleOutcome{
|
||||
Message: "At least 3 distinct instance types.",
|
||||
URI: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Filters: &troubleshootv1beta2.NodeResourceFilters{
|
||||
Selector: &troubleshootv1beta2.NodeResourceSelectors{
|
||||
MatchExpressions: []metav1.LabelSelectorRequirement{
|
||||
{
|
||||
Key: "node.kubernetes.io/instance-type",
|
||||
Operator: metav1.LabelSelectorOpIn,
|
||||
Values: []string{"s-2vcpu-4gb"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: &AnalyzeResult{
|
||||
IsPass: false,
|
||||
IsFail: false,
|
||||
IsWarn: true,
|
||||
Title: "filtered instance-type spread",
|
||||
Message: "Fewer than 3 distinct instance types.",
|
||||
URI: "",
|
||||
IconKey: "kubernetes_node_resources",
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/node-resources.svg?w=16&h=18",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "countDistinct is 0 when the label is absent", // AIR-238 zone syntax; fixture nodes carry no zone label
|
||||
analyzer: &troubleshootv1beta2.NodeResources{
|
||||
AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{
|
||||
CheckName: "zone spread",
|
||||
},
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Warn: &troubleshootv1beta2.SingleOutcome{
|
||||
When: "countDistinct(topology.kubernetes.io/zone) < 3",
|
||||
Message: "Nodes span fewer than 3 availability zones.",
|
||||
URI: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
Pass: &troubleshootv1beta2.SingleOutcome{
|
||||
Message: "Nodes span at least 3 availability zones.",
|
||||
URI: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: &AnalyzeResult{
|
||||
IsPass: false,
|
||||
IsFail: false,
|
||||
IsWarn: true,
|
||||
Title: "zone spread",
|
||||
Message: "Nodes span fewer than 3 availability zones.",
|
||||
URI: "",
|
||||
IconKey: "kubernetes_node_resources",
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/node-resources.svg?w=16&h=18",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
getExampleNodeContents := func(nodeName string) ([]byte, error) {
|
||||
@@ -1684,3 +1796,56 @@ func Test_analyzeNodeResources(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_analyzeNodeResources_NoFiles(t *testing.T) {
|
||||
missingFile := func(name string) ([]byte, error) {
|
||||
return nil, &types.NotFoundError{Name: name}
|
||||
}
|
||||
|
||||
t.Run("emits warning when nodes.json is not collected", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
analyzer := &troubleshootv1beta2.NodeResources{
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{Pass: &troubleshootv1beta2.SingleOutcome{Message: "ok"}},
|
||||
},
|
||||
}
|
||||
a := AnalyzeNodeResources{analyzer: analyzer}
|
||||
got, err := a.Analyze(missingFile, nil)
|
||||
req.NoError(err)
|
||||
req.Len(got, 1)
|
||||
req.True(got[0].IsWarn)
|
||||
req.Equal("Node Resources", got[0].Title)
|
||||
})
|
||||
|
||||
t.Run("ignoreIfNoFiles suppresses the warning", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
analyzer := &troubleshootv1beta2.NodeResources{
|
||||
IgnoreIfNoFiles: true,
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{Pass: &troubleshootv1beta2.SingleOutcome{Message: "ok"}},
|
||||
},
|
||||
}
|
||||
a := AnalyzeNodeResources{analyzer: analyzer}
|
||||
got, err := a.Analyze(missingFile, nil)
|
||||
req.NoError(err)
|
||||
req.Empty(got)
|
||||
})
|
||||
|
||||
t.Run("non-NotFound errors are propagated", func(t *testing.T) {
|
||||
req := require.New(t)
|
||||
analyzer := &troubleshootv1beta2.NodeResources{
|
||||
IgnoreIfNoFiles: true,
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{Pass: &troubleshootv1beta2.SingleOutcome{Message: "ok"}},
|
||||
},
|
||||
}
|
||||
ioErr := func(string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("permission denied")
|
||||
}
|
||||
a := AnalyzeNodeResources{analyzer: analyzer}
|
||||
got, err := a.Analyze(ioErr, nil)
|
||||
req.Error(err)
|
||||
req.Nil(got)
|
||||
req.Contains(err.Error(), "permission denied")
|
||||
})
|
||||
}
|
||||
|
||||
+54
-25
@@ -30,6 +30,9 @@ func (a *AnalyzeSecret) Analyze(getFile getCollectedFileContents, findFiles getC
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
return nil, nil
|
||||
}
|
||||
result.Strict = a.analyzer.Strict.BoolOrDefaultFalse()
|
||||
return []*AnalyzeResult{result}, nil
|
||||
}
|
||||
@@ -54,42 +57,68 @@ func (a *AnalyzeSecret) analyzeSecret(analyzer *troubleshootv1beta2.AnalyzeSecre
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The secret analyzer only supports fail (not found) and pass (found) outcomes
|
||||
// per https://troubleshoot.sh/docs/analyze/secrets. If the spec contains
|
||||
// neither, return an explicit error: returning (nil, nil) is swallowed by the
|
||||
// Analyze wrapper into an empty result slice, so the misconfiguration would
|
||||
// surface as neither a result nor an error.
|
||||
// Capture fail and pass independently: a single outcome object may set both,
|
||||
// so an else-if here would silently drop the second one.
|
||||
var failOutcome, passOutcome *troubleshootv1beta2.SingleOutcome
|
||||
for _, outcome := range analyzer.Outcomes {
|
||||
if outcome.Fail != nil {
|
||||
failOutcome = outcome.Fail
|
||||
}
|
||||
if outcome.Pass != nil {
|
||||
passOutcome = outcome.Pass
|
||||
}
|
||||
}
|
||||
if failOutcome == nil && passOutcome == nil {
|
||||
return nil, fmt.Errorf("secret analyzer %s/%s must define at least one pass or fail outcome", analyzer.Namespace, analyzer.SecretName)
|
||||
}
|
||||
|
||||
result := AnalyzeResult{
|
||||
Title: a.Title(),
|
||||
IconKey: "kubernetes_analyze_secret",
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
|
||||
}
|
||||
|
||||
var failOutcome *troubleshootv1beta2.Outcome
|
||||
for _, outcome := range analyzer.Outcomes {
|
||||
if outcome.Fail != nil {
|
||||
failOutcome = outcome
|
||||
}
|
||||
secretFound := foundSecret.SecretExists
|
||||
if secretFound && analyzer.Key != "" {
|
||||
secretFound = foundSecret.Key == analyzer.Key && foundSecret.KeyExists
|
||||
}
|
||||
|
||||
if !foundSecret.SecretExists {
|
||||
// Use the matched branch's configured outcome verbatim, tracking whether one
|
||||
// was actually present. A configured outcome with an intentionally empty
|
||||
// message (e.g. a URI-only outcome) is preserved as-is. But when the matched
|
||||
// branch has NO configured outcome at all — e.g. a pass-only spec that took
|
||||
// the fail path, or a fail-only spec that passed — the empty message is not
|
||||
// an intentional choice, so fall back to a default diagnostic. An absent
|
||||
// outcome is not the same as an intentionally empty one.
|
||||
outcomeConfigured := false
|
||||
if secretFound {
|
||||
result.IsPass = true
|
||||
if passOutcome != nil {
|
||||
result.Message = passOutcome.Message
|
||||
result.URI = passOutcome.URI
|
||||
outcomeConfigured = true
|
||||
}
|
||||
} else {
|
||||
result.IsFail = true
|
||||
result.Message = failOutcome.Fail.Message
|
||||
result.URI = failOutcome.Fail.URI
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
if analyzer.Key != "" {
|
||||
if foundSecret.Key != analyzer.Key || !foundSecret.KeyExists {
|
||||
result.IsFail = true
|
||||
result.Message = failOutcome.Fail.Message
|
||||
result.URI = failOutcome.Fail.URI
|
||||
|
||||
return &result, nil
|
||||
if failOutcome != nil {
|
||||
result.Message = failOutcome.Message
|
||||
result.URI = failOutcome.URI
|
||||
outcomeConfigured = true
|
||||
}
|
||||
}
|
||||
|
||||
result.IsPass = true
|
||||
for _, outcome := range analyzer.Outcomes {
|
||||
if outcome.Pass != nil {
|
||||
result.Message = outcome.Pass.Message
|
||||
result.URI = outcome.Pass.URI
|
||||
if !outcomeConfigured {
|
||||
switch {
|
||||
case result.IsPass:
|
||||
result.Message = fmt.Sprintf("Secret %s was found in namespace %s", analyzer.SecretName, analyzer.Namespace)
|
||||
case analyzer.Key != "" && foundSecret.SecretExists:
|
||||
result.Message = fmt.Sprintf("Key %s was not found in secret %s/%s", analyzer.Key, analyzer.Namespace, analyzer.SecretName)
|
||||
default:
|
||||
result.Message = fmt.Sprintf("Secret %s was not found in namespace %s", analyzer.SecretName, analyzer.Namespace)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,118 @@ func Test_analyzeSecret(t *testing.T) {
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "not found with no fail outcome falls back to a default message (no configured outcome for this branch)",
|
||||
analyzer: &troubleshootv1beta2.AnalyzeSecret{
|
||||
AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{
|
||||
CheckName: "Optional Secret",
|
||||
},
|
||||
Namespace: "default",
|
||||
SecretName: "does-not-exist",
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Pass: &troubleshootv1beta2.SingleOutcome{
|
||||
Message: "secret found",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mockFiles: map[string][]byte{
|
||||
"secrets/default/does-not-exist.json": mustJSONMarshalIndent(t, collect.SecretOutput{
|
||||
Namespace: "default",
|
||||
Name: "does-not-exist",
|
||||
SecretExists: false,
|
||||
}),
|
||||
},
|
||||
want: &AnalyzeResult{
|
||||
IsFail: true,
|
||||
Message: "Secret does-not-exist was not found in namespace default",
|
||||
Title: "Optional Secret",
|
||||
IconKey: "kubernetes_analyze_secret",
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "key not found with no fail outcome falls back to a default message (no configured outcome for this branch)",
|
||||
analyzer: &troubleshootv1beta2.AnalyzeSecret{
|
||||
AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{
|
||||
CheckName: "Optional Secret Key",
|
||||
},
|
||||
Namespace: "test-namespace",
|
||||
SecretName: "test-secret",
|
||||
Key: "missing-key",
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Pass: &troubleshootv1beta2.SingleOutcome{
|
||||
Message: "key found",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mockFiles: map[string][]byte{
|
||||
"secrets/test-namespace/test-secret/missing-key.json": mustJSONMarshalIndent(t, collect.SecretOutput{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-secret",
|
||||
Key: "missing-key",
|
||||
SecretExists: true,
|
||||
KeyExists: false,
|
||||
}),
|
||||
},
|
||||
want: &AnalyzeResult{
|
||||
IsFail: true,
|
||||
Message: "Key missing-key was not found in secret test-namespace/test-secret",
|
||||
Title: "Optional Secret Key",
|
||||
IconKey: "kubernetes_analyze_secret",
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "found with only fail outcome configured falls back to the default pass message, not the fail outcome's message",
|
||||
analyzer: &troubleshootv1beta2.AnalyzeSecret{
|
||||
Namespace: "test-namespace",
|
||||
SecretName: "test-secret",
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Fail: &troubleshootv1beta2.SingleOutcome{
|
||||
Message: "Not found",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mockFiles: map[string][]byte{
|
||||
"secrets/test-namespace/test-secret.json": mustJSONMarshalIndent(t, collect.SecretOutput{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-secret",
|
||||
SecretExists: true,
|
||||
}),
|
||||
},
|
||||
want: &AnalyzeResult{
|
||||
IsPass: true,
|
||||
Message: "Secret test-secret was found in namespace test-namespace",
|
||||
Title: "Secret test-secret",
|
||||
IconKey: "kubernetes_analyze_secret",
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "spec with neither fail nor pass outcome returns an error so the framework surfaces the misconfiguration",
|
||||
analyzer: &troubleshootv1beta2.AnalyzeSecret{
|
||||
AnalyzeMeta: troubleshootv1beta2.AnalyzeMeta{
|
||||
CheckName: "Misconfigured",
|
||||
},
|
||||
Namespace: "default",
|
||||
SecretName: "does-not-exist",
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{},
|
||||
},
|
||||
mockFiles: map[string][]byte{
|
||||
"secrets/default/does-not-exist.json": mustJSONMarshalIndent(t, collect.SecretOutput{
|
||||
Namespace: "default",
|
||||
Name: "does-not-exist",
|
||||
SecretExists: false,
|
||||
}),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key not found secret not found",
|
||||
analyzer: &troubleshootv1beta2.AnalyzeSecret{
|
||||
@@ -190,6 +302,128 @@ func Test_analyzeSecret(t *testing.T) {
|
||||
},
|
||||
wantErr: true, // TODO: should this be a not found error? This will not work with selectors.
|
||||
},
|
||||
{
|
||||
name: "combined fail and pass in a single outcome, secret found uses the pass outcome",
|
||||
analyzer: &troubleshootv1beta2.AnalyzeSecret{
|
||||
Namespace: "test-namespace",
|
||||
SecretName: "test-secret",
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Fail: &troubleshootv1beta2.SingleOutcome{
|
||||
Message: "Not found",
|
||||
},
|
||||
Pass: &troubleshootv1beta2.SingleOutcome{
|
||||
Message: "Found",
|
||||
URI: "https://example.com/found",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mockFiles: map[string][]byte{
|
||||
"secrets/test-namespace/test-secret.json": mustJSONMarshalIndent(t, collect.SecretOutput{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-secret",
|
||||
SecretExists: true,
|
||||
}),
|
||||
},
|
||||
want: &AnalyzeResult{
|
||||
IsPass: true,
|
||||
Message: "Found",
|
||||
URI: "https://example.com/found",
|
||||
Title: "Secret test-secret",
|
||||
IconKey: "kubernetes_analyze_secret",
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "combined fail and pass in a single outcome, secret not found uses the fail outcome",
|
||||
analyzer: &troubleshootv1beta2.AnalyzeSecret{
|
||||
Namespace: "test-namespace",
|
||||
SecretName: "test-secret",
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Fail: &troubleshootv1beta2.SingleOutcome{
|
||||
Message: "Not found",
|
||||
},
|
||||
Pass: &troubleshootv1beta2.SingleOutcome{
|
||||
Message: "Found",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mockFiles: map[string][]byte{
|
||||
"secrets/test-namespace/test-secret.json": mustJSONMarshalIndent(t, collect.SecretOutput{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-secret",
|
||||
SecretExists: false,
|
||||
}),
|
||||
},
|
||||
want: &AnalyzeResult{
|
||||
IsFail: true,
|
||||
Message: "Not found",
|
||||
Title: "Secret test-secret",
|
||||
IconKey: "kubernetes_analyze_secret",
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "secret found with URI-only pass outcome preserves the empty message and URI",
|
||||
analyzer: &troubleshootv1beta2.AnalyzeSecret{
|
||||
Namespace: "test-namespace",
|
||||
SecretName: "test-secret",
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Pass: &troubleshootv1beta2.SingleOutcome{
|
||||
URI: "https://example.com/pass",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mockFiles: map[string][]byte{
|
||||
"secrets/test-namespace/test-secret.json": mustJSONMarshalIndent(t, collect.SecretOutput{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-secret",
|
||||
SecretExists: true,
|
||||
}),
|
||||
},
|
||||
want: &AnalyzeResult{
|
||||
IsPass: true,
|
||||
Message: "",
|
||||
URI: "https://example.com/pass",
|
||||
Title: "Secret test-secret",
|
||||
IconKey: "kubernetes_analyze_secret",
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "secret not found with URI-only fail outcome preserves the empty message and URI",
|
||||
analyzer: &troubleshootv1beta2.AnalyzeSecret{
|
||||
Namespace: "test-namespace",
|
||||
SecretName: "test-secret",
|
||||
Outcomes: []*troubleshootv1beta2.Outcome{
|
||||
{
|
||||
Fail: &troubleshootv1beta2.SingleOutcome{
|
||||
URI: "https://example.com/fail",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mockFiles: map[string][]byte{
|
||||
"secrets/test-namespace/test-secret.json": mustJSONMarshalIndent(t, collect.SecretOutput{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-secret",
|
||||
SecretExists: false,
|
||||
}),
|
||||
},
|
||||
want: &AnalyzeResult{
|
||||
IsFail: true,
|
||||
Message: "",
|
||||
URI: "https://example.com/fail",
|
||||
Title: "Secret test-secret",
|
||||
IconKey: "kubernetes_analyze_secret",
|
||||
IconURI: "https://troubleshoot.sh/images/analyzer-icons/secret.svg?w=13&h=16",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -261,7 +261,7 @@ func (c *Collect) AccessReviewSpecs(overrideNS string) []authorizationv1.SelfSub
|
||||
result = append(result, authorizationv1.SelfSubjectAccessReviewSpec{
|
||||
ResourceAttributes: &authorizationv1.ResourceAttributes{
|
||||
Namespace: pickNamespaceOrDefault(c.Exec.Namespace, overrideNS),
|
||||
Verb: "get",
|
||||
Verb: "create",
|
||||
Group: "",
|
||||
Version: "",
|
||||
Resource: "pods",
|
||||
@@ -286,7 +286,7 @@ func (c *Collect) AccessReviewSpecs(overrideNS string) []authorizationv1.SelfSub
|
||||
result = append(result, authorizationv1.SelfSubjectAccessReviewSpec{
|
||||
ResourceAttributes: &authorizationv1.ResourceAttributes{
|
||||
Namespace: pickNamespaceOrDefault(c.Copy.Namespace, overrideNS),
|
||||
Verb: "get",
|
||||
Verb: "create",
|
||||
Group: "",
|
||||
Version: "",
|
||||
Resource: "pods",
|
||||
|
||||
@@ -130,9 +130,10 @@ type Distribution struct {
|
||||
}
|
||||
|
||||
type NodeResources struct {
|
||||
AnalyzeMeta `json:",inline" yaml:",inline"`
|
||||
Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"`
|
||||
Filters *NodeResourceFilters `json:"filters,omitempty" yaml:"filters,omitempty"`
|
||||
AnalyzeMeta `json:",inline" yaml:",inline"`
|
||||
Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"`
|
||||
Filters *NodeResourceFilters `json:"filters,omitempty" yaml:"filters,omitempty"`
|
||||
IgnoreIfNoFiles bool `json:"ignoreIfNoFiles,omitempty" yaml:"ignoreIfNoFiles,omitempty"`
|
||||
}
|
||||
|
||||
type NodeResourceFilters struct {
|
||||
@@ -304,6 +305,7 @@ type Analyze struct {
|
||||
Mssql *DatabaseAnalyze `json:"mssql,omitempty" yaml:"mssql,omitempty"`
|
||||
Mysql *DatabaseAnalyze `json:"mysql,omitempty" yaml:"mysql,omitempty"`
|
||||
Redis *DatabaseAnalyze `json:"redis,omitempty" yaml:"redis,omitempty"`
|
||||
ClickHouse *DatabaseAnalyze `json:"clickhouse,omitempty" yaml:"clickhouse,omitempty"`
|
||||
CephStatus *CephStatusAnalyze `json:"cephStatus,omitempty" yaml:"cephStatus,omitempty"`
|
||||
Velero *VeleroAnalyze `json:"velero,omitempty" yaml:"velero,omitempty"`
|
||||
Longhorn *LonghornAnalyze `json:"longhorn,omitempty" yaml:"longhorn,omitempty"`
|
||||
|
||||
@@ -353,6 +353,7 @@ type Collect struct {
|
||||
Mssql *Database `json:"mssql,omitempty" yaml:"mssql,omitempty"`
|
||||
Mysql *Database `json:"mysql,omitempty" yaml:"mysql,omitempty"`
|
||||
Redis *Database `json:"redis,omitempty" yaml:"redis,omitempty"`
|
||||
ClickHouse *Database `json:"clickhouse,omitempty" yaml:"clickhouse,omitempty"`
|
||||
Collectd *Collectd `json:"collectd,omitempty" yaml:"collectd,omitempty"`
|
||||
Ceph *Ceph `json:"ceph,omitempty" yaml:"ceph,omitempty"`
|
||||
Longhorn *Longhorn `json:"longhorn,omitempty" yaml:"longhorn,omitempty"`
|
||||
@@ -529,7 +530,7 @@ func (c *Collect) AccessReviewSpecs(overrideNS string) []authorizationv1.SelfSub
|
||||
result = append(result, authorizationv1.SelfSubjectAccessReviewSpec{
|
||||
ResourceAttributes: &authorizationv1.ResourceAttributes{
|
||||
Namespace: pickNamespaceOrDefault(c.Exec.Namespace, overrideNS),
|
||||
Verb: "get",
|
||||
Verb: "create",
|
||||
Group: "",
|
||||
Version: "",
|
||||
Resource: "pods",
|
||||
@@ -554,7 +555,7 @@ func (c *Collect) AccessReviewSpecs(overrideNS string) []authorizationv1.SelfSub
|
||||
result = append(result, authorizationv1.SelfSubjectAccessReviewSpec{
|
||||
ResourceAttributes: &authorizationv1.ResourceAttributes{
|
||||
Namespace: pickNamespaceOrDefault(c.Copy.Namespace, overrideNS),
|
||||
Verb: "get",
|
||||
Verb: "create",
|
||||
Group: "",
|
||||
Version: "",
|
||||
Resource: "pods",
|
||||
@@ -680,6 +681,10 @@ func (c *Collect) GetName() string {
|
||||
collector = "redis"
|
||||
name = c.Redis.CollectorName
|
||||
}
|
||||
if c.ClickHouse != nil {
|
||||
collector = "clickhouse"
|
||||
name = c.ClickHouse.CollectorName
|
||||
}
|
||||
if c.Collectd != nil {
|
||||
collector = "collectd"
|
||||
name = c.Collectd.CollectorName
|
||||
|
||||
@@ -153,6 +153,12 @@ type HostSysctlAnalyze struct {
|
||||
Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"`
|
||||
}
|
||||
|
||||
type HostRegistryImagesAnalyze struct {
|
||||
AnalyzeMeta `json:",inline" yaml:",inline"`
|
||||
CollectorName string `json:"collectorName,omitempty" yaml:"collectorName,omitempty"`
|
||||
Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"`
|
||||
}
|
||||
|
||||
type HostAnalyze struct {
|
||||
CPU *CPUAnalyze `json:"cpu,omitempty" yaml:"cpu,omitempty"`
|
||||
TCPLoadBalancer *TCPLoadBalancerAnalyze `json:"tcpLoadBalancer,omitempty" yaml:"tcpLoadBalancer,omitempty"`
|
||||
@@ -180,4 +186,5 @@ type HostAnalyze struct {
|
||||
JsonCompare *JsonCompare `json:"jsonCompare,omitempty" yaml:"jsonCompare,omitempty"`
|
||||
NetworkNamespaceConnectivity *NetworkNamespaceConnectivityAnalyze `json:"networkNamespaceConnectivity,omitempty" yaml:"networkNamespaceConnectivity,omitempty"`
|
||||
Sysctl *HostSysctlAnalyze `json:"sysctl,omitempty" yaml:"sysctl,omitempty"`
|
||||
RegistryImages *HostRegistryImagesAnalyze `json:"registryImages,omitempty" yaml:"registryImages,omitempty"`
|
||||
}
|
||||
|
||||
@@ -235,6 +235,16 @@ type HostSysctl struct {
|
||||
HostCollectorMeta `json:",inline" yaml:",inline"`
|
||||
}
|
||||
|
||||
// HostRegistryImages checks whether images are accessible from the host,
|
||||
// without requiring a Kubernetes cluster. Auth can be supplied inline via
|
||||
// Username/Password or omitted to rely on ambient credentials (e.g. ~/.docker/config.json).
|
||||
type HostRegistryImages struct {
|
||||
HostCollectorMeta `json:",inline" yaml:",inline"`
|
||||
Images []string `json:"images" yaml:"images"`
|
||||
Username string `json:"username,omitempty" yaml:"username,omitempty"`
|
||||
Password string `json:"password,omitempty" yaml:"password,omitempty"`
|
||||
}
|
||||
|
||||
type HostCollect struct {
|
||||
CPU *CPU `json:"cpu,omitempty" yaml:"cpu,omitempty"`
|
||||
Memory *Memory `json:"memory,omitempty" yaml:"memory,omitempty"`
|
||||
@@ -265,6 +275,7 @@ type HostCollect struct {
|
||||
HostDNS *HostDNS `json:"dns,omitempty" yaml:"dns,omitempty"`
|
||||
NetworkNamespaceConnectivity *HostNetworkNamespaceConnectivity `json:"networkNamespaceConnectivity,omitempty" yaml:"networkNamespaceConnectivity,omitempty"`
|
||||
HostSysctl *HostSysctl `json:"sysctl,omitempty" yaml:"sysctl,omitempty"`
|
||||
RegistryImages *HostRegistryImages `json:"registryImages,omitempty" yaml:"registryImages,omitempty"`
|
||||
}
|
||||
|
||||
// GetName gets the name of the collector
|
||||
|
||||
@@ -175,6 +175,11 @@ func (in *Analyze) DeepCopyInto(out *Analyze) {
|
||||
*out = new(DatabaseAnalyze)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.ClickHouse != nil {
|
||||
in, out := &in.ClickHouse, &out.ClickHouse
|
||||
*out = new(DatabaseAnalyze)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.CephStatus != nil {
|
||||
in, out := &in.CephStatus, &out.CephStatus
|
||||
*out = new(CephStatusAnalyze)
|
||||
@@ -935,6 +940,11 @@ func (in *Collect) DeepCopyInto(out *Collect) {
|
||||
*out = new(Database)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.ClickHouse != nil {
|
||||
in, out := &in.ClickHouse, &out.ClickHouse
|
||||
*out = new(Database)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.Collectd != nil {
|
||||
in, out := &in.Collectd, &out.Collectd
|
||||
*out = new(Collectd)
|
||||
@@ -1999,6 +2009,11 @@ func (in *HostAnalyze) DeepCopyInto(out *HostAnalyze) {
|
||||
*out = new(HostSysctlAnalyze)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.RegistryImages != nil {
|
||||
in, out := &in.RegistryImages, &out.RegistryImages
|
||||
*out = new(HostRegistryImagesAnalyze)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostAnalyze.
|
||||
@@ -2239,6 +2254,11 @@ func (in *HostCollect) DeepCopyInto(out *HostCollect) {
|
||||
*out = new(HostSysctl)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.RegistryImages != nil {
|
||||
in, out := &in.RegistryImages, &out.RegistryImages
|
||||
*out = new(HostRegistryImages)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostCollect.
|
||||
@@ -2684,6 +2704,54 @@ func (in *HostPreflightStatus) DeepCopy() *HostPreflightStatus {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *HostRegistryImages) DeepCopyInto(out *HostRegistryImages) {
|
||||
*out = *in
|
||||
in.HostCollectorMeta.DeepCopyInto(&out.HostCollectorMeta)
|
||||
if in.Images != nil {
|
||||
in, out := &in.Images, &out.Images
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostRegistryImages.
|
||||
func (in *HostRegistryImages) DeepCopy() *HostRegistryImages {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(HostRegistryImages)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *HostRegistryImagesAnalyze) DeepCopyInto(out *HostRegistryImagesAnalyze) {
|
||||
*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 HostRegistryImagesAnalyze.
|
||||
func (in *HostRegistryImagesAnalyze) DeepCopy() *HostRegistryImagesAnalyze {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(HostRegistryImagesAnalyze)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *HostRun) DeepCopyInto(out *HostRun) {
|
||||
*out = *in
|
||||
|
||||
@@ -68,10 +68,11 @@ type HostCollect struct {
|
||||
// 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"`
|
||||
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"`
|
||||
ClickHouse *Database `json:"clickhouse,omitempty" yaml:"clickhouse,omitempty"`
|
||||
|
||||
// TODO: Add remaining collector types as we expand v1beta3 support
|
||||
// For now, these are placeholders to make the types compile
|
||||
|
||||
@@ -81,6 +81,14 @@ func convertCollector(
|
||||
v2collector.Redis = db
|
||||
}
|
||||
|
||||
if v3collector.ClickHouse != nil {
|
||||
db, err := convertDatabase(ctx, v3collector.ClickHouse, client, defaultNamespace)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert clickhouse collector: %w", err)
|
||||
}
|
||||
v2collector.ClickHouse = db
|
||||
}
|
||||
|
||||
// TODO: Add conversion for other collector types as v1beta3 support expands
|
||||
|
||||
return v2collector, nil
|
||||
|
||||
@@ -171,7 +171,17 @@ func TestConvertToV1Beta2WithResolution_MultipleDatabases(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
client := fake.NewSimpleClientset(pgSecret, mysqlSecret, redisSecret)
|
||||
clickhouseSecret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "clickhouse-secret",
|
||||
Namespace: "default",
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"uri": []byte("http://clickhouse.example.com:9000"),
|
||||
},
|
||||
}
|
||||
|
||||
client := fake.NewSimpleClientset(pgSecret, mysqlSecret, redisSecret, clickhouseSecret)
|
||||
|
||||
v3spec := &SupportBundleSpec{
|
||||
Collectors: []*Collect{
|
||||
@@ -211,13 +221,25 @@ func TestConvertToV1Beta2WithResolution_MultipleDatabases(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ClickHouse: &Database{
|
||||
URI: StringOrValueFrom{
|
||||
ValueFrom: &ValueFromSource{
|
||||
SecretKeyRef: &SecretKeyRef{
|
||||
Name: "clickhouse-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.Len(t, v2spec.Collectors, 4)
|
||||
|
||||
require.NotNil(t, v2spec.Collectors[0].Postgres)
|
||||
assert.Equal(t, "postgresql://user:pass@pg.example.com:5432/db", v2spec.Collectors[0].Postgres.URI)
|
||||
@@ -227,6 +249,8 @@ func TestConvertToV1Beta2WithResolution_MultipleDatabases(t *testing.T) {
|
||||
|
||||
require.NotNil(t, v2spec.Collectors[2].Redis)
|
||||
assert.Equal(t, "redis://redis.example.com:6379", v2spec.Collectors[2].Redis.URI)
|
||||
require.NotNil(t, v2spec.Collectors[3].ClickHouse)
|
||||
assert.Equal(t, "http://clickhouse.example.com:9000", v2spec.Collectors[3].ClickHouse.URI)
|
||||
}
|
||||
|
||||
func TestConvertToV1Beta2WithResolution_SecretNotFound(t *testing.T) {
|
||||
|
||||
@@ -79,6 +79,11 @@ func (in *Collect) DeepCopyInto(out *Collect) {
|
||||
*out = new(Database)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.ClickHouse != nil {
|
||||
in, out := &in.ClickHouse, &out.ClickHouse
|
||||
*out = new(Database)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Collect.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/pkg/errors"
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
var _ Collector = &CollectClickhouse{}
|
||||
|
||||
type CollectClickhouse struct {
|
||||
Collector *troubleshootv1beta2.Database
|
||||
BundlePath string
|
||||
Namespace string
|
||||
ClientConfig *rest.Config
|
||||
Client kubernetes.Interface
|
||||
Context context.Context
|
||||
RBACErrors
|
||||
}
|
||||
|
||||
func (c *CollectClickhouse) Title() string {
|
||||
return getCollectorName(c)
|
||||
}
|
||||
|
||||
func (c *CollectClickhouse) IsExcluded() (bool, error) {
|
||||
return isExcluded(c.Collector.Exclude)
|
||||
}
|
||||
|
||||
func (c *CollectClickhouse) createConnectConfig() (*clickhouse.Options, error) {
|
||||
if c.Collector.URI == "" {
|
||||
return nil, errors.New("clickhouse uri cannot be empty")
|
||||
}
|
||||
|
||||
opts, err := clickhouse.ParseDSN(c.Collector.URI)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "couldn't parse clickhouse URI")
|
||||
}
|
||||
|
||||
if c.Collector.TLS != nil {
|
||||
tlsCfg, err := createTLSConfig(c.Context, c.Client, c.Collector.TLS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.TLS = tlsCfg
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func (c *CollectClickhouse) connect() (driver.Conn, error) {
|
||||
config, err := c.createConnectConfig()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create clickhouse connection config")
|
||||
}
|
||||
|
||||
conn, err := clickhouse.Open(config)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to open clickhouse connection")
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (c *CollectClickhouse) Collect(progressChan chan<- interface{}) (CollectorResult, error) {
|
||||
databaseConnection := DatabaseConnection{}
|
||||
|
||||
conn, err := c.connect()
|
||||
if err != nil {
|
||||
databaseConnection.Error = err.Error()
|
||||
} else {
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.Ping(c.Context); err != nil {
|
||||
databaseConnection.Error = err.Error()
|
||||
} else {
|
||||
|
||||
var version string
|
||||
err := conn.QueryRow(c.Context, "SELECT version()").Scan(&version)
|
||||
if err != nil {
|
||||
databaseConnection.Error = err.Error()
|
||||
} else {
|
||||
databaseConnection.Version = version
|
||||
databaseConnection.IsConnected = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(databaseConnection)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal database connection")
|
||||
}
|
||||
|
||||
collectorName := c.Collector.CollectorName
|
||||
if collectorName == "" {
|
||||
collectorName = "clickhouse"
|
||||
}
|
||||
|
||||
output := NewResult()
|
||||
output.SaveResult(c.BundlePath, fmt.Sprintf("clickhouse/%s.json", collectorName), bytes.NewBuffer(b))
|
||||
return output, nil
|
||||
}
|
||||
@@ -294,7 +294,18 @@ func (c *CollectClusterResources) Collect(progressChan chan<- interface{}) (Coll
|
||||
// crs
|
||||
customResources, crErrors := crs(ctx, dynamicClient, client, c.ClientConfig, namespaceNames)
|
||||
for k, v := range customResources {
|
||||
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES, k), bytes.NewBuffer(v))
|
||||
jsonPath := path.Join(constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES, k)
|
||||
output.SaveResult(c.BundlePath, jsonPath, bytes.NewBuffer(v))
|
||||
|
||||
// Keep a YAML symlink for backward compatibility. It points at the JSON
|
||||
// file, so any analyzer that expects YAML can still read it, and the
|
||||
// content is redacted through the JSON copy.
|
||||
if strings.HasSuffix(k, ".json") {
|
||||
yamlPath := path.Join(constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES, strings.TrimSuffix(k, ".json")+".yaml")
|
||||
if err := output.SymLinkResult(c.BundlePath, yamlPath, jsonPath); err != nil {
|
||||
klog.V(2).Infof("failed to create YAML symlink for %s: %v", jsonPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES, fmt.Sprintf("%s-errors.json", constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES)), marshalErrors(crErrors))
|
||||
|
||||
@@ -411,6 +422,16 @@ 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))
|
||||
|
||||
// Validating Webhook Configurations
|
||||
validatingWebhookConfigurations, validatingWebhookConfigurationsErrors := validatingWebhookConfigurations(ctx, client)
|
||||
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_VALIDATING_WEBHOOK_CONFIGURATIONS)), bytes.NewBuffer(validatingWebhookConfigurations))
|
||||
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s-errors.json", constants.CLUSTER_RESOURCES_VALIDATING_WEBHOOK_CONFIGURATIONS)), marshalErrors(validatingWebhookConfigurationsErrors))
|
||||
|
||||
// Mutating Webhook Configurations
|
||||
mutatingWebhookConfigurations, mutatingWebhookConfigurationsErrors := mutatingWebhookConfigurations(ctx, client)
|
||||
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s.json", constants.CLUSTER_RESOURCES_MUTATING_WEBHOOK_CONFIGURATIONS)), bytes.NewBuffer(mutatingWebhookConfigurations))
|
||||
output.SaveResult(c.BundlePath, path.Join(constants.CLUSTER_RESOURCES_DIR, fmt.Sprintf("%s-errors.json", constants.CLUSTER_RESOURCES_MUTATING_WEBHOOK_CONFIGURATIONS)), marshalErrors(mutatingWebhookConfigurationsErrors))
|
||||
|
||||
// Replicated License
|
||||
licenseData, licenseErr := replicatedLicense(ctx, client, namespaceNames)
|
||||
if licenseErr == nil {
|
||||
@@ -2234,23 +2255,68 @@ func configMaps(ctx context.Context, client kubernetes.Interface, namespaces []s
|
||||
return configmapByNamespace, errorsByNamespace
|
||||
}
|
||||
|
||||
// storeCustomResource stores a custom resource as JSON and YAML
|
||||
// We use both formats for backwards compatibility. This way we
|
||||
// avoid breaking existing tools and analysers that already rely on
|
||||
// the YAML format.
|
||||
func validatingWebhookConfigurations(ctx context.Context, client kubernetes.Interface) ([]byte, []string) {
|
||||
validatingWebhookConfigurations, err := client.AdmissionregistrationV1().ValidatingWebhookConfigurations().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, []string{err.Error()}
|
||||
}
|
||||
|
||||
gvk, err := apiutil.GVKForObject(validatingWebhookConfigurations, scheme.Scheme)
|
||||
if err == nil {
|
||||
validatingWebhookConfigurations.GetObjectKind().SetGroupVersionKind(gvk)
|
||||
}
|
||||
|
||||
for i, o := range validatingWebhookConfigurations.Items {
|
||||
gvk, err := apiutil.GVKForObject(&o, scheme.Scheme)
|
||||
if err == nil {
|
||||
validatingWebhookConfigurations.Items[i].GetObjectKind().SetGroupVersionKind(gvk)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.MarshalIndent(validatingWebhookConfigurations, "", " ")
|
||||
if err != nil {
|
||||
return nil, []string{err.Error()}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func mutatingWebhookConfigurations(ctx context.Context, client kubernetes.Interface) ([]byte, []string) {
|
||||
mutatingWebhookConfigurations, err := client.AdmissionregistrationV1().MutatingWebhookConfigurations().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, []string{err.Error()}
|
||||
}
|
||||
|
||||
gvk, err := apiutil.GVKForObject(mutatingWebhookConfigurations, scheme.Scheme)
|
||||
if err == nil {
|
||||
mutatingWebhookConfigurations.GetObjectKind().SetGroupVersionKind(gvk)
|
||||
}
|
||||
|
||||
for i, o := range mutatingWebhookConfigurations.Items {
|
||||
gvk, err := apiutil.GVKForObject(&o, scheme.Scheme)
|
||||
if err == nil {
|
||||
mutatingWebhookConfigurations.Items[i].GetObjectKind().SetGroupVersionKind(gvk)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.MarshalIndent(mutatingWebhookConfigurations, "", " ")
|
||||
if err != nil {
|
||||
return nil, []string{err.Error()}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// storeCustomResource stores a custom resource as JSON only.
|
||||
// JSON is valid YAML, so any analyzer expecting YAML can consume the JSON
|
||||
// file directly. We no longer write a separate YAML file because the
|
||||
// built-in redactors are authored for JSON, and the duplicate YAML copy
|
||||
// would otherwise be left unredacted.
|
||||
func storeCustomResource(name string, objects any, m map[string][]byte) error {
|
||||
j, err := json.MarshalIndent(objects, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
y, err := yaml.Marshal(objects)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m[fmt.Sprintf("%s.json", name)] = j
|
||||
m[fmt.Sprintf("%s.yaml", name)] = y
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
|
||||
certificatesv1 "k8s.io/api/certificates/v1"
|
||||
v1 "k8s.io/api/coordination/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
@@ -27,7 +28,6 @@ import (
|
||||
"k8s.io/client-go/kubernetes"
|
||||
testclient "k8s.io/client-go/kubernetes/fake"
|
||||
k8stesting "k8s.io/client-go/testing"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -511,17 +511,8 @@ func TestCollectClusterResources_CustomResource(t *testing.T) {
|
||||
// Fetch the CR from cluster
|
||||
res, errs := crsV1(ctx, dynamicClient, apixClient.ApiextensionsV1(), []string{"default"})
|
||||
assert.Empty(t, errs)
|
||||
require.Equal(t, 2, len(res))
|
||||
require.Equal(t, 1, len(res))
|
||||
assert.Equal(t, fromJSON(t, res["supportbundles.troubleshoot.sh/default.json"]), sbObject)
|
||||
assert.Equal(t, fromYAML(t, res["supportbundles.troubleshoot.sh/default.yaml"]), sbObject)
|
||||
}
|
||||
|
||||
func fromYAML(t *testing.T, dat []byte) troubleshootv1beta2.SupportBundle {
|
||||
sb := []troubleshootv1beta2.SupportBundle{}
|
||||
err := yaml.Unmarshal(dat, &sb)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(sb))
|
||||
return sb[0]
|
||||
}
|
||||
|
||||
func fromJSON(t *testing.T, dat []byte) troubleshootv1beta2.SupportBundle {
|
||||
@@ -776,3 +767,167 @@ func createTestCertificateSigningRequests(client kubernetes.Interface, csrNames
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Test_ValidatingWebhookConfigurations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
vwcNames []string
|
||||
}{
|
||||
{
|
||||
name: "single validating webhook configuration",
|
||||
vwcNames: []string{"test-vwc"},
|
||||
},
|
||||
{
|
||||
name: "multiple validating webhook configurations",
|
||||
vwcNames: []string{"vwc-1", "vwc-2", "vwc-3"},
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
vwcNames: []string{},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := testclient.NewSimpleClientset()
|
||||
ctx := context.Background()
|
||||
err := createTestValidatingWebhookConfigurations(client, tt.vwcNames)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, errs := validatingWebhookConfigurations(ctx, client)
|
||||
assert.Empty(t, errs)
|
||||
assert.NotNil(t, data)
|
||||
|
||||
var list admissionregistrationv1.ValidatingWebhookConfigurationList
|
||||
err = json.Unmarshal(data, &list)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list.Items, len(tt.vwcNames))
|
||||
for _, item := range list.Items {
|
||||
assert.Contains(t, tt.vwcNames, item.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ValidatingWebhookConfigurations_PermissionDenied(t *testing.T) {
|
||||
client := testclient.NewSimpleClientset()
|
||||
ctx := context.Background()
|
||||
|
||||
client.PrependReactor("list", "validatingwebhookconfigurations", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) {
|
||||
return true, nil, fmt.Errorf("validatingwebhookconfigurations.admissionregistration.k8s.io is forbidden: User \"system:serviceaccount:default:default\" cannot list resource \"validatingwebhookconfigurations\" in API group \"admissionregistration.k8s.io\" at the cluster scope")
|
||||
})
|
||||
|
||||
data, errs := validatingWebhookConfigurations(ctx, client)
|
||||
|
||||
assert.Nil(t, data)
|
||||
require.NotEmpty(t, errs)
|
||||
assert.Len(t, errs, 1)
|
||||
assert.Contains(t, errs[0], "forbidden")
|
||||
}
|
||||
|
||||
func createTestValidatingWebhookConfigurations(client kubernetes.Interface, names []string) error {
|
||||
for _, name := range names {
|
||||
_, err := client.AdmissionregistrationV1().ValidatingWebhookConfigurations().Create(context.Background(), &admissionregistrationv1.ValidatingWebhookConfiguration{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
},
|
||||
Webhooks: []admissionregistrationv1.ValidatingWebhook{
|
||||
{
|
||||
Name: "test-webhook.example.com",
|
||||
ClientConfig: admissionregistrationv1.WebhookClientConfig{
|
||||
Service: &admissionregistrationv1.ServiceReference{
|
||||
Namespace: "default",
|
||||
Name: "webhook-service",
|
||||
},
|
||||
},
|
||||
AdmissionReviewVersions: []string{"v1"},
|
||||
},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Test_MutatingWebhookConfigurations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mwcNames []string
|
||||
}{
|
||||
{
|
||||
name: "single mutating webhook configuration",
|
||||
mwcNames: []string{"test-mwc"},
|
||||
},
|
||||
{
|
||||
name: "multiple mutating webhook configurations",
|
||||
mwcNames: []string{"mwc-1", "mwc-2"},
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
mwcNames: []string{},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := testclient.NewSimpleClientset()
|
||||
ctx := context.Background()
|
||||
err := createTestMutatingWebhookConfigurations(client, tt.mwcNames)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, errs := mutatingWebhookConfigurations(ctx, client)
|
||||
assert.Empty(t, errs)
|
||||
assert.NotNil(t, data)
|
||||
|
||||
var list admissionregistrationv1.MutatingWebhookConfigurationList
|
||||
err = json.Unmarshal(data, &list)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list.Items, len(tt.mwcNames))
|
||||
for _, item := range list.Items {
|
||||
assert.Contains(t, tt.mwcNames, item.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_MutatingWebhookConfigurations_PermissionDenied(t *testing.T) {
|
||||
client := testclient.NewSimpleClientset()
|
||||
ctx := context.Background()
|
||||
|
||||
client.PrependReactor("list", "mutatingwebhookconfigurations", func(action k8stesting.Action) (handled bool, ret runtime.Object, err error) {
|
||||
return true, nil, fmt.Errorf("mutatingwebhookconfigurations.admissionregistration.k8s.io is forbidden: User \"system:serviceaccount:default:default\" cannot list resource \"mutatingwebhookconfigurations\" in API group \"admissionregistration.k8s.io\" at the cluster scope")
|
||||
})
|
||||
|
||||
data, errs := mutatingWebhookConfigurations(ctx, client)
|
||||
|
||||
assert.Nil(t, data)
|
||||
require.NotEmpty(t, errs)
|
||||
assert.Len(t, errs, 1)
|
||||
assert.Contains(t, errs[0], "forbidden")
|
||||
}
|
||||
|
||||
func createTestMutatingWebhookConfigurations(client kubernetes.Interface, names []string) error {
|
||||
for _, name := range names {
|
||||
_, err := client.AdmissionregistrationV1().MutatingWebhookConfigurations().Create(context.Background(), &admissionregistrationv1.MutatingWebhookConfiguration{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
},
|
||||
Webhooks: []admissionregistrationv1.MutatingWebhook{
|
||||
{
|
||||
Name: "test-mutating-webhook.example.com",
|
||||
ClientConfig: admissionregistrationv1.WebhookClientConfig{
|
||||
Service: &admissionregistrationv1.ServiceReference{
|
||||
Namespace: "default",
|
||||
Name: "webhook-service",
|
||||
},
|
||||
},
|
||||
AdmissionReviewVersions: []string{"v1"},
|
||||
},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"github.com/replicatedhq/troubleshoot/pkg/multitype"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
type Collector interface {
|
||||
@@ -104,6 +107,8 @@ func GetCollector(collector *troubleshootv1beta2.Collect, bundlePath string, nam
|
||||
return &CollectMysql{collector.Mysql, bundlePath, namespace, clientConfig, client, ctx, RBACErrors}, true
|
||||
case collector.Redis != nil:
|
||||
return &CollectRedis{collector.Redis, bundlePath, namespace, clientConfig, client, ctx, RBACErrors}, true
|
||||
case collector.ClickHouse != nil:
|
||||
return &CollectClickhouse{collector.ClickHouse, bundlePath, namespace, clientConfig, client, ctx, RBACErrors}, true
|
||||
case collector.Collectd != nil:
|
||||
return &CollectCollectd{collector.Collectd, bundlePath, namespace, clientConfig, client, ctx, RBACErrors}, true
|
||||
case collector.Ceph != nil:
|
||||
@@ -198,6 +203,9 @@ func getCollectorName(c interface{}) string {
|
||||
case *CollectRedis:
|
||||
collector = "redis"
|
||||
name = v.Collector.CollectorName
|
||||
case *CollectClickhouse:
|
||||
collector = "clickhouse"
|
||||
name = v.Collector.CollectorName
|
||||
case *CollectCollectd:
|
||||
collector = "collectd"
|
||||
name = v.Collector.CollectorName
|
||||
@@ -236,7 +244,6 @@ func getCollectorName(c interface{}) string {
|
||||
default:
|
||||
collector = "<none>"
|
||||
}
|
||||
|
||||
if name != "" {
|
||||
return fmt.Sprintf("%s/%s", collector, name)
|
||||
}
|
||||
@@ -297,6 +304,35 @@ func DedupCollectors(allCollectors []*troubleshootv1beta2.Collect) []*troublesho
|
||||
return finalCollectors
|
||||
}
|
||||
|
||||
// SkippedCollector records information about a collector that was skipped during collection.
|
||||
type SkippedCollector struct {
|
||||
Collector string `json:"collector"`
|
||||
Reason string `json:"reason"`
|
||||
Errors []string `json:"errors"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// WriteSkippedCollectors marshals the skipped collectors list and saves it
|
||||
// using SaveResult which handles both in-memory and on-disk storage.
|
||||
func WriteSkippedCollectors(skipped []SkippedCollector, allCollectedData map[string][]byte, bundlePath string) {
|
||||
if len(skipped) == 0 {
|
||||
return
|
||||
}
|
||||
skippedJSON, err := json.Marshal(skipped)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Either write to bundle path or memory
|
||||
c := CollectorResult{}
|
||||
if err := c.SaveResult(bundlePath, "skipped-collectors.json", bytes.NewReader(skippedJSON)); err != nil {
|
||||
klog.Errorf("Failed to save skipped collectors: %v", err)
|
||||
} else {
|
||||
// Write to collected data to return downstream
|
||||
maps.Copy(allCollectedData, c)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Copy collectors are last in the list
|
||||
// This is because copy collectors are expected to copy files from other collectors such as Exec, RunPod, RunDaemonSet
|
||||
func EnsureCopyLast(allCollectors []Collector) []Collector {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
@@ -519,3 +522,88 @@ func TestEnsureCopyLast(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSkippedCollectors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
skipped []SkippedCollector
|
||||
bundlePath string
|
||||
useTempDir bool
|
||||
wantInMap bool
|
||||
wantOnDisk bool
|
||||
wantEntries []SkippedCollector
|
||||
}{
|
||||
{
|
||||
name: "empty skipped list does nothing",
|
||||
skipped: nil,
|
||||
bundlePath: "",
|
||||
wantInMap: false,
|
||||
},
|
||||
{
|
||||
name: "in-memory only when bundlePath is empty",
|
||||
skipped: []SkippedCollector{
|
||||
{Collector: "clusterResources", Reason: "excluded", Timestamp: "2026-01-01T00:00:00Z"},
|
||||
},
|
||||
bundlePath: "",
|
||||
wantInMap: true,
|
||||
wantOnDisk: false,
|
||||
wantEntries: []SkippedCollector{
|
||||
{Collector: "clusterResources", Reason: "excluded", Timestamp: "2026-01-01T00:00:00Z"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "writes to disk when bundlePath is set",
|
||||
skipped: []SkippedCollector{
|
||||
{Collector: "clusterResources", Reason: "excluded", Timestamp: "2026-01-01T00:00:00Z"},
|
||||
{Collector: "logs", Reason: "insufficient RBAC permissions", Errors: []string{"pods is forbidden"}, Timestamp: "2026-01-01T00:00:01Z"},
|
||||
},
|
||||
useTempDir: true,
|
||||
wantInMap: true,
|
||||
wantOnDisk: true,
|
||||
wantEntries: []SkippedCollector{
|
||||
{Collector: "clusterResources", Reason: "excluded", Timestamp: "2026-01-01T00:00:00Z"},
|
||||
{Collector: "logs", Reason: "insufficient RBAC permissions", Errors: []string{"pods is forbidden"}, Timestamp: "2026-01-01T00:00:01Z"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bundlePath := tt.bundlePath
|
||||
if tt.useTempDir {
|
||||
bundlePath = t.TempDir()
|
||||
}
|
||||
|
||||
result := CollectorResult{}
|
||||
WriteSkippedCollectors(tt.skipped, result, bundlePath)
|
||||
|
||||
if !tt.wantInMap {
|
||||
assert.Empty(t, result)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify in-memory entry exists
|
||||
if bundlePath == "" {
|
||||
// In-memory mode: data is stored in the map
|
||||
data, ok := result["skipped-collectors.json"]
|
||||
require.True(t, ok, "skipped-collectors.json should be in result map")
|
||||
require.NotNil(t, data)
|
||||
|
||||
var got []SkippedCollector
|
||||
require.NoError(t, json.Unmarshal(data, &got))
|
||||
assert.Equal(t, tt.wantEntries, got)
|
||||
} else {
|
||||
// On-disk mode: map entry exists with nil value, file is on disk
|
||||
_, ok := result["skipped-collectors.json"]
|
||||
require.True(t, ok, "skipped-collectors.json should be in result map")
|
||||
|
||||
diskData, err := os.ReadFile(filepath.Join(bundlePath, "skipped-collectors.json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
var got []SkippedCollector
|
||||
require.NoError(t, json.Unmarshal(diskData, &got))
|
||||
assert.Equal(t, tt.wantEntries, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/pkg/errors"
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
@@ -97,19 +98,22 @@ func copyFilesFromPod(ctx context.Context, dstPath string, clientConfig *restcli
|
||||
return nil, nil, errors.Wrap(err, "failed to add runtime scheme")
|
||||
}
|
||||
|
||||
// Stdin must be false because StreamOptions.Stdin is nil below.
|
||||
// A mismatch causes the SPDY fallback (after WebSocket fails on RBAC)
|
||||
// to hang: the API server opens a stdin stream but never receives EOF.
|
||||
parameterCodec := runtime.NewParameterCodec(scheme)
|
||||
req.VersionedParams(&corev1.PodExecOptions{
|
||||
Command: command,
|
||||
Container: containerName,
|
||||
Stdin: true,
|
||||
Stdout: false,
|
||||
Stdin: false,
|
||||
Stdout: true,
|
||||
Stderr: true,
|
||||
TTY: false,
|
||||
}, parameterCodec)
|
||||
|
||||
exec, err := remotecommand.NewSPDYExecutor(clientConfig, "POST", req.URL())
|
||||
exec, err := k8sutil.NewFallbackExecutor(clientConfig, req.URL())
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(err, "failed to create SPDY executor")
|
||||
return nil, nil, errors.Wrap(err, "failed to create executor")
|
||||
}
|
||||
|
||||
result := NewResult()
|
||||
|
||||
@@ -299,19 +299,22 @@ func copyFilesFromHost(ctx context.Context, dstPath string, clientConfig *restcl
|
||||
return nil, nil, errors.Wrap(err, "failed to add runtime scheme")
|
||||
}
|
||||
|
||||
// Stdin must be false because StreamOptions.Stdin is nil below.
|
||||
// A mismatch causes the SPDY fallback (after WebSocket fails on RBAC)
|
||||
// to hang: the API server opens a stdin stream but never receives EOF.
|
||||
parameterCodec := runtime.NewParameterCodec(scheme)
|
||||
req.VersionedParams(&corev1.PodExecOptions{
|
||||
Command: command,
|
||||
Container: containerName,
|
||||
Stdin: true,
|
||||
Stdout: false,
|
||||
Stdin: false,
|
||||
Stdout: true,
|
||||
Stderr: true,
|
||||
TTY: false,
|
||||
}, parameterCodec)
|
||||
|
||||
exec, err := remotecommand.NewSPDYExecutor(clientConfig, "POST", req.URL())
|
||||
exec, err := k8sutil.NewFallbackExecutor(clientConfig, req.URL())
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(err, "failed to create SPDY executor")
|
||||
return nil, nil, errors.Wrap(err, "failed to create executor")
|
||||
}
|
||||
|
||||
result := NewResult()
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/pkg/errors"
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
@@ -325,7 +326,7 @@ func (c *etcdDebug) executeCommand(command string) ([]byte, []byte, error) {
|
||||
TTY: false,
|
||||
}, scheme.ParameterCodec)
|
||||
|
||||
exec, err := remotecommand.NewSPDYExecutor(c.clientConfig, "POST", req.URL())
|
||||
exec, err := k8sutil.NewFallbackExecutor(c.clientConfig, req.URL())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
+20
-6
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/pkg/errors"
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
@@ -87,16 +88,26 @@ func execWithoutTimeout(clientConfig *rest.Config, bundlePath string, execCollec
|
||||
pod := pods[0]
|
||||
stdout, stderr, execErrors := getExecOutputs(ctx, clientConfig, client, pod, execCollector)
|
||||
|
||||
container := pod.Spec.Containers[0].Name
|
||||
if execCollector.ContainerName != "" {
|
||||
container = execCollector.ContainerName
|
||||
}
|
||||
|
||||
filePrefix := execCollector.CollectorName
|
||||
if filePrefix == "" {
|
||||
filePrefix = container
|
||||
}
|
||||
|
||||
path := filepath.Join(execCollector.Name, pod.Namespace, pod.Name)
|
||||
if len(stdout) > 0 {
|
||||
output.SaveResult(bundlePath, filepath.Join(path, execCollector.CollectorName+"-stdout.txt"), bytes.NewBuffer(stdout))
|
||||
output.SaveResult(bundlePath, filepath.Join(path, filePrefix+"-stdout.txt"), bytes.NewBuffer(stdout))
|
||||
}
|
||||
if len(stderr) > 0 {
|
||||
output.SaveResult(bundlePath, filepath.Join(path, execCollector.CollectorName+"-stderr.txt"), bytes.NewBuffer(stderr))
|
||||
output.SaveResult(bundlePath, filepath.Join(path, filePrefix+"-stderr.txt"), bytes.NewBuffer(stderr))
|
||||
}
|
||||
|
||||
if len(execErrors) > 0 {
|
||||
output.SaveResult(bundlePath, filepath.Join(path, execCollector.CollectorName+"-errors.json"), marshalErrors(execErrors))
|
||||
output.SaveResult(bundlePath, filepath.Join(path, filePrefix+"-errors.json"), marshalErrors(execErrors))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,16 +129,19 @@ func getExecOutputs(
|
||||
}
|
||||
|
||||
parameterCodec := runtime.NewParameterCodec(scheme)
|
||||
// Stdin must be false because StreamOptions.Stdin is nil below.
|
||||
// A mismatch causes the SPDY fallback (after WebSocket fails on RBAC)
|
||||
// to hang: the API server opens a stdin stream but never receives EOF.
|
||||
req.VersionedParams(&corev1.PodExecOptions{
|
||||
Command: append(execCollector.Command, execCollector.Args...),
|
||||
Container: container,
|
||||
Stdin: true,
|
||||
Stdout: false,
|
||||
Stdin: false,
|
||||
Stdout: true,
|
||||
Stderr: true,
|
||||
TTY: false,
|
||||
}, parameterCodec)
|
||||
|
||||
exec, err := remotecommand.NewSPDYExecutor(clientConfig, "POST", req.URL())
|
||||
exec, err := k8sutil.NewFallbackExecutor(clientConfig, req.URL())
|
||||
if err != nil {
|
||||
return nil, nil, []string{err.Error()}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,8 @@ func GetHostCollector(collector *troubleshootv1beta2.HostCollect, bundlePath str
|
||||
return &CollectHostNetworkNamespaceConnectivity{collector.NetworkNamespaceConnectivity, bundlePath}, true
|
||||
case collector.HostSysctl != nil:
|
||||
return &CollectHostSysctl{collector.HostSysctl, bundlePath}, true
|
||||
case collector.RegistryImages != nil:
|
||||
return &CollectHostRegistryImages{collector.RegistryImages, bundlePath}, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -82,7 +82,6 @@ func loadKConfigs(kernelRelease string) (KConfigs, error) {
|
||||
// https://github.com/torvalds/linux/blob/v4.3/init/Kconfig#L794
|
||||
// https://github.com/torvalds/linux/blob/v4.3/init/Kconfig#L9
|
||||
possiblePaths := []string{
|
||||
"/proc/config.gz",
|
||||
"/boot/config-" + kernelRelease,
|
||||
"/usr/src/linux-" + kernelRelease + "/.config",
|
||||
"/usr/src/linux/.config",
|
||||
@@ -93,6 +92,13 @@ func loadKConfigs(kernelRelease string) (KConfigs, error) {
|
||||
"/lib/modules/" + kernelRelease + "/build/.config",
|
||||
}
|
||||
|
||||
// /proc/config.gz reflects the currently running kernel. Only use it when
|
||||
// the requested kernel release matches the running kernel.
|
||||
currentRelease, err := getKernelRelease()
|
||||
if err == nil && currentRelease == kernelRelease {
|
||||
possiblePaths = append([]string{"/proc/config.gz"}, possiblePaths...)
|
||||
}
|
||||
|
||||
for _, path := range possiblePaths {
|
||||
// open file for reading
|
||||
f, err := os.Open(path)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
type CollectHostRegistryImages struct {
|
||||
hostCollector *troubleshootv1beta2.HostRegistryImages
|
||||
BundlePath string
|
||||
}
|
||||
|
||||
func (c *CollectHostRegistryImages) Title() string {
|
||||
return hostCollectorTitleOrDefault(c.hostCollector.HostCollectorMeta, "Registry Images")
|
||||
}
|
||||
|
||||
func (c *CollectHostRegistryImages) IsExcluded() (bool, error) {
|
||||
return isExcluded(c.hostCollector.Exclude)
|
||||
}
|
||||
|
||||
func (c *CollectHostRegistryImages) Collect(progressChan chan<- interface{}) (map[string][]byte, error) {
|
||||
registryInfo := RegistryInfo{
|
||||
Images: map[string]RegistryImage{},
|
||||
}
|
||||
|
||||
auth := c.resolveAuth()
|
||||
if auth != nil {
|
||||
klog.V(2).Infof("using inline credentials for registry check (username=%s)", c.hostCollector.Username)
|
||||
} else {
|
||||
klog.V(2).Info("no inline credentials provided, using ambient auth")
|
||||
}
|
||||
|
||||
klog.V(2).Infof("checking %d images", len(c.hostCollector.Images))
|
||||
for _, image := range c.hostCollector.Images {
|
||||
klog.V(2).Infof("checking image: %s", image)
|
||||
imageRef, err := parseImageRef(image)
|
||||
if err != nil {
|
||||
klog.Errorf("failed to parse image ref %s: %v", image, err)
|
||||
registryInfo.Images[image] = RegistryImage{Error: err.Error()}
|
||||
continue
|
||||
}
|
||||
exists, err := imageExistsWithAuth(auth, imageRef, image, 10*time.Second)
|
||||
if err != nil {
|
||||
klog.Errorf("image check failed for %s: %v", image, err)
|
||||
registryInfo.Images[image] = RegistryImage{Error: err.Error()}
|
||||
} else {
|
||||
klog.V(2).Infof("image %s exists=%t", image, exists)
|
||||
registryInfo.Images[image] = RegistryImage{Exists: exists}
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.MarshalIndent(registryInfo, "", " ")
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal registry info")
|
||||
}
|
||||
|
||||
collectorName := c.hostCollector.CollectorName
|
||||
if collectorName == "" {
|
||||
collectorName = "images"
|
||||
}
|
||||
|
||||
name := filepath.Join("host-collectors/registry-images", collectorName+".json")
|
||||
|
||||
output := NewResult()
|
||||
output.SaveResult(c.BundlePath, name, bytes.NewBuffer(b))
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (c *CollectHostRegistryImages) RemoteCollect(progressChan chan<- interface{}) (map[string][]byte, error) {
|
||||
return nil, ErrRemoteCollectorNotImplemented
|
||||
}
|
||||
|
||||
// resolveAuth returns auth config from inline credentials or nil for ambient auth.
|
||||
func (c *CollectHostRegistryImages) resolveAuth() *registryAuthConfig {
|
||||
if c.hostCollector.Username != "" {
|
||||
return ®istryAuthConfig{
|
||||
username: c.hostCollector.Username,
|
||||
password: c.hostCollector.Password,
|
||||
}
|
||||
}
|
||||
// No credentials: rely on ambient auth (~/.docker/config.json)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCollectHostRegistryImagesTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
meta troubleshootv1beta2.HostCollectorMeta
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "default title",
|
||||
meta: troubleshootv1beta2.HostCollectorMeta{},
|
||||
expected: "Registry Images",
|
||||
},
|
||||
{
|
||||
name: "custom title",
|
||||
meta: troubleshootv1beta2.HostCollectorMeta{
|
||||
CollectorName: "My Registry",
|
||||
},
|
||||
expected: "My Registry",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
c := &CollectHostRegistryImages{
|
||||
hostCollector: &troubleshootv1beta2.HostRegistryImages{
|
||||
HostCollectorMeta: test.meta,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, test.expected, c.Title())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectHostRegistryImagesResolveAuth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
password string
|
||||
expected *registryAuthConfig
|
||||
}{
|
||||
{
|
||||
name: "nil when no credentials",
|
||||
username: "",
|
||||
password: "",
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "returns auth with credentials",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
expected: ®istryAuthConfig{
|
||||
username: "user",
|
||||
password: "pass",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "returns auth with username only",
|
||||
username: "user",
|
||||
password: "",
|
||||
expected: ®istryAuthConfig{
|
||||
username: "user",
|
||||
password: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
c := &CollectHostRegistryImages{
|
||||
hostCollector: &troubleshootv1beta2.HostRegistryImages{
|
||||
Username: test.username,
|
||||
Password: test.password,
|
||||
},
|
||||
}
|
||||
result := c.resolveAuth()
|
||||
assert.Equal(t, test.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectHostRegistryImagesRemoteCollect(t *testing.T) {
|
||||
c := &CollectHostRegistryImages{
|
||||
hostCollector: &troubleshootv1beta2.HostRegistryImages{},
|
||||
}
|
||||
result, err := c.RemoteCollect(nil)
|
||||
require.ErrorIs(t, err, ErrRemoteCollectorNotImplemented)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/pkg/errors"
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
|
||||
longhornv1beta1types "github.com/replicatedhq/troubleshoot/pkg/longhorn/apis/longhorn/v1beta1"
|
||||
longhornv1beta1 "github.com/replicatedhq/troubleshoot/pkg/longhorn/client/clientset/versioned/typed/longhorn/v1beta1"
|
||||
longhorntypes "github.com/replicatedhq/troubleshoot/pkg/longhorn/types"
|
||||
@@ -391,7 +392,7 @@ func GetLonghornReplicaChecksum(clientConfig *rest.Config, replica longhornv1bet
|
||||
Param("command", "-c").
|
||||
Param("command", fmt.Sprintf("if [ -d %s ]; then md5sum %s/*; fi", dir, dir))
|
||||
|
||||
executor, err := remotecommand.NewSPDYExecutor(clientConfig, "POST", req.URL())
|
||||
executor, err := k8sutil.NewFallbackExecutor(clientConfig, req.URL())
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "create remote exec")
|
||||
}
|
||||
|
||||
+51
-3
@@ -7,11 +7,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/pkg/errors"
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
type CollectMysql struct {
|
||||
@@ -32,11 +33,58 @@ func (c *CollectMysql) IsExcluded() (bool, error) {
|
||||
return isExcluded(c.Collector.Exclude)
|
||||
}
|
||||
|
||||
func (c *CollectMysql) Collect(progressChan chan<- interface{}) (CollectorResult, error) {
|
||||
databaseConnection := DatabaseConnection{}
|
||||
func (c *CollectMysql) createConnectConfig() (*mysql.Config, error) {
|
||||
if c.Collector.URI == "" {
|
||||
return nil, errors.New("mysql uri cannot be empty")
|
||||
}
|
||||
|
||||
cfg, err := mysql.ParseDSN(c.Collector.URI)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to parse mysql config")
|
||||
}
|
||||
|
||||
if c.Collector.TLS != nil {
|
||||
klog.V(2).Infof("Connecting to mysql with TLS client config")
|
||||
tlsConfig, err := createTLSConfig(c.Context, c.Client, c.Collector.TLS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.TLS = tlsConfig
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *CollectMysql) connect() (*sql.DB, error) {
|
||||
cfg, err := c.createConnectConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if c.Collector.TLS != nil {
|
||||
connector, err := mysql.NewConnector(cfg)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create mysql connector")
|
||||
}
|
||||
|
||||
db := sql.OpenDB(connector)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
db, err := sql.Open("mysql", c.Collector.URI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (c *CollectMysql) Collect(progressChan chan<- interface{}) (CollectorResult, error) {
|
||||
databaseConnection := DatabaseConnection{}
|
||||
|
||||
db, err := c.connect()
|
||||
if err != nil {
|
||||
klog.V(2).Infof("MySQL connection error: %s", err.Error())
|
||||
databaseConnection.Error = err.Error()
|
||||
} else {
|
||||
defer db.Close()
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"github.com/replicatedhq/troubleshoot/internal/testutils"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
testclient "k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
func TestCollectMysql_createConnectConfigPlainText(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uri string
|
||||
hasError bool
|
||||
}{
|
||||
{
|
||||
name: "valid uri creates mysql connection config successfully",
|
||||
uri: "user:password@tcp(localhost:3306)/defaultdb",
|
||||
},
|
||||
{
|
||||
name: "empty uri fails to create mysql connection config with error",
|
||||
uri: "",
|
||||
hasError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid protocol fails to create mysql connection config with error",
|
||||
uri: "http://somehost:3306",
|
||||
hasError: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &CollectMysql{
|
||||
Context: context.Background(),
|
||||
Collector: &v1beta2.Database{
|
||||
URI: tt.uri,
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := c.createConnectConfig()
|
||||
assert.Equal(t, tt.hasError, err != nil)
|
||||
if err == nil {
|
||||
require.NotNil(t, cfg)
|
||||
assert.Equal(t, "localhost:3306", cfg.Addr)
|
||||
assert.Equal(t, "defaultdb", cfg.DBName)
|
||||
} else {
|
||||
t.Log(err)
|
||||
assert.Nil(t, cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMysql_createConnectConfigTLS(t *testing.T) {
|
||||
k8sClient := testclient.NewSimpleClientset()
|
||||
|
||||
c := &CollectMysql{
|
||||
Client: k8sClient,
|
||||
Context: context.Background(),
|
||||
Collector: &v1beta2.Database{
|
||||
URI: "user:password@tcp(localhost:3306)/defaultdb",
|
||||
TLS: &v1beta2.TLSParams{
|
||||
CACert: testutils.GetTestFixture(t, "db/ca.pem"),
|
||||
ClientCert: testutils.GetTestFixture(t, "db/client.pem"),
|
||||
ClientKey: testutils.GetTestFixture(t, "db/client-key.pem"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := c.createConnectConfig()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cfg)
|
||||
assert.Equal(t, "localhost:3306", cfg.Addr)
|
||||
|
||||
// Check TLS config exists and is configured correctly
|
||||
require.NotNil(t, cfg.TLS)
|
||||
|
||||
// Check client cert
|
||||
require.Len(t, cfg.TLS.Certificates, 1)
|
||||
require.Len(t, cfg.TLS.Certificates[0].Certificate, 1)
|
||||
cert := cfg.TLS.Certificates[0]
|
||||
clientCert, err := x509.ParseCertificate(cert.Certificate[0])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "CN=client,L=Didcot,ST=Oxfordshire,C=UK", clientCert.Subject.String())
|
||||
|
||||
// Check client key
|
||||
block, _ := pem.Decode([]byte(testutils.GetTestFixture(t, "db/client-key.pem")))
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, key.Equal(cert.PrivateKey.(*rsa.PrivateKey)))
|
||||
|
||||
assert.NotNil(t, cfg.TLS.RootCAs)
|
||||
assert.False(t, cfg.TLS.InsecureSkipVerify)
|
||||
}
|
||||
|
||||
func TestCollectMysql_createConnectConfigTLSSkipVerify(t *testing.T) {
|
||||
c := &CollectMysql{
|
||||
Context: context.Background(),
|
||||
Collector: &v1beta2.Database{
|
||||
URI: "user:password@tcp(localhost:3306)/defaultdb",
|
||||
TLS: &v1beta2.TLSParams{
|
||||
SkipVerify: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := c.createConnectConfig()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cfg)
|
||||
require.NotNil(t, cfg.TLS)
|
||||
assert.True(t, cfg.TLS.InsecureSkipVerify)
|
||||
}
|
||||
|
||||
func TestCollectMysql_createConnectConfigTLSCACertOnly(t *testing.T) {
|
||||
c := &CollectMysql{
|
||||
Context: context.Background(),
|
||||
Collector: &v1beta2.Database{
|
||||
URI: "user:password@tcp(localhost:3306)/defaultdb",
|
||||
TLS: &v1beta2.TLSParams{
|
||||
CACert: testutils.GetTestFixture(t, "db/ca.pem"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := c.createConnectConfig()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cfg)
|
||||
require.NotNil(t, cfg.TLS)
|
||||
assert.NotNil(t, cfg.TLS.RootCAs)
|
||||
assert.Empty(t, cfg.TLS.Certificates)
|
||||
assert.False(t, cfg.TLS.InsecureSkipVerify)
|
||||
}
|
||||
|
||||
func TestCollectMysql_createConnectConfigTLSSecret(t *testing.T) {
|
||||
k8sClient := testclient.NewSimpleClientset()
|
||||
|
||||
c := &CollectMysql{
|
||||
Client: k8sClient,
|
||||
Context: context.Background(),
|
||||
Collector: &v1beta2.Database{
|
||||
URI: "user:password@tcp(localhost:3306)/defaultdb",
|
||||
TLS: &v1beta2.TLSParams{
|
||||
Secret: createTLSSecret(t, k8sClient, map[string]string{
|
||||
"cacert": testutils.GetTestFixture(t, "db/ca.pem"),
|
||||
"clientCert": testutils.GetTestFixture(t, "db/client.pem"),
|
||||
"clientKey": testutils.GetTestFixture(t, "db/client-key.pem"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg, err := c.createConnectConfig()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cfg)
|
||||
require.NotNil(t, cfg.TLS)
|
||||
assert.NotNil(t, cfg.TLS.RootCAs)
|
||||
assert.NotEmpty(t, cfg.TLS.Certificates)
|
||||
assert.False(t, cfg.TLS.InsecureSkipVerify)
|
||||
}
|
||||
+38
-6
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -16,18 +17,49 @@ import (
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
// Max number of concurrent redactors to run
|
||||
// Ensure the number is low enough since each of the redactors
|
||||
// also spawns goroutines to redact files in tar archives and
|
||||
// other goroutines for each redactor spec.
|
||||
const MAX_CONCURRENT_REDACTORS = 10
|
||||
// Default cap on concurrent file redactors. Each redactor also spawns
|
||||
// goroutines to redact files in tar archives and goroutines for each
|
||||
// redactor spec, so the ceiling is intentionally low.
|
||||
const DefaultMaxConcurrentRedactors = 10
|
||||
|
||||
// MaxConcurrentRedactorsEnvVar is the environment variable name used to
|
||||
// override DefaultMaxConcurrentRedactors at runtime. Operators set this on
|
||||
// the support-bundle binary (e.g. inside a Job/initContainer) when the
|
||||
// default ceiling becomes a bottleneck for very large bundles.
|
||||
const MaxConcurrentRedactorsEnvVar = "TROUBLESHOOT_MAX_CONCURRENT_REDACTORS"
|
||||
|
||||
// maxConcurrentRedactors returns the active cap on concurrent redactors.
|
||||
// It reads MaxConcurrentRedactorsEnvVar; if unset, empty, non-numeric, or
|
||||
// <= 0 it falls back to DefaultMaxConcurrentRedactors (with a klog warning
|
||||
// for non-empty invalid input so silent misconfiguration is hard to miss).
|
||||
func maxConcurrentRedactors() int {
|
||||
raw, ok := os.LookupEnv(MaxConcurrentRedactorsEnvVar)
|
||||
if !ok || raw == "" {
|
||||
return DefaultMaxConcurrentRedactors
|
||||
}
|
||||
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
klog.Warningf("Invalid %s=%q (not an integer); falling back to default %d", MaxConcurrentRedactorsEnvVar, raw, DefaultMaxConcurrentRedactors)
|
||||
return DefaultMaxConcurrentRedactors
|
||||
}
|
||||
if n <= 0 {
|
||||
klog.Warningf("Invalid %s=%d (must be > 0); falling back to default %d", MaxConcurrentRedactorsEnvVar, n, DefaultMaxConcurrentRedactors)
|
||||
return DefaultMaxConcurrentRedactors
|
||||
}
|
||||
|
||||
if n != DefaultMaxConcurrentRedactors {
|
||||
klog.Infof("Overriding concurrent redactor cap: %s=%d (default %d)", MaxConcurrentRedactorsEnvVar, n, DefaultMaxConcurrentRedactors)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func RedactResult(bundlePath string, input CollectorResult, additionalRedactors []*troubleshootv1beta2.Redact) error {
|
||||
wg := &sync.WaitGroup{}
|
||||
|
||||
// Error channel to capture errors from goroutines
|
||||
errorCh := make(chan error, len(input))
|
||||
limitCh := make(chan struct{}, MAX_CONCURRENT_REDACTORS)
|
||||
limitCh := make(chan struct{}, maxConcurrentRedactors())
|
||||
defer close(limitCh)
|
||||
|
||||
for k, v := range input {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_maxConcurrentRedactors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setEnv bool
|
||||
value string
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "env unset returns default",
|
||||
setEnv: false,
|
||||
want: DefaultMaxConcurrentRedactors,
|
||||
},
|
||||
{
|
||||
name: "empty value returns default",
|
||||
setEnv: true,
|
||||
value: "",
|
||||
want: DefaultMaxConcurrentRedactors,
|
||||
},
|
||||
{
|
||||
name: "valid positive int overrides default",
|
||||
setEnv: true,
|
||||
value: "50",
|
||||
want: 50,
|
||||
},
|
||||
{
|
||||
name: "value equal to default is honored",
|
||||
setEnv: true,
|
||||
value: "10",
|
||||
want: DefaultMaxConcurrentRedactors,
|
||||
},
|
||||
{
|
||||
name: "zero falls back to default",
|
||||
setEnv: true,
|
||||
value: "0",
|
||||
want: DefaultMaxConcurrentRedactors,
|
||||
},
|
||||
{
|
||||
name: "negative value falls back to default",
|
||||
setEnv: true,
|
||||
value: "-3",
|
||||
want: DefaultMaxConcurrentRedactors,
|
||||
},
|
||||
{
|
||||
name: "non-numeric value falls back to default",
|
||||
setEnv: true,
|
||||
value: "potato",
|
||||
want: DefaultMaxConcurrentRedactors,
|
||||
},
|
||||
{
|
||||
name: "whitespace-padded value falls back to default",
|
||||
setEnv: true,
|
||||
value: " 4 ",
|
||||
want: DefaultMaxConcurrentRedactors,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Snapshot inherited state so each subtest is hermetic regardless
|
||||
// of the order Go picks. t.Setenv would mask "env unset" cases
|
||||
// with an empty value, so we manage the env directly here.
|
||||
prev, hadPrev := os.LookupEnv(MaxConcurrentRedactorsEnvVar)
|
||||
t.Cleanup(func() {
|
||||
if hadPrev {
|
||||
_ = os.Setenv(MaxConcurrentRedactorsEnvVar, prev)
|
||||
} else {
|
||||
_ = os.Unsetenv(MaxConcurrentRedactorsEnvVar)
|
||||
}
|
||||
})
|
||||
|
||||
if tt.setEnv {
|
||||
if err := os.Setenv(MaxConcurrentRedactorsEnvVar, tt.value); err != nil {
|
||||
t.Fatalf("setenv: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := os.Unsetenv(MaxConcurrentRedactorsEnvVar); err != nil {
|
||||
t.Fatalf("unsetenv: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got := maxConcurrentRedactors()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
+83
-55
@@ -3,21 +3,22 @@ package collect
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/distribution/distribution/v3/registry/api/errcode"
|
||||
registryv2 "github.com/distribution/distribution/v3/registry/api/v2"
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
|
||||
"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"
|
||||
@@ -91,9 +92,9 @@ func (c *CollectRegistry) Collect(progressChan chan<- interface{}) (CollectorRes
|
||||
}
|
||||
|
||||
func imageExists(namespace string, clientConfig *rest.Config, registryCollector *troubleshootv1beta2.RegistryImages, image string, deadline time.Duration) (bool, error) {
|
||||
imageRef, err := alltransports.ParseImageName(fmt.Sprintf("docker://%s", image))
|
||||
imageRef, err := parseImageRef(image)
|
||||
if err != nil {
|
||||
return false, errors.Wrapf(err, "failed to parse image name %s", image)
|
||||
return false, err
|
||||
}
|
||||
|
||||
authConfig, err := getImageAuthConfig(namespace, clientConfig, registryCollector, imageRef)
|
||||
@@ -102,16 +103,29 @@ func imageExists(namespace string, clientConfig *rest.Config, registryCollector
|
||||
return false, errors.Wrap(err, "failed to get auth config")
|
||||
}
|
||||
|
||||
sysCtx := types.SystemContext{
|
||||
DockerDisableV1Ping: true,
|
||||
DockerInsecureSkipTLSVerify: types.OptionalBoolTrue,
|
||||
return imageExistsWithAuth(authConfig, imageRef, image, deadline)
|
||||
}
|
||||
|
||||
func parseImageRef(image string) (name.Reference, error) {
|
||||
ref, err := name.ParseReference(image)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to parse image name %s", image)
|
||||
}
|
||||
if authConfig != nil {
|
||||
sysCtx.DockerAuthConfig = &types.DockerAuthConfig{
|
||||
Username: authConfig.username,
|
||||
Password: authConfig.password,
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// imageExistsWithAuth checks if an image exists in a registry using optional auth credentials.
|
||||
// authConfig may be nil for ambient credentials (e.g. ~/.docker/config.json).
|
||||
// This is the shared core used by both the cluster-level and host-level registry collectors.
|
||||
func imageExistsWithAuth(authConfig *registryAuthConfig, ref name.Reference, image string, deadline time.Duration) (bool, error) {
|
||||
// remote.DefaultTransport includes Proxy (HTTP_PROXY/HTTPS_PROXY), dial/TLS
|
||||
// timeouts, and keepalive; clone it so InsecureSkipVerify does not drop those.
|
||||
defaultTR, ok := remote.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
return false, errors.New("remote.DefaultTransport is not *http.Transport")
|
||||
}
|
||||
insecureTransport := defaultTR.Clone()
|
||||
insecureTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
|
||||
|
||||
if deadline == 0 {
|
||||
deadline = 10 * time.Second
|
||||
@@ -122,12 +136,30 @@ func imageExists(namespace string, clientConfig *rest.Config, registryCollector
|
||||
err := func() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), deadline)
|
||||
defer cancel()
|
||||
remoteImage, err := imageRef.NewImage(ctx, &sysCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
opts := []remote.Option{
|
||||
remote.WithContext(ctx),
|
||||
remote.WithTransport(insecureTransport),
|
||||
}
|
||||
remoteImage.Close()
|
||||
return nil
|
||||
if authConfig != nil {
|
||||
opts = append(opts, remote.WithAuth(&authn.Basic{
|
||||
Username: authConfig.username,
|
||||
Password: authConfig.password,
|
||||
}))
|
||||
} else {
|
||||
opts = append(opts, remote.WithAuthFromKeychain(authn.DefaultKeychain))
|
||||
}
|
||||
|
||||
// Use Get (not Head) so 404 responses include a JSON body; the registry
|
||||
// API encodes MANIFEST_UNKNOWN vs NAME_UNKNOWN there, which we need to
|
||||
// distinguish. Head 404s typically have no body, so *transport.Error has
|
||||
// empty Errors and we cannot classify the failure.
|
||||
//
|
||||
// Get fetches the manifest (or list/index) for the tag or digest and does
|
||||
// not pick a per-platform child image, so this checks presence only, not
|
||||
// whether the image runs on a given architecture.
|
||||
_, err := remote.Get(ref, opts...)
|
||||
return err
|
||||
}()
|
||||
if err == nil {
|
||||
klog.V(2).Infof("image %s exists", image)
|
||||
@@ -136,18 +168,10 @@ func imageExists(namespace string, clientConfig *rest.Config, registryCollector
|
||||
|
||||
klog.Errorf("failed to get image %s: %v", image, err)
|
||||
|
||||
// if this is a context timeout, stop here so we dont run this check for too long
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
if stderrors.Is(err, context.DeadlineExceeded) {
|
||||
return false, errors.Wrap(err, "failed to get image manifest")
|
||||
}
|
||||
|
||||
if strings.Contains(err.Error(), "no image found in manifest list for architecture") {
|
||||
// manifest was downloaded, but no matching architecture found in manifest
|
||||
// should this count as image does not exist?
|
||||
// this binary's architecture is not necessarily what will run in the cluster
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if isNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
@@ -164,7 +188,7 @@ func imageExists(namespace string, clientConfig *rest.Config, registryCollector
|
||||
return false, errors.Wrap(lastErr, "failed to retry")
|
||||
}
|
||||
|
||||
func getImageAuthConfig(namespace string, clientConfig *rest.Config, registryCollector *troubleshootv1beta2.RegistryImages, imageRef types.ImageReference) (*registryAuthConfig, error) {
|
||||
func getImageAuthConfig(namespace string, clientConfig *rest.Config, registryCollector *troubleshootv1beta2.RegistryImages, imageRef name.Reference) (*registryAuthConfig, error) {
|
||||
if registryCollector.ImagePullSecrets == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -195,13 +219,13 @@ func getImageAuthConfig(namespace string, clientConfig *rest.Config, registryCol
|
||||
return nil, errors.New("image pull secret spec is not valid")
|
||||
}
|
||||
|
||||
func getImageAuthConfigFromData(imageRef types.ImageReference, pullSecrets *v1beta2.ImagePullSecrets) (*registryAuthConfig, error) {
|
||||
func getImageAuthConfigFromData(imageRef name.Reference, pullSecrets *v1beta2.ImagePullSecrets) (*registryAuthConfig, error) {
|
||||
if pullSecrets.SecretType != "kubernetes.io/dockerconfigjson" {
|
||||
return nil, errors.Errorf("secret type is not supported: %s", pullSecrets.SecretType)
|
||||
}
|
||||
|
||||
configJsonBase64 := pullSecrets.Data[".dockerconfigjson"]
|
||||
registry := dockerref.Domain(imageRef.DockerReference())
|
||||
registry := imageRef.Context().RegistryStr()
|
||||
|
||||
configJson, err := base64.StdEncoding.DecodeString(configJsonBase64)
|
||||
if err != nil {
|
||||
@@ -222,8 +246,14 @@ func getImageAuthConfigFromData(imageRef types.ImageReference, pullSecrets *v1be
|
||||
}
|
||||
|
||||
auth, ok := dockerCfgJSON.Auths[registry]
|
||||
// go-containerregistry normalizes "docker.io" to "index.docker.io"
|
||||
// (name.DefaultRegistry); many dockerconfigjson files key on "docker.io"
|
||||
// instead. Fall back to the alias so existing user secrets keep working.
|
||||
if !ok && registry == name.DefaultRegistry {
|
||||
auth, ok = dockerCfgJSON.Auths["docker.io"]
|
||||
}
|
||||
if !ok {
|
||||
// Suport a mix of public and private images
|
||||
// Support a mix of public and private images
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -258,7 +288,7 @@ func getImageAuthConfigFromData(imageRef types.ImageReference, pullSecrets *v1be
|
||||
return &authConfig, nil
|
||||
}
|
||||
|
||||
func getImageAuthConfigFromSecret(clientConfig *rest.Config, imageRef types.ImageReference, pullSecrets *v1beta2.ImagePullSecrets, namespace string) (*registryAuthConfig, error) {
|
||||
func getImageAuthConfigFromSecret(clientConfig *rest.Config, imageRef name.Reference, pullSecrets *v1beta2.ImagePullSecrets, namespace string) (*registryAuthConfig, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := kubernetes.NewForConfig(clientConfig)
|
||||
@@ -287,30 +317,28 @@ func getImageAuthConfigFromSecret(clientConfig *rest.Config, imageRef types.Imag
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// isNotFound returns true only when the registry reports MANIFEST_UNKNOWN: the
|
||||
// repository exists but the tag or digest has no manifest. A 404 with
|
||||
// NAME_UNKNOWN (repository missing) is not "not found" in that sense; callers
|
||||
// should see the error to diagnose a wrong image path. Unstructured 404s
|
||||
// (e.g. empty body on HEAD) are not treated as a known-missing image.
|
||||
func isNotFound(err error) bool {
|
||||
switch err := err.(type) {
|
||||
case errcode.Errors:
|
||||
for _, e := range err {
|
||||
if isNotFound(e) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case errcode.Error:
|
||||
return err.Message == registryv2.ErrorCodeManifestUnknown.Message()
|
||||
}
|
||||
|
||||
// this type will cause panic when compared to error type
|
||||
if _, ok := err.(imagedocker.ErrUnauthorizedForCredentials); ok {
|
||||
var terr *transport.Error
|
||||
if !stderrors.As(err, &terr) || terr.StatusCode != http.StatusNotFound {
|
||||
return false
|
||||
}
|
||||
|
||||
cause := errors.Cause(err)
|
||||
if cause, ok := cause.(error); ok {
|
||||
if cause == err {
|
||||
if len(terr.Errors) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, d := range terr.Errors {
|
||||
if d.Code == transport.NameUnknownErrorCode {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return isNotFound(cause)
|
||||
for _, d := range terr.Errors {
|
||||
if d.Code == transport.ManifestUnknownErrorCode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -5,15 +5,55 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"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"
|
||||
)
|
||||
|
||||
// fakeRegistry is a minimal Docker Registry v2 TLS stand-in for unit testing
|
||||
// imageExists. The handler returns whatever the caller puts in `manifest`
|
||||
// for /v2/{name}/manifests/{ref}. /v2/ is always a 200.
|
||||
//
|
||||
// We use NewTLSServer so the same tests work against both the current
|
||||
// containers/image implementation (DockerInsecureSkipTLSVerify) and the
|
||||
// new go-containerregistry implementation (InsecureSkipVerify transport).
|
||||
// Plain HTTP would break after Task 3 because go-containerregistry does not
|
||||
// treat 127.0.0.1 as an insecure registry by default.
|
||||
type fakeRegistry struct {
|
||||
server *httptest.Server
|
||||
manifest http.HandlerFunc
|
||||
}
|
||||
|
||||
func newFakeRegistry(t *testing.T, manifest http.HandlerFunc) *fakeRegistry {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v2/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v2/" || r.URL.Path == "/v2" {
|
||||
w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
manifest(w, r)
|
||||
})
|
||||
srv := httptest.NewTLSServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return &fakeRegistry{server: srv, manifest: manifest}
|
||||
}
|
||||
|
||||
// hostPort strips "https://" from the test server URL, leaving "127.0.0.1:NNNN"
|
||||
// suitable for use as the registry portion of an image reference.
|
||||
func (f *fakeRegistry) hostPort() string {
|
||||
return strings.TrimPrefix(f.server.URL, "https://")
|
||||
}
|
||||
|
||||
func TestGetImageAuthConfigFromData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -57,7 +97,7 @@ func TestGetImageAuthConfigFromData(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
imageRef, err := alltransports.ParseImageName(fmt.Sprintf("docker://%s", test.imageName))
|
||||
imageRef, err := name.ParseReference(test.imageName)
|
||||
assert.NoError(t, err)
|
||||
|
||||
pullSecrets := &v1beta2.ImagePullSecrets{
|
||||
@@ -119,3 +159,101 @@ func TestImageExists_ContextDeadlineExceeded(t *testing.T) {
|
||||
assert.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestImageExists_Found(t *testing.T) {
|
||||
fr := newFakeRegistry(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
w.Header().Set("Docker-Content-Digest", "sha256:1111111111111111111111111111111111111111111111111111111111111111")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":1,"digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"layers":[]}`))
|
||||
})
|
||||
|
||||
collector := &v1beta2.RegistryImages{
|
||||
Images: []string{fmt.Sprintf("%s/test:latest", fr.hostPort())},
|
||||
}
|
||||
exists, err := imageExists("default", &rest.Config{}, collector, fmt.Sprintf("%s/test:latest", fr.hostPort()), 5*time.Second)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
func TestImageExists_NotFound(t *testing.T) {
|
||||
fr := newFakeRegistry(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown"}]}`))
|
||||
})
|
||||
|
||||
collector := &v1beta2.RegistryImages{
|
||||
Images: []string{fmt.Sprintf("%s/test:latest", fr.hostPort())},
|
||||
}
|
||||
exists, err := imageExists("default", &rest.Config{}, collector, fmt.Sprintf("%s/test:latest", fr.hostPort()), 5*time.Second)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestImageExists_NameUnknown_PropagatesError(t *testing.T) {
|
||||
fr := newFakeRegistry(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"code":"NAME_UNKNOWN","message":"repository name not known to registry"}]}`))
|
||||
})
|
||||
|
||||
collector := &v1beta2.RegistryImages{
|
||||
Images: []string{fmt.Sprintf("%s/nonexistentrepo/image:latest", fr.hostPort())},
|
||||
}
|
||||
exists, err := imageExists("default", &rest.Config{}, collector, fmt.Sprintf("%s/nonexistentrepo/image:latest", fr.hostPort()), 5*time.Second)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestImageExists_Unauthorized(t *testing.T) {
|
||||
fr := newFakeRegistry(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Www-Authenticate", `Basic realm="registry"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}`))
|
||||
})
|
||||
|
||||
collector := &v1beta2.RegistryImages{
|
||||
Images: []string{fmt.Sprintf("%s/test:latest", fr.hostPort())},
|
||||
}
|
||||
exists, err := imageExists("default", &rest.Config{}, collector, fmt.Sprintf("%s/test:latest", fr.hostPort()), 5*time.Second)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestImageExists_RetriesOnEOF(t *testing.T) {
|
||||
var attempts int32
|
||||
fr := newFakeRegistry(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&attempts, 1)
|
||||
if n < 3 {
|
||||
// hijack the connection and slam it shut to cause an EOF on the client
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
t.Fatalf("response writer does not support hijacking")
|
||||
}
|
||||
conn, _, err := hj.Hijack()
|
||||
if err != nil {
|
||||
t.Fatalf("hijack: %v", err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
|
||||
w.Header().Set("Docker-Content-Digest", "sha256:1111111111111111111111111111111111111111111111111111111111111111")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":1,"digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"layers":[]}`))
|
||||
})
|
||||
|
||||
collector := &v1beta2.RegistryImages{
|
||||
Images: []string{fmt.Sprintf("%s/test:latest", fr.hostPort())},
|
||||
}
|
||||
exists, err := imageExists("default", &rest.Config{}, collector, fmt.Sprintf("%s/test:latest", fr.hostPort()), 5*time.Second)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
assert.GreaterOrEqual(t, atomic.LoadInt32(&attempts), int32(3))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@@ -87,9 +88,7 @@ func (r CollectorResult) SymLinkResult(bundlePath, relativeLinkPath, relativeFil
|
||||
// It also ensures that when operating on the results in memory (e.g preflights),
|
||||
// all files are included.
|
||||
func (r CollectorResult) AddResult(other CollectorResult) {
|
||||
for k, v := range other {
|
||||
r[k] = v
|
||||
}
|
||||
maps.Copy(r, other)
|
||||
}
|
||||
|
||||
// SaveResult saves the collector result to relativePath file on disk. If bundlePath is
|
||||
|
||||
@@ -69,7 +69,7 @@ func (c *CollectRunPod) Collect(progressChan chan<- interface{}) (result Collect
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
result, err = savePodDetails(ctx, client, result, c.BundlePath, c.ClientConfig, pod, c.Collector)
|
||||
result, err = savePodDetails(ctx, client, result, c.BundlePath, pod, c.Collector)
|
||||
if err != nil {
|
||||
klog.Errorf("failed to save pod details: %v", err)
|
||||
}
|
||||
@@ -407,7 +407,7 @@ func RunPodLogsWithOptions(ctx context.Context, client v1.CoreV1Interface, podSp
|
||||
return io.ReadAll(logs)
|
||||
}
|
||||
|
||||
func savePodDetails(ctx context.Context, client *kubernetes.Clientset, output CollectorResult, bundlePath string, clientConfig *rest.Config, pod *corev1.Pod, runPodCollector *troubleshootv1beta2.RunPod) (CollectorResult, error) {
|
||||
func savePodDetails(ctx context.Context, client kubernetes.Interface, output CollectorResult, bundlePath string, pod *corev1.Pod, runPodCollector *troubleshootv1beta2.RunPod) (CollectorResult, error) {
|
||||
podStatus, err := client.CoreV1().Pods(pod.Namespace).Get(ctx, pod.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get pod")
|
||||
@@ -418,7 +418,12 @@ func savePodDetails(ctx context.Context, client *kubernetes.Clientset, output Co
|
||||
return nil, errors.Wrap(err, "failed to get pod events")
|
||||
}
|
||||
|
||||
podBytes, err := json.MarshalIndent(podStatus, "", " ")
|
||||
// The full pod Spec can contain sensitive data such as env vars, commands, args,
|
||||
// volumes, and image pull secrets. Strip it before saving the pod to the bundle.
|
||||
sanitizedPod := *podStatus
|
||||
sanitizedPod.Spec = corev1.PodSpec{}
|
||||
|
||||
podBytes, err := json.MarshalIndent(sanitizedPod, "", " ")
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal pod status")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package collect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
@@ -239,3 +240,99 @@ func Test_deleteImagePullSecret(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePodDetails_StripsPodSpec(t *testing.T) {
|
||||
pod := &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-run-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{
|
||||
"troubleshoot-role": "run-collector",
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
NodeName: "test-node",
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "collector",
|
||||
Image: "busybox",
|
||||
Command: []string{"sh", "-c", "echo secret-command"},
|
||||
Args: []string{"--token", "super-secret-token"},
|
||||
Env: []corev1.EnvVar{
|
||||
{Name: "PASSWORD", Value: "hunter2"},
|
||||
{
|
||||
Name: "API_KEY",
|
||||
ValueFrom: &corev1.EnvVarSource{
|
||||
SecretKeyRef: &corev1.SecretKeySelector{
|
||||
LocalObjectReference: corev1.LocalObjectReference{Name: "my-secret"},
|
||||
Key: "api-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ImagePullSecrets: []corev1.LocalObjectReference{{Name: "my-pull-secret"}},
|
||||
Volumes: []corev1.Volume{
|
||||
{
|
||||
Name: "secret-vol",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{SecretName: "my-secret"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: corev1.PodStatus{
|
||||
Phase: corev1.PodSucceeded,
|
||||
ContainerStatuses: []corev1.ContainerStatus{
|
||||
{
|
||||
Name: "collector",
|
||||
State: corev1.ContainerState{
|
||||
Terminated: &corev1.ContainerStateTerminated{ExitCode: 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
client := fake.NewSimpleClientset(pod)
|
||||
collector := &troubleshootv1beta2.RunPod{
|
||||
Name: "test-collector",
|
||||
Namespace: "default",
|
||||
}
|
||||
|
||||
result, err := savePodDetails(context.Background(), client, NewResult(), "", pod, collector)
|
||||
require.NoError(t, err)
|
||||
|
||||
podKey := "test-collector/test-collector.json"
|
||||
require.Contains(t, result, podKey)
|
||||
|
||||
saved := string(result[podKey])
|
||||
|
||||
// Debugging info must still be present
|
||||
assert.Contains(t, saved, `"name": "test-run-pod"`)
|
||||
assert.Contains(t, saved, `"phase": "Succeeded"`)
|
||||
assert.Contains(t, saved, `"exitCode": 0`)
|
||||
|
||||
// Sensitive spec data must not be present
|
||||
assert.NotContains(t, saved, "hunter2")
|
||||
assert.NotContains(t, saved, "super-secret-token")
|
||||
assert.NotContains(t, saved, "secret-command")
|
||||
assert.NotContains(t, saved, "my-secret")
|
||||
assert.NotContains(t, saved, "my-pull-secret")
|
||||
assert.NotContains(t, saved, `"command":`)
|
||||
assert.NotContains(t, saved, `"args":`)
|
||||
assert.NotContains(t, saved, `"env":`)
|
||||
|
||||
// The saved JSON must still unmarshal into a Pod so downstream consumers
|
||||
// (e.g. goldpinger) can read Name and Status.ContainerStatuses.
|
||||
var savedPod corev1.Pod
|
||||
require.NoError(t, json.Unmarshal(result[podKey], &savedPod))
|
||||
assert.Equal(t, "test-run-pod", savedPod.Name)
|
||||
assert.Equal(t, corev1.PodSucceeded, savedPod.Status.Phase)
|
||||
assert.Len(t, savedPod.Status.ContainerStatuses, 1)
|
||||
assert.Equal(t, int32(0), savedPod.Status.ContainerStatuses[0].State.Terminated.ExitCode)
|
||||
assert.Empty(t, savedPod.Spec.Containers)
|
||||
assert.Empty(t, savedPod.Spec.Volumes)
|
||||
assert.Empty(t, savedPod.Spec.ImagePullSecrets)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
kerrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -147,7 +148,7 @@ func sonobuoyRetrieveResults(
|
||||
Stdout: true,
|
||||
Stderr: false,
|
||||
}, scheme.ParameterCodec)
|
||||
executor, err := remotecommand.NewSPDYExecutor(restConfig, "POST", req.URL())
|
||||
executor, err := k8sutil.NewFallbackExecutor(restConfig, req.URL())
|
||||
if err != nil {
|
||||
return nil, ec, err
|
||||
}
|
||||
|
||||
+43
-41
@@ -23,47 +23,49 @@ 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_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"
|
||||
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_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"
|
||||
CLUSTER_RESOURCES_INGRESS_CLASS = "ingress-classes"
|
||||
CLUSTER_RESOURCES_VALIDATING_WEBHOOK_CONFIGURATIONS = "validating-webhook-configurations"
|
||||
CLUSTER_RESOURCES_MUTATING_WEBHOOK_CONFIGURATIONS = "mutating-webhook-configurations"
|
||||
|
||||
// SelfSubjectRulesReview evaluation responses
|
||||
SELFSUBJECTRULESREVIEW_ERROR_AUTHORIZATION_WEBHOOK_UNSUPPORTED = "webhook authorizer does not support user rule resolution"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package k8sutil
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
restclient "k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/remotecommand"
|
||||
"k8s.io/streaming/pkg/httpstream"
|
||||
)
|
||||
|
||||
// NewFallbackExecutor creates an executor that tries WebSocket first and falls
|
||||
// back to SPDY if the server does not support it. Use this in place of
|
||||
// remotecommand.NewSPDYExecutor everywhere.
|
||||
func NewFallbackExecutor(config *restclient.Config, u *url.URL) (remotecommand.Executor, error) {
|
||||
// WebSocket upgrade requires GET per RFC 6455; SPDY uses POST.
|
||||
wsExec, err := remotecommand.NewWebSocketExecutor(config, "GET", u.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spdyExec, err := remotecommand.NewSPDYExecutor(config, "POST", u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return remotecommand.NewFallbackExecutor(wsExec, spdyExec, func(err error) bool {
|
||||
return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package k8sutil
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
func TestNewFallbackExecutor(t *testing.T) {
|
||||
config := &restclient.Config{Host: "http://localhost:8080"}
|
||||
u, err := url.Parse("http://localhost:8080/api/v1/namespaces/default/pods/foo/exec")
|
||||
require.NoError(t, err)
|
||||
|
||||
exec, err := NewFallbackExecutor(config, u)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, exec)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package k8sutil
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
restclient "k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/portforward"
|
||||
"k8s.io/client-go/transport/spdy"
|
||||
)
|
||||
|
||||
func PortForward(config *restclient.Config, localPort int, remotePort int, namespace string, podName string) (chan struct{}, error) {
|
||||
roundTripper, upgrader, err := spdy.RoundTripperFor(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/portforward", namespace, podName)
|
||||
hostIP := strings.TrimLeft(config.Host, "htps:/")
|
||||
serverURL := url.URL{Scheme: "http", Path: path, Host: hostIP}
|
||||
dialer := spdy.NewDialer(upgrader, &http.Client{Transport: roundTripper}, http.MethodPost, &serverURL)
|
||||
|
||||
stopChan, readyChan := make(chan struct{}, 1), make(chan struct{}, 1)
|
||||
out, errOut := new(bytes.Buffer), new(bytes.Buffer)
|
||||
|
||||
forwarder, err := portforward.New(dialer, []string{fmt.Sprintf("%d:%d", localPort, remotePort)}, stopChan, readyChan, out, errOut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
for range readyChan { // Kubernetes will close this channel when it has something to tell us.
|
||||
}
|
||||
if errOut.String() != "" {
|
||||
panic(errOut.String())
|
||||
} else if out.String() != "" {
|
||||
// fmt.Println(out.String())
|
||||
}
|
||||
}()
|
||||
|
||||
go func() error {
|
||||
if err = forwarder.ForwardPorts(); err != nil { // Locks until stopChan is closed.
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
// Block until the new service is responding, limited to (math) seconds
|
||||
quickClient := &http.Client{
|
||||
Timeout: time.Millisecond * 200,
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
for {
|
||||
response, err := quickClient.Get(fmt.Sprintf("http://localhost:%d", localPort))
|
||||
if err == nil && response.StatusCode == http.StatusOK {
|
||||
break
|
||||
}
|
||||
if time.Now().Sub(start) > time.Duration(time.Second*5) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
}
|
||||
|
||||
return stopChan, nil
|
||||
}
|
||||
@@ -386,6 +386,9 @@ func v1beta3SpecRequiresClient(spec *troubleshootv1beta3.SupportBundleSpec) bool
|
||||
if c.Redis != nil && databaseRequiresClient(c.Redis) {
|
||||
return true
|
||||
}
|
||||
if c.ClickHouse != nil && databaseRequiresClient(c.ClickHouse) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
+39
-42
@@ -9,19 +9,23 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
longhornns "github.com/longhorn/go-common-libs/ns"
|
||||
longhornproc "github.com/longhorn/go-common-libs/proc"
|
||||
lhtypes "github.com/longhorn/go-common-libs/types"
|
||||
"github.com/pkg/errors"
|
||||
"k8s.io/klog/v2"
|
||||
|
||||
iscsi_util "github.com/longhorn/go-iscsi-helper/util"
|
||||
)
|
||||
|
||||
func newHostNamespaceExecutor() (*longhornns.Executor, error) {
|
||||
return longhornns.NewNamespaceExecutor(lhtypes.ProcessNone, HostProcPath, []lhtypes.Namespace{lhtypes.NamespaceMnt})
|
||||
}
|
||||
|
||||
func GetDiskInfo(directory string) (info *DiskInfo, err error) {
|
||||
defer func() {
|
||||
err = errors.Wrapf(err, "cannot get disk info of directory %v", directory)
|
||||
}()
|
||||
initiatorNSPath := iscsi_util.GetHostNamespacePath(HostProcPath)
|
||||
mountPath := fmt.Sprintf("--mount=%s/mnt", initiatorNSPath)
|
||||
output, err := Execute([]string{}, "nsenter", mountPath, "stat", "-fc", "{\"path\":\"%n\",\"fsid\":\"%i\",\"type\":\"%T\",\"freeBlock\":%f,\"totalBlock\":%b,\"blockSize\":%S}", directory)
|
||||
nsDir := longhornproc.GetHostNamespaceDirectory(HostProcPath)
|
||||
output, err := Execute([]string{}, "nsenter", "--mount="+nsDir+"/mnt", "stat", "-fc", "{\"path\":\"%n\",\"fsid\":\"%i\",\"type\":\"%T\",\"freeBlock\":%f,\"totalBlock\":%b,\"blockSize\":%S}", directory)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -51,17 +55,16 @@ func RemoveHostDirectoryContent(directory string) (err error) {
|
||||
if strings.Count(dir, "/") < 2 {
|
||||
return fmt.Errorf("prohibit removing the top level of directory %v", dir)
|
||||
}
|
||||
initiatorNSPath := iscsi_util.GetHostNamespacePath(HostProcPath)
|
||||
nsExec, err := iscsi_util.NewNamespaceExecutor(initiatorNSPath)
|
||||
nsExec, err := newHostNamespaceExecutor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// check if the directory already deleted
|
||||
if _, err := nsExec.Execute("ls", []string{dir}); err != nil {
|
||||
if _, err := nsExec.Execute(nil, "ls", []string{dir}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
klog.Warningf("cannot find host directory %v for removal", dir)
|
||||
return nil
|
||||
}
|
||||
if _, err := nsExec.Execute("rm", []string{"-rf", dir}); err != nil {
|
||||
if _, err := nsExec.Execute(nil, "rm", []string{"-rf", dir}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -84,35 +87,33 @@ func CopyHostDirectoryContent(src, dest string) (err error) {
|
||||
return fmt.Errorf("prohibit copying the content for the top level of directory %v or %v", srcDir, destDir)
|
||||
}
|
||||
|
||||
initiatorNSPath := iscsi_util.GetHostNamespacePath(HostProcPath)
|
||||
nsExec, err := iscsi_util.NewNamespaceExecutor(initiatorNSPath)
|
||||
nsExec, err := newHostNamespaceExecutor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// There can be no src directory, hence returning nil is fine.
|
||||
if _, err := nsExec.Execute("bash", []string{"-c", fmt.Sprintf("ls %s", filepath.Join(srcDir, "*"))}); err != nil {
|
||||
if _, err := nsExec.Execute(nil, "bash", []string{"-c", fmt.Sprintf("ls %s", filepath.Join(srcDir, "*"))}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
klog.V(2).Infof("cannot list the content of the src directory %v for the copy, will do nothing: %v", srcDir, err)
|
||||
return nil
|
||||
}
|
||||
// Check if the dest directory exists.
|
||||
if _, err := nsExec.Execute("mkdir", []string{"-p", destDir}); err != nil {
|
||||
if _, err := nsExec.Execute(nil, "mkdir", []string{"-p", destDir}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
return err
|
||||
}
|
||||
// The flag `-n` means not overwriting an existing file.
|
||||
if _, err := nsExec.Execute("bash", []string{"-c", fmt.Sprintf("cp -an %s %s", filepath.Join(srcDir, "*"), destDir)}); err != nil {
|
||||
if _, err := nsExec.Execute(nil, "bash", []string{"-c", fmt.Sprintf("cp -an %s %s", filepath.Join(srcDir, "*"), destDir)}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateDiskPathReplicaSubdirectory(path string) error {
|
||||
nsPath := iscsi_util.GetHostNamespacePath(HostProcPath)
|
||||
nsExec, err := iscsi_util.NewNamespaceExecutor(nsPath)
|
||||
nsExec, err := newHostNamespaceExecutor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := nsExec.Execute("mkdir", []string{"-p", filepath.Join(path, ReplicaDirectory)}); err != nil {
|
||||
if _, err := nsExec.Execute(nil, "mkdir", []string{"-p", filepath.Join(path, ReplicaDirectory)}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
return errors.Wrapf(err, "error creating data path %v on host", path)
|
||||
}
|
||||
|
||||
@@ -120,22 +121,22 @@ func CreateDiskPathReplicaSubdirectory(path string) error {
|
||||
}
|
||||
|
||||
func DeleteDiskPathReplicaSubdirectoryAndDiskCfgFile(
|
||||
nsExec *iscsi_util.NamespaceExecutor, path string) error {
|
||||
nsExec *longhornns.Executor, path string) error {
|
||||
|
||||
var err error
|
||||
dirPath := filepath.Join(path, ReplicaDirectory)
|
||||
filePath := filepath.Join(path, DiskConfigFile)
|
||||
|
||||
// Check if the replica directory exist, delete it
|
||||
if _, err := nsExec.Execute("ls", []string{dirPath}); err == nil {
|
||||
if _, err := nsExec.Execute("rmdir", []string{dirPath}); err != nil {
|
||||
if _, err := nsExec.Execute(nil, "ls", []string{dirPath}, lhtypes.ExecuteDefaultTimeout); err == nil {
|
||||
if _, err := nsExec.Execute(nil, "rmdir", []string{dirPath}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
return errors.Wrapf(err, "error deleting data path %v on host", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the disk cfg file exist, delete it
|
||||
if _, err := nsExec.Execute("ls", []string{filePath}); err == nil {
|
||||
if _, err := nsExec.Execute("rm", []string{filePath}); err != nil {
|
||||
if _, err := nsExec.Execute(nil, "ls", []string{filePath}, lhtypes.ExecuteDefaultTimeout); err == nil {
|
||||
if _, err := nsExec.Execute(nil, "rm", []string{filePath}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
err = errors.Wrapf(err, "error deleting disk cfg file %v on host", filePath)
|
||||
}
|
||||
}
|
||||
@@ -145,8 +146,7 @@ func DeleteDiskPathReplicaSubdirectoryAndDiskCfgFile(
|
||||
|
||||
func ExpandFileSystem(volumeName string) (err error) {
|
||||
devicePath := filepath.Join(DeviceDirectory, volumeName)
|
||||
nsPath := iscsi_util.GetHostNamespacePath(HostProcPath)
|
||||
nsExec, err := iscsi_util.NewNamespaceExecutor(nsPath)
|
||||
nsExec, err := newHostNamespaceExecutor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func ExpandFileSystem(volumeName string) (err error) {
|
||||
// make sure there is a mount point for the volume before file system expansion
|
||||
tmpMountNeeded := true
|
||||
mountPoint := ""
|
||||
mountRes, err := nsExec.Execute("bash", []string{"-c", "mount | grep \"/" + volumeName + " \" | awk '{print $3}'"})
|
||||
mountRes, err := nsExec.Execute(nil, "bash", []string{"-c", "mount | grep \"/" + volumeName + " \" | awk '{print $3}'"}, lhtypes.ExecuteDefaultTimeout)
|
||||
if err != nil {
|
||||
klog.Warningf("failed to use command mount to get the mount info of volume %v, consider the volume as unmounted: %v", volumeName, err)
|
||||
} else {
|
||||
@@ -185,10 +185,10 @@ func ExpandFileSystem(volumeName string) (err error) {
|
||||
if tmpMountNeeded {
|
||||
mountPoint = filepath.Join(TemporaryMountPointDirectory, volumeName)
|
||||
klog.V(2).Infof("The volume %v is unmounted, hence it will be temporarily mounted on %v for file system expansion", volumeName, mountPoint)
|
||||
if _, err := nsExec.Execute("mkdir", []string{"-p", mountPoint}); err != nil {
|
||||
if _, err := nsExec.Execute(nil, "mkdir", []string{"-p", mountPoint}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
return errors.Wrapf(err, "failed to create a temporary mount point %v before file system expansion", mountPoint)
|
||||
}
|
||||
if _, err := nsExec.Execute("mount", []string{devicePath, mountPoint}); err != nil {
|
||||
if _, err := nsExec.Execute(nil, "mount", []string{devicePath, mountPoint}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
return errors.Wrapf(err, "failed to temporarily mount volume %v on %v before file system expansion", volumeName, mountPoint)
|
||||
}
|
||||
}
|
||||
@@ -199,11 +199,11 @@ func ExpandFileSystem(volumeName string) (err error) {
|
||||
case "ext3":
|
||||
fallthrough
|
||||
case "ext4":
|
||||
if _, err = nsExec.Execute("resize2fs", []string{devicePath}); err != nil {
|
||||
if _, err = nsExec.Execute(nil, "resize2fs", []string{devicePath}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
return err
|
||||
}
|
||||
case "xfs":
|
||||
if _, err = nsExec.Execute("xfs_growfs", []string{mountPoint}); err != nil {
|
||||
if _, err = nsExec.Execute(nil, "xfs_growfs", []string{mountPoint}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
@@ -212,10 +212,10 @@ func ExpandFileSystem(volumeName string) (err error) {
|
||||
|
||||
// cleanup
|
||||
if tmpMountNeeded {
|
||||
if _, err := nsExec.Execute("umount", []string{mountPoint}); err != nil {
|
||||
if _, err := nsExec.Execute(nil, "umount", []string{mountPoint}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
return errors.Wrapf(err, "failed to unmount volume %v on the temporary mount point %v after file system expansion", volumeName, mountPoint)
|
||||
}
|
||||
if _, err := nsExec.Execute("rm", []string{"-r", mountPoint}); err != nil {
|
||||
if _, err := nsExec.Execute(nil, "rm", []string{"-r", mountPoint}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
return errors.Wrapf(err, "failed to remove the temporary mount point %v after file system expansion", mountPoint)
|
||||
}
|
||||
}
|
||||
@@ -225,8 +225,7 @@ func ExpandFileSystem(volumeName string) (err error) {
|
||||
|
||||
func DetectFileSystem(volumeName string) (string, error) {
|
||||
devicePath := filepath.Join(DeviceDirectory, volumeName)
|
||||
nsPath := iscsi_util.GetHostNamespacePath(HostProcPath)
|
||||
nsExec, err := iscsi_util.NewNamespaceExecutor(nsPath)
|
||||
nsExec, err := newHostNamespaceExecutor()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -235,7 +234,7 @@ func DetectFileSystem(volumeName string) (string, error) {
|
||||
// For filesystem `btrfs`, the schema is: `<device path>: UUID="<filesystem UUID>" UUID_SUB="<filesystem UUID_SUB>" TYPE="<filesystem type>"`
|
||||
// For filesystem `ext4` or `xfs`, the schema is: `<device path>: UUID="<filesystem UUID>" TYPE="<filesystem type>"`
|
||||
cmd := fmt.Sprintf("blkid %s | sed 's/.*TYPE=//g'", devicePath)
|
||||
output, err := nsExec.Execute("bash", []string{"-c", cmd})
|
||||
output, err := nsExec.Execute(nil, "bash", []string{"-c", cmd}, lhtypes.ExecuteDefaultTimeout)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "failed to get the file system info for volume %v, maybe there is no Linux file system on the volume", volumeName)
|
||||
}
|
||||
@@ -247,13 +246,12 @@ func DetectFileSystem(volumeName string) (string, error) {
|
||||
}
|
||||
|
||||
func GetDiskConfig(path string) (*DiskConfig, error) {
|
||||
nsPath := iscsi_util.GetHostNamespacePath(HostProcPath)
|
||||
nsExec, err := iscsi_util.NewNamespaceExecutor(nsPath)
|
||||
nsExec, err := newHostNamespaceExecutor()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filePath := filepath.Join(path, DiskConfigFile)
|
||||
output, err := nsExec.Execute("cat", []string{filePath})
|
||||
output, err := nsExec.Execute(nil, "cat", []string{filePath}, lhtypes.ExecuteDefaultTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot find config file %v on host: %v", filePath, err)
|
||||
}
|
||||
@@ -274,13 +272,12 @@ func GenerateDiskConfig(path string) (*DiskConfig, error) {
|
||||
return nil, fmt.Errorf("BUG: Cannot marshal %+v: %v", cfg, err)
|
||||
}
|
||||
|
||||
nsPath := iscsi_util.GetHostNamespacePath(HostProcPath)
|
||||
nsExec, err := iscsi_util.NewNamespaceExecutor(nsPath)
|
||||
nsExec, err := newHostNamespaceExecutor()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filePath := filepath.Join(path, DiskConfigFile)
|
||||
if _, err := nsExec.Execute("ls", []string{filePath}); err == nil {
|
||||
if _, err := nsExec.Execute(nil, "ls", []string{filePath}, lhtypes.ExecuteDefaultTimeout); err == nil {
|
||||
return nil, fmt.Errorf("disk cfg on %v exists, cannot override", filePath)
|
||||
}
|
||||
|
||||
@@ -293,13 +290,13 @@ func GenerateDiskConfig(path string) (*DiskConfig, error) {
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := nsExec.ExecuteWithStdin("dd", []string{"of=" + filePath}, string(encoded)); err != nil {
|
||||
if _, err := nsExec.ExecuteWithStdin(nil, "dd", []string{"of=" + filePath}, string(encoded), lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
return nil, fmt.Errorf("cannot write to disk cfg on %v: %v", filePath, err)
|
||||
}
|
||||
if err := CreateDiskPathReplicaSubdirectory(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := nsExec.Execute("sync", []string{filePath}); err != nil {
|
||||
if _, err := nsExec.Execute(nil, "sync", []string{filePath}, lhtypes.ExecuteDefaultTimeout); err != nil {
|
||||
return nil, fmt.Errorf("cannot sync disk cfg on %v: %v", filePath, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,21 @@ func CollectHostWithContext(
|
||||
// The values of map entries will contain the collected data in bytes if the data was not stored to disk
|
||||
collectResult.AllCollectedData = allCollectedData
|
||||
|
||||
// Local host preflight collectors are the only collection path (cluster,
|
||||
// remote host, in-cluster support bundle) that skipped redaction entirely.
|
||||
// A `run` collector's captured environment in particular can carry
|
||||
// credentials verbatim (e.g. HTTPS_PROXY with embedded Basic Auth) into
|
||||
// the bundle. See https://github.com/replicatedhq/troubleshoot/issues/2100.
|
||||
_, span := otel.Tracer(constants.LIB_TRACER_NAME).Start(ctx, "Host collectors")
|
||||
span.SetAttributes(attribute.String("type", "Redactors"))
|
||||
if err := collect.RedactResult(opts.BundlePath, collect.CollectorResult(allCollectedData), nil); err != nil {
|
||||
err = errors.Wrap(err, "failed to redact host collector results")
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.End()
|
||||
return collectResult, err
|
||||
}
|
||||
span.End()
|
||||
|
||||
return collectResult, nil
|
||||
}
|
||||
|
||||
@@ -245,6 +260,8 @@ func CollectWithContext(ctx context.Context, opts CollectOpts, p *troubleshootv1
|
||||
// move Copy Collectors if any to the end of the execution list
|
||||
allCollectors = collect.EnsureCopyLast(allCollectors)
|
||||
|
||||
var skippedCollectors []collect.SkippedCollector
|
||||
|
||||
for i, collector := range allCollectors {
|
||||
_, span := otel.Tracer(constants.LIB_TRACER_NAME).Start(ctx, collector.Title())
|
||||
span.SetAttributes(attribute.String("type", reflect.TypeOf(collector).String()))
|
||||
@@ -254,6 +271,13 @@ func CollectWithContext(ctx context.Context, opts CollectOpts, p *troubleshootv1
|
||||
klog.V(1).Infof("excluding %q collector", collector.Title())
|
||||
span.SetAttributes(attribute.Bool(constants.EXCLUDED, true))
|
||||
span.End()
|
||||
|
||||
skippedCollectors = append(skippedCollectors, collect.SkippedCollector{
|
||||
Collector: collector.Title(),
|
||||
Reason: "excluded",
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -270,6 +294,19 @@ func CollectWithContext(ctx context.Context, opts CollectOpts, p *troubleshootv1
|
||||
}
|
||||
span.SetStatus(codes.Error, "skipping collector, insufficient RBAC permissions")
|
||||
span.End()
|
||||
|
||||
rbacErrors := collector.GetRBACErrors()
|
||||
errorMessages := make([]string, 0, len(rbacErrors))
|
||||
for _, e := range rbacErrors {
|
||||
errorMessages = append(errorMessages, e.Error())
|
||||
}
|
||||
skippedCollectors = append(skippedCollectors, collect.SkippedCollector{
|
||||
Collector: collector.Title(),
|
||||
Reason: "insufficient RBAC permissions",
|
||||
Errors: errorMessages,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -320,6 +357,9 @@ func CollectWithContext(ctx context.Context, opts CollectOpts, p *troubleshootv1
|
||||
span.End()
|
||||
}
|
||||
|
||||
// Write skipped collectors manifest so users can see what was missed
|
||||
collect.WriteSkippedCollectors(skippedCollectors, allCollectedData, opts.BundlePath)
|
||||
|
||||
// The values of map entries will contain the collected data in bytes if the data was not stored to disk
|
||||
collectResult.AllCollectedData = allCollectedData
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
@@ -209,3 +212,46 @@ func TestCollectWithContext_PreservesOrderAfterClusterResources(t *testing.T) {
|
||||
assert.Less(t, dataIndex, secretIndex, "data collectors should come before secret collectors, preserving relative order")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectHostWithContext_RedactsSensitiveEnvValues verifies that a `run`
|
||||
// host collector's captured environment is redacted before being written to
|
||||
// the bundle. CollectHostWithContext is the only collection path (cluster,
|
||||
// host, remote) that skipped redaction entirely: every env var passed to the
|
||||
// command -- including credentials embedded in a proxy URL -- landed
|
||||
// verbatim in <collectorName>-info.json.
|
||||
func TestCollectHostWithContext_RedactsSensitiveEnvValues(t *testing.T) {
|
||||
bundlePath := t.TempDir()
|
||||
|
||||
hostPreflight := &troubleshootv1beta2.HostPreflight{
|
||||
Spec: troubleshootv1beta2.HostPreflightSpec{
|
||||
Collectors: []*troubleshootv1beta2.HostCollect{
|
||||
{
|
||||
HostRun: &troubleshootv1beta2.HostRun{
|
||||
HostCollectorMeta: troubleshootv1beta2.HostCollectorMeta{
|
||||
CollectorName: "proxy-credential-check",
|
||||
},
|
||||
Command: "sh",
|
||||
Args: []string{"-c", "echo ok"},
|
||||
IgnoreParentEnvs: true,
|
||||
Env: []string{
|
||||
"HTTPS_PROXY=http://alice:sw0rdfish@proxy.example.com:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := CollectHostWithContext(context.Background(), CollectOpts{
|
||||
ProgressChan: make(chan interface{}, 100),
|
||||
BundlePath: bundlePath,
|
||||
}, hostPreflight)
|
||||
require.NoError(t, err)
|
||||
|
||||
infoPath := filepath.Join(bundlePath, "host-collectors/run-host/proxy-credential-check-info.json")
|
||||
b, err := os.ReadFile(infoPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotContains(t, string(b), "sw0rdfish", "proxy credential leaked unredacted into the host preflight bundle")
|
||||
assert.Contains(t, string(b), "***HIDDEN***", "expected the built-in URL-userinfo redactor to mask the credential")
|
||||
}
|
||||
|
||||
+31
-32
@@ -35,6 +35,32 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// kurlInstallerRedactors are built-in redactors scoped to the kurl installer
|
||||
// custom resource. They previously applied only to the YAML copy of custom
|
||||
// resources; now they target the JSON file (and the YAML symlink that points to it).
|
||||
// The installer CRD can be either cluster-scoped (installers.cluster.kurl.sh.json)
|
||||
// or namespaced (installers.cluster.kurl.sh/<namespace>.json), so both patterns are
|
||||
// included.
|
||||
var kurlInstallerRedactors = []*troubleshootv1beta2.Redact{
|
||||
{
|
||||
Name: "Redact kurl installer fields",
|
||||
FileSelector: troubleshootv1beta2.FileSelector{
|
||||
Files: []string{
|
||||
fmt.Sprintf("%s/%s/%s.json", constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES, "installers.cluster.kurl.sh"),
|
||||
fmt.Sprintf("%s/%s/%s/*.json", constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES, "installers.cluster.kurl.sh"),
|
||||
},
|
||||
},
|
||||
Removals: troubleshootv1beta2.Removals{
|
||||
Regex: []troubleshootv1beta2.Regex{
|
||||
{Redactor: `(?i)("bootstrapToken"\s*:\s*")(?P<mask>[^"]*)(")`},
|
||||
{Redactor: `(?i)("certKey"\s*:\s*")(?P<mask>[^"]*)(")`},
|
||||
{Redactor: `(?i)("kubeadmToken"\s*:\s*")(?P<mask>[^"]*)(")`},
|
||||
{Redactor: `(?i)("kubectl\.kubernetes\.io/last-applied-configuration"\s*:\s*")(?P<mask>(?:\\.|[^"\\])*)(")`},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// A regex cache to avoid recompiling the same regexes over and over
|
||||
func compileRegex(pattern string) (*regexp.Regexp, error) {
|
||||
regexCacheLock.Lock()
|
||||
@@ -440,39 +466,12 @@ func getRedactors(path string) ([]Redactor, error) {
|
||||
redactors = append(redactors, r)
|
||||
}
|
||||
|
||||
customResources := []struct {
|
||||
resource string
|
||||
yamlPath string
|
||||
}{
|
||||
{
|
||||
resource: "installers.cluster.kurl.sh",
|
||||
yamlPath: "*.spec.kubernetes.bootstrapToken",
|
||||
},
|
||||
{
|
||||
resource: "installers.cluster.kurl.sh",
|
||||
yamlPath: "*.spec.kubernetes.certKey",
|
||||
},
|
||||
{
|
||||
resource: "installers.cluster.kurl.sh",
|
||||
yamlPath: "*.spec.kubernetes.kubeadmToken",
|
||||
},
|
||||
}
|
||||
|
||||
uniqueCRs := map[string]bool{}
|
||||
for _, cr := range customResources {
|
||||
fileglob := fmt.Sprintf("%s/%s/%s/*.yaml", constants.CLUSTER_RESOURCES_DIR, constants.CLUSTER_RESOURCES_CUSTOM_RESOURCES, cr.resource)
|
||||
redactors = append(redactors, NewYamlRedactor(cr.yamlPath, fileglob, ""))
|
||||
|
||||
// redact kubectl last applied annotation once for each resource since it contains copies of
|
||||
// redacted fields
|
||||
if !uniqueCRs[cr.resource] {
|
||||
uniqueCRs[cr.resource] = true
|
||||
redactors = append(redactors, &YamlRedactor{
|
||||
filePath: fileglob,
|
||||
maskPath: []string{"*", "metadata", "annotations", "kubectl.kubernetes.io/last-applied-configuration"},
|
||||
})
|
||||
}
|
||||
// Add built-in redactors that are scoped to specific custom resource files.
|
||||
scopedRedactors, err := buildAdditionalRedactors(path, kurlInstallerRedactors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
redactors = append(redactors, scopedRedactors...)
|
||||
|
||||
return redactors, nil
|
||||
}
|
||||
|
||||
@@ -1874,3 +1874,49 @@ func Test_redactMatchesPath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_RedactKurlInstallerJSON(t *testing.T) {
|
||||
input := `[
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kurl",
|
||||
"annotations": {
|
||||
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"cluster.kurl.sh/v1beta1\",\"kind\":\"Installer\",\"spec\":{\"kubernetes\":{\"bootstrapToken\":\"abc\",\"certKey\":\"def\",\"kubeadmToken\":\"ghi\"}}}"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"kubernetes": {
|
||||
"bootstrapToken": "abc",
|
||||
"certKey": "def",
|
||||
"kubeadmToken": "ghi"
|
||||
}
|
||||
}
|
||||
}
|
||||
]`
|
||||
|
||||
cases := []string{
|
||||
// Cluster-scoped installer (file directly under custom-resources)
|
||||
"cluster-resources/custom-resources/installers.cluster.kurl.sh.json",
|
||||
// Namespaced installer (file under a per-CRD directory)
|
||||
"cluster-resources/custom-resources/installers.cluster.kurl.sh/default.json",
|
||||
}
|
||||
|
||||
for _, path := range cases {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
r, err := Redact(strings.NewReader(input), path, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := ioutil.ReadAll(r)
|
||||
require.NoError(t, err)
|
||||
|
||||
outStr := string(out)
|
||||
require.NotContains(t, outStr, `"abc"`)
|
||||
require.NotContains(t, outStr, `"def"`)
|
||||
require.NotContains(t, outStr, `"ghi"`)
|
||||
require.Contains(t, outStr, `"bootstrapToken": "***HIDDEN***"`)
|
||||
require.Contains(t, outStr, `"certKey": "***HIDDEN***"`)
|
||||
require.Contains(t, outStr, `"kubeadmToken": "***HIDDEN***"`)
|
||||
require.Contains(t, outStr, `"kubectl.kubernetes.io/last-applied-configuration": "***HIDDEN***"`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/replicatedhq/troubleshoot/pkg/collect"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/constants"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/convert"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/redact"
|
||||
"github.com/replicatedhq/troubleshoot/pkg/version"
|
||||
"go.opentelemetry.io/otel"
|
||||
@@ -39,9 +40,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
selectorLabelKey = "ds-selector-label"
|
||||
selectorLabelValue = "remote-host-collector"
|
||||
defaultTimeout = 30
|
||||
selectorLabelKey = "ds-selector-label"
|
||||
defaultTimeout = 30
|
||||
)
|
||||
|
||||
func runHostCollectors(ctx context.Context, hostCollectors []*troubleshootv1beta2.HostCollect, additionalRedactors *troubleshootv1beta2.Redactor, bundlePath string, opts SupportBundleCreateOpts) (collect.CollectorResult, error) {
|
||||
@@ -107,7 +107,7 @@ func runCollectors(ctx context.Context, collectors []*troubleshootv1beta2.Collec
|
||||
|
||||
allCollectorsMap := make(map[reflect.Type][]collect.Collector)
|
||||
collectorTypeOrder := make([]reflect.Type, 0) // Preserve order of collector types
|
||||
allCollectedData := make(map[string][]byte)
|
||||
allCollectedData := map[string][]byte{}
|
||||
|
||||
for _, desiredCollector := range collectSpecs {
|
||||
if collectorInterface, ok := collect.GetCollector(desiredCollector, bundlePath, opts.Namespace, opts.KubernetesRestConfig, k8sClient, opts.SinceTime); ok {
|
||||
@@ -155,6 +155,8 @@ func runCollectors(ctx context.Context, collectors []*troubleshootv1beta2.Collec
|
||||
// move Copy Collectors if any to the end of the execution list
|
||||
allCollectors = collect.EnsureCopyLast(allCollectors)
|
||||
|
||||
var skippedCollectors []collect.SkippedCollector
|
||||
|
||||
for _, collector := range allCollectors {
|
||||
_, span := otel.Tracer(constants.LIB_TRACER_NAME).Start(ctx, collector.Title())
|
||||
span.SetAttributes(attribute.String("type", reflect.TypeOf(collector).String()))
|
||||
@@ -165,6 +167,13 @@ func runCollectors(ctx context.Context, collectors []*troubleshootv1beta2.Collec
|
||||
opts.CollectorProgressCallback(opts.ProgressChan, msg)
|
||||
span.SetAttributes(attribute.Bool(constants.EXCLUDED, true))
|
||||
span.End()
|
||||
|
||||
skippedCollectors = append(skippedCollectors, collect.SkippedCollector{
|
||||
Collector: collector.Title(),
|
||||
Reason: "excluded",
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -175,6 +184,19 @@ func runCollectors(ctx context.Context, collectors []*troubleshootv1beta2.Collec
|
||||
opts.CollectorProgressCallback(opts.ProgressChan, msg)
|
||||
span.SetStatus(codes.Error, "skipping collector, insufficient RBAC permissions")
|
||||
span.End()
|
||||
|
||||
rbacErrors := collector.GetRBACErrors()
|
||||
errorMessages := make([]string, 0, len(rbacErrors))
|
||||
for _, e := range rbacErrors {
|
||||
errorMessages = append(errorMessages, e.Error())
|
||||
}
|
||||
skippedCollectors = append(skippedCollectors, collect.SkippedCollector{
|
||||
Collector: collector.Title(),
|
||||
Reason: "insufficient RBAC permissions",
|
||||
Errors: errorMessages,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -207,6 +229,9 @@ func runCollectors(ctx context.Context, collectors []*troubleshootv1beta2.Collec
|
||||
span.End()
|
||||
}
|
||||
|
||||
// Write skipped collectors manifest to the bundle so users can see what was missed
|
||||
collect.WriteSkippedCollectors(skippedCollectors, allCollectedData, bundlePath)
|
||||
|
||||
collectResult := allCollectedData
|
||||
|
||||
globalRedactors := []*troubleshootv1beta2.Redact{}
|
||||
@@ -343,7 +368,7 @@ func getExecOutputs(
|
||||
TTY: false,
|
||||
}, parameterCodec)
|
||||
|
||||
exec, err := remotecommand.NewSPDYExecutor(clientConfig, "POST", req.URL())
|
||||
exec, err := k8sutil.NewFallbackExecutor(clientConfig, req.URL())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -34,6 +34,11 @@ func ExtractLicenseFromBundle(bundlePath string) (string, string, error) {
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
// Collect results from both sources in a single pass; license.json takes priority
|
||||
// regardless of its position in the tar, since configmaps often appear earlier.
|
||||
var licenseJSONID, licenseJSONSlug string
|
||||
var configmapID, configmapSlug string
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
@@ -43,50 +48,47 @@ func ExtractLicenseFromBundle(bundlePath string) (string, string, error) {
|
||||
return "", "", errors.Wrap(err, "failed to read tar header")
|
||||
}
|
||||
|
||||
// First priority: check for the new license.json file
|
||||
if strings.Contains(header.Name, "cluster-resources/license.json") && header.Typeflag == tar.TypeReg {
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
|
||||
// First priority: cluster-resources/license.json
|
||||
if strings.Contains(header.Name, "cluster-resources/license.json") {
|
||||
content := make([]byte, header.Size)
|
||||
if _, err := io.ReadFull(tarReader, content); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the license.json file
|
||||
var licenseData struct {
|
||||
LicenseID string `json:"licenseID"`
|
||||
AppSlug string `json:"appSlug"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &licenseData); err == nil {
|
||||
if licenseData.LicenseID != "" && licenseData.AppSlug != "" {
|
||||
return licenseData.LicenseID, licenseData.AppSlug, nil
|
||||
licenseJSONID = licenseData.LicenseID
|
||||
licenseJSONSlug = licenseData.AppSlug
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Fallback: process files in cluster-resources/configmaps/
|
||||
// Fallback: cluster-resources/configmaps/
|
||||
if configmapID != "" {
|
||||
continue // already have a configmap result
|
||||
}
|
||||
if !strings.Contains(header.Name, "cluster-resources/configmaps/") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
|
||||
// Process .yaml, .yml, and .json files
|
||||
if !strings.HasSuffix(header.Name, ".yaml") &&
|
||||
!strings.HasSuffix(header.Name, ".yml") &&
|
||||
!strings.HasSuffix(header.Name, ".json") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Read the file content
|
||||
content := make([]byte, header.Size)
|
||||
if _, err := io.ReadFull(tarReader, content); err != nil {
|
||||
continue // Skip files we can't read
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to extract license from this configmap
|
||||
var license string
|
||||
if strings.HasSuffix(header.Name, ".json") {
|
||||
license = extractLicenseFromJSON(content)
|
||||
@@ -95,15 +97,21 @@ func ExtractLicenseFromBundle(bundlePath string) (string, string, error) {
|
||||
}
|
||||
|
||||
if license != "" {
|
||||
// Extract app slug from filename
|
||||
filename := filepath.Base(header.Name)
|
||||
appSlug := strings.TrimSuffix(filename, ".json")
|
||||
appSlug = strings.TrimSuffix(appSlug, ".yaml")
|
||||
appSlug = strings.TrimSuffix(appSlug, ".yml")
|
||||
return license, appSlug, nil
|
||||
slug := strings.TrimSuffix(filename, ".json")
|
||||
slug = strings.TrimSuffix(slug, ".yaml")
|
||||
slug = strings.TrimSuffix(slug, ".yml")
|
||||
configmapID = license
|
||||
configmapSlug = slug
|
||||
}
|
||||
}
|
||||
|
||||
if licenseJSONID != "" {
|
||||
return licenseJSONID, licenseJSONSlug, nil
|
||||
}
|
||||
if configmapID != "" {
|
||||
return configmapID, configmapSlug, nil
|
||||
}
|
||||
return "", "", nil // No license found
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package supportbundle
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// makeBundleTarGz creates a temporary .tar.gz file containing the given entries.
|
||||
// Each entry is a (path, content) pair. Returns the file path; caller must remove it.
|
||||
func makeBundleTarGz(t *testing.T, entries []struct{ name, content string }) string {
|
||||
t.Helper()
|
||||
f, err := os.CreateTemp(t.TempDir(), "bundle-*.tar.gz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gw := gzip.NewWriter(f)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
for _, e := range entries {
|
||||
hdr := &tar.Header{
|
||||
Name: e.name,
|
||||
Typeflag: tar.TypeReg,
|
||||
Size: int64(len(e.content)),
|
||||
Mode: 0644,
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(e.content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
func TestExtractLicenseFromBundle_PrefersLicenseJSONOverConfigmap(t *testing.T) {
|
||||
// The configmap appears first in the tar but license.json should win.
|
||||
configmapContent := `{
|
||||
"kind": "ConfigMapList",
|
||||
"apiVersion": "v1",
|
||||
"items": [{
|
||||
"data": {
|
||||
"license": "configmapLicenseIDAAAAAAAAAAAA"
|
||||
}
|
||||
}]
|
||||
}`
|
||||
licenseJSONContent := `{"licenseID":"correctLicenseIDAAAAAAAAAAAA","appSlug":"my-app"}`
|
||||
|
||||
bundlePath := makeBundleTarGz(t, []struct{ name, content string }{
|
||||
// configmap comes first in the tar — this is the bug trigger
|
||||
{"bundle/cluster-resources/configmaps/kotsadm.json", configmapContent},
|
||||
{"bundle/cluster-resources/license.json", licenseJSONContent},
|
||||
})
|
||||
|
||||
licenseID, appSlug, err := ExtractLicenseFromBundle(bundlePath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if licenseID != "correctLicenseIDAAAAAAAAAAAA" {
|
||||
t.Errorf("licenseID = %q, want %q", licenseID, "correctLicenseIDAAAAAAAAAAAA")
|
||||
}
|
||||
if appSlug != "my-app" {
|
||||
t.Errorf("appSlug = %q, want %q", appSlug, "my-app")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLicenseFromBundle_FallsBackToConfigmap(t *testing.T) {
|
||||
// No license.json — should fall back to configmap scan.
|
||||
configmapContent := `{
|
||||
"kind": "ConfigMapList",
|
||||
"apiVersion": "v1",
|
||||
"items": [{
|
||||
"data": {
|
||||
"licenseID": "fallbackLicenseIDAAAAAAAAAA"
|
||||
}
|
||||
}]
|
||||
}`
|
||||
bundlePath := makeBundleTarGz(t, []struct{ name, content string }{
|
||||
{"bundle/cluster-resources/configmaps/my-app.json", configmapContent},
|
||||
})
|
||||
|
||||
licenseID, appSlug, err := ExtractLicenseFromBundle(bundlePath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if licenseID != "fallbackLicenseIDAAAAAAAAAA" {
|
||||
t.Errorf("licenseID = %q, want %q", licenseID, "fallbackLicenseIDAAAAAAAAAA")
|
||||
}
|
||||
if appSlug != "my-app" {
|
||||
t.Errorf("appSlug = %q, want %q", appSlug, "my-app")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLicenseFromBundle_ReturnsEmptyWhenNotFound(t *testing.T) {
|
||||
bundlePath := makeBundleTarGz(t, []struct{ name, content string }{
|
||||
{"bundle/cluster-resources/configmaps/some.json", `{"kind":"ConfigMapList"}`},
|
||||
})
|
||||
|
||||
licenseID, appSlug, err := ExtractLicenseFromBundle(bundlePath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if licenseID != "" || appSlug != "" {
|
||||
t.Errorf("expected empty results, got licenseID=%q appSlug=%q", licenseID, appSlug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLicenseFromBundle_RealBundle(t *testing.T) {
|
||||
// Validates against the actual support bundle that triggered the bug.
|
||||
// The bundle has license.json at tar entry #853 but kotsadm configmap at #94.
|
||||
bundlePath := filepath.Join("..", "..", "support-bundle-2026-04-10T17_13_13.tar.gz")
|
||||
if _, err := os.Stat(bundlePath); os.IsNotExist(err) {
|
||||
t.Skip("real bundle not present")
|
||||
}
|
||||
|
||||
licenseID, appSlug, err := ExtractLicenseFromBundle(bundlePath)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if licenseID != "36G95wTeTQoX7UYcm2QvhssCIkH" {
|
||||
t.Errorf("licenseID = %q, want %q", licenseID, "36G95wTeTQoX7UYcm2QvhssCIkH")
|
||||
}
|
||||
if appSlug != "embedded-cluster-smoke-test-staging-app" {
|
||||
t.Errorf("appSlug = %q, want %q", appSlug, "embedded-cluster-smoke-test-staging-app")
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ import (
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
// loadFromSecret is a package-level hook so tests can stub out cluster access.
|
||||
var loadFromSecret = specs.LoadFromSecret
|
||||
|
||||
// GetSupportBundleFromURI downloads and parses a support bundle from a URI and returns a SupportBundle object
|
||||
func GetSupportBundleFromURI(bundleURI string) (*troubleshootv1beta2.SupportBundle, error) {
|
||||
collectorContent, err := LoadSupportBundleSpec(bundleURI)
|
||||
@@ -165,6 +168,29 @@ func LoadSupportBundleSpec(arg string) ([]byte, error) {
|
||||
}
|
||||
|
||||
func LoadRedactorSpec(arg string) ([]byte, error) {
|
||||
if strings.HasPrefix(arg, "secret/") {
|
||||
// format secret/namespace-name/secret-name[/data-key]
|
||||
pathParts := strings.Split(arg, "/")
|
||||
if len(pathParts) > 4 {
|
||||
return nil, errors.Errorf("secret path %s must have at most 4 components", arg)
|
||||
}
|
||||
if len(pathParts) < 3 {
|
||||
return nil, errors.Errorf("secret path %s must have at least 3 components", arg)
|
||||
}
|
||||
|
||||
dataKey := "redactor-spec"
|
||||
if len(pathParts) == 4 {
|
||||
dataKey = pathParts[3]
|
||||
}
|
||||
|
||||
spec, err := loadFromSecret(pathParts[1], pathParts[2], dataKey)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to get spec from secret")
|
||||
}
|
||||
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(arg, "configmap/") {
|
||||
// format configmap/namespace-name/configmap-name[/data-key]
|
||||
pathParts := strings.Split(arg, "/")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package supportbundle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
|
||||
@@ -92,3 +94,61 @@ spec:
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRedactorSpec(t *testing.T) {
|
||||
origLoadFromSecret := loadFromSecret
|
||||
defer func() { loadFromSecret = origLoadFromSecret }()
|
||||
|
||||
loadFromSecret = func(namespace, secretName, key string) ([]byte, error) {
|
||||
return []byte(fmt.Sprintf("namespace=%s,secret=%s,key=%s", namespace, secretName, key)), nil
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
uri string
|
||||
wantContent string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "secret URI with default key",
|
||||
uri: "secret/default/my-redactor",
|
||||
wantContent: "namespace=default,secret=my-redactor,key=redactor-spec",
|
||||
},
|
||||
{
|
||||
name: "secret URI with custom key",
|
||||
uri: "secret/default/my-redactor/custom-key",
|
||||
wantContent: "namespace=default,secret=my-redactor,key=custom-key",
|
||||
},
|
||||
{
|
||||
name: "secret URI with too few components",
|
||||
uri: "secret/default",
|
||||
wantErr: "must have at least 3 components",
|
||||
},
|
||||
{
|
||||
name: "secret URI with too many components",
|
||||
uri: "secret/default/my-redactor/custom-key/extra",
|
||||
wantErr: "must have at most 4 components",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := LoadRedactorSpec(tt.uri)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("LoadRedactorSpec() expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("LoadRedactorSpec() error = %q, want containing %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRedactorSpec() unexpected error = %v", err)
|
||||
}
|
||||
if string(got) != tt.wantContent {
|
||||
t.Errorf("LoadRedactorSpec() = %q, want %q", string(got), tt.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1836,6 +1836,9 @@
|
||||
"exclude": {
|
||||
"oneOf": [{"type": "string"},{"type": "boolean"}]
|
||||
},
|
||||
"ignoreIfNoFiles": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1836,6 +1836,9 @@
|
||||
"exclude": {
|
||||
"oneOf": [{"type": "string"},{"type": "boolean"}]
|
||||
},
|
||||
"ignoreIfNoFiles": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"filters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user