Compare commits

...
Author SHA1 Message Date
Marc Campbell 8990c4b06e some more goreleaser/homebrew stuffs 2025-09-29 21:11:03 -07:00
Marc Campbell e03fa0a046 publish to directory: 2025-09-29 21:00:21 -07:00
Marc Campbell 758d454434 prerelease auto and more 2025-09-29 20:48:28 -07:00
Marc Campbell f0d0eda060 larger 2025-09-29 19:57:57 -07:00
Marc Campbell 4eea31d916 goreleaser 2025-09-29 19:55:14 -07:00
Marc Campbell cfea35e7c0 fix syntax 2025-09-29 19:47:50 -07:00
Marc Campbell 630aa631ee fix syntax 2025-09-29 19:44:50 -07:00
Marc Campbell b61728a11f dropping goreleaser 2025-09-29 19:20:17 -07:00
Marc Campbell 252f9650c9 larger runner for release 2025-09-29 19:02:49 -07:00
Marc Campbell 857a028585 add debug logging 2025-09-29 18:54:17 -07:00
Marc Campbell 6e8704d6f1 remove this check 2025-09-29 18:37:28 -07:00
Marc Campbell c8c246a809 remove collect binary and risc binary 2025-09-29 18:36:36 -07:00
Marc Campbell a41958c173 bump goreleaser to v2 2025-09-29 18:13:11 -07:00
Benjamin YangandNoah Campbell 17518349ff Fix/exec textanalyze path clean (#1865)
* created roadmap and yaml claude agent

* Update roadmap.md

* Fix textAnalyze analyzer to auto-match exec collector nested paths

- Auto-detect exec output files (*-stdout.txt, *-stderr.txt, *-errors.json)
- Convert simple filenames to wildcard patterns automatically
- Preserve existing wildcard patterns
- Fixes 'No matching file' errors for exec + textAnalyze workflows

---------

Co-authored-by: Noah Campbell <noah.edward.campbell@gmail.com>
2025-09-29 17:49:54 -07:00
Benjamin Yang 266647ef2f Fixing testing errors (#1871)
fix: resolve failing unit tests and diff consistency in v1beta3

- Fix readLinesFromReader to return lines WITH newlines (like difflib.SplitLines)
- Update test expectations to match correct function behavior with newlines
- This ensures consistency between streaming and non-streaming diff paths
- Fix timeout test by changing from 10ms to 500ms to eliminate flaky failures

Fixes TestReadLinesFromReader and Test_loadSupportBundleSpecsFromURIs_TimeoutError
Resolves diff output inconsistency between code paths
2025-09-29 17:48:51 -07:00
Marc Campbell 3062dd8d5c adding brew (#1872) 2025-09-29 15:01:22 -07:00
Marc Campbell 91db72b484 removing coverage reporting 2025-09-29 14:36:51 -07:00
Marc Campbell 5f0835f7d0 add a v1beta3 fixture 2025-09-29 11:02:51 -07:00
Marc Campbell 9af9845fb9 removing unused examples 2025-09-29 10:50:00 -07:00
Marc Campbell 007ef32e77 fmt: 2025-09-29 10:43:51 -07:00
Marc Campbell d9904d9632 update ci for v1beta3 (#1870) 2025-09-29 10:40:04 -07:00
Marc Campbell 074fee81f7 V1beta3 cleanup (#1869)
* moving some files around

* more cleanup

* removing more unused
2025-09-29 10:10:41 -07:00
Noah Campbell 7bd7eca528 does not auto update for package managers (#1850) 2025-09-18 12:56:12 -05:00
Noah Campbell b1830ff515 moved v1beta3 examples to proper directory 2025-09-17 17:38:26 -05:00
Noah Campbell 885819ea9a Auto support bundle upload (#1860)
* basic auto uploading support bundles

* added upload command

* added default vendor endpoint

* added auth system from replicated cli

* fixed case sensitivity issue in YAML parsing

* support bundle uploads for end customers

* app slug flag and detection without licenseID
2025-09-17 12:59:41 -05:00
Noah Campbell 20b0c5ed06 fixed gitignore issue 2025-09-17 11:02:53 -05:00
Noah Campbell 71067edb41 Updated yaml spec (#1863)
* v1beta3 spec can be read by preflight

* added test files for ease of testing

* v1beta3 renderer fixes
2025-09-17 10:38:11 -05:00
Noah Campbell d0584a4d4d Updated yaml spec (#1851)
* v1beta3 spec can be read by preflight

* added test files for ease of testing

* updated v1beta3 guide doc and added tests

* fixed not removing tmp files from v1beta3 processing

* created v1beta2 to v1beta3 converter
2025-09-16 15:43:46 -05:00
Noah Campbell acc1aad843 Preflight docs and template subcommands (#1847)
* Added docs and template subcommands with test files

* uses helm templating preflight yaml files

* merge doc requirements for multiple inputs

* Helm aware rendering and markdown output

* v1beta3 yaml structure better mirrors beta2

* Update sample-preflight-templated.yaml

* Added docs and template subcommands with test files

* uses helm templating preflight yaml files

* merge doc requirements for multiple inputs

* Helm aware rendering and markdown output

* v1beta3 yaml structure better mirrors beta2

* Update sample-preflight-templated.yaml

* Added/updated documentation on subcommands

* Update docs.go

* commit to trigger actions
2025-09-16 14:12:09 -05:00
Noah Campbell 8027e273e4 Support bundle diff (#1855)
implemented support bundle diff command
2025-09-15 13:20:35 -05:00
Marc Campbell 3b9d280a95 resetting make targets and github workflows to support v1beta3 releas… (#1853)
* resetting make targets and github workflows to support v1beta3 release later

* removing generate

* remove

* removing

* removing
2025-09-15 09:15:53 -07:00
Benjamin YangandBenjamin Yang 74811c669b Auto-collectors: foundational discovery, image metadata, CLI integrat… (#1845)
* Auto-collectors: foundational discovery, image metadata, CLI integration; reset PRD markers

* Address PR review feedback

- Implement missing namespace exclude patterns functionality
- Fix image facts collector to use empty Data field instead of static string
- Correct APIVersion to use troubleshoot.sh/v1beta2 consistently

* Fix bug bot issues: API parsing, EOF error, and API group corrections

- Fix RBAC API parsing errors in rbac_checker.go (getAPIGroup/getAPIVersion functions)
- Fix FakeReader EOF error to use standard io.EOF instead of custom error
- Fix incorrect API group from troubleshoot.sh to troubleshoot.replicated.com in run.go

These changes address the issues identified by the bug bot and ensure proper
interface compliance and consistent API group usage.

* Fix multiple bug bot issues

- Fix RBAC API parsing errors in rbac_checker.go (getAPIGroup/getAPIVersion functions)
- Fix FakeReader EOF error to use standard io.EOF instead of custom error
- Fix incorrect API group from troubleshoot.sh to troubleshoot.replicated.com in run.go
- Fix image facts collector Data field to contain structured JSON instead of static strings

These changes address all issues identified by the bug bot and ensure proper
interface compliance, consistent API usage, and meaningful data fields.

* Update auto_discovery.go

* Fix TODO comments in Auto-collector section

Fixed 3 of 4 TODOs as requested in PR review:

1. pkg/collect/images/registry_client.go (line 46):
   - Implement custom CA certificate loading
   - Add x509 import and certificate parsing logic
   - Enables image collection from private registries with custom CAs

2. cmd/troubleshoot/cli/diff.go (line 209):
   - Implement bundle file count functionality
   - Add tar/gzip imports and getFileCountFromBundle() function
   - Properly counts files in support bundle archives (.gz/.tgz)

3. cmd/troubleshoot/cli/run.go (line 338):
   - Replace TODO with clarifying comment about RemoteCollectors usage
   - Confirmed RemoteCollectors are still actively used in preflights

The 4th TODO (diff.go line 196) is left as-is since it's explicitly marked
as Phase 4 future work (Support Bundle Differencing implementation).

Addresses PR review feedback about unimplemented TODO comments.

---------

Co-authored-by: Benjamin Yang <benjaminyang@Benjamins-MacBook-Pro.local>
2025-09-12 13:44:26 -06:00
Noah Campbell a1d1a12c1a Auto updater (#1849)
* added auto updater

* updated docs

* commit to trigger actions
2025-09-12 12:30:57 -06:00
Noah Campbell 050dc8499f Change workflow branch from 'main' to 'v1beta3' 2025-09-12 11:08:16 -05:00
136 changed files with 19528 additions and 4778 deletions
+163
View File
@@ -0,0 +1,163 @@
---
name: preflight-v1beta3-writer
description: MUST BE USED PROACTIVELY WHEN WRITING PREFLIGHT CHECKS.Writes Troubleshoot v1beta3 Preflight YAML templates with strict .Values templating,
optional docStrings, and values-driven toggles. Uses repo examples for structure
and analyzer coverage. Produces ready-to-run, templated specs and companion values.
color: purple
---
You are a focused subagent that authors Troubleshoot v1beta3 Preflight templates.
Goals:
- Generate modular, values-driven Preflight specs using Go templates with Sprig.
- Use strict `.Values.*` references (no implicit defaults inside templates).
- Guard optional analyzers with `{{- if .Values.<feature>.enabled }}`.
- Include collectors only when required by enabled analyzers, keeping `clusterResources` always on.
- Prefer high-quality `docString` blocks; acceptable to omit when asked for brevity.
- Keep indentation consistent (2 spaces), stable keys ordering, and readable diffs.
Reference files in this repository:
- `v1beta3-all-analyzers.yaml` (comprehensive example template)
- `docs/v1beta3-guide.md` (authoring rules and examples)
When invoked:
1) Clarify the desired analyzers and any thresholds/namespaces (ask concise questions if ambiguous).
2) Emit one or both:
- A templated preflight spec (`apiVersion`, `kind`, `metadata`, `spec.collectors`, `spec.analyzers`).
- A companion values snippet covering all `.Values.*` keys used.
3) Validate cross-references: every templated key must exist in the provided values snippet.
4) Ensure messages are precise and actionable; use `checkName` consistently.
Conventions to follow:
- Header:
- `apiVersion: troubleshoot.sh/v1beta3`
- `kind: Preflight`
- `metadata.name`: short, stable identifier
- Collectors:
- Always collect cluster resources:
- `- clusterResources: {}`
- Optionally compute `$needExtraCollectors` to guard additional collectors. Keep logic simple and readable.
- Analyzers:
- Each optional analyzer is gated with `{{- if .Values.<feature>.enabled }}`.
- Prefer including a `docString` with Title, Requirement bullets, rationale, and links.
- Use `checkName` for stable labels.
- Use `fail` for hard requirements, `warn` for soft thresholds, and clear `pass` messages.
Supported analyzers (aligned with the example):
- Core/platform: `clusterVersion`, `distribution`, `containerRuntime`, `nodeResources` (count/cpu/memory/ephemeral)
- Workloads: `deploymentStatus`, `statefulsetStatus`, `jobStatus`, `replicasetStatus`
- Cluster resources: `ingress`, `secret`, `configMap`, `imagePullSecret`, `clusterResource`
- Data inspection: `textAnalyze`, `yamlCompare`, `jsonCompare`
- Ecosystem/integrations: `velero`, `weaveReport`, `longhorn`, `cephStatus`, `certificates`, `sysctl`, `event`, `nodeMetrics`, `clusterPodStatuses`, `clusterContainerStatuses`, `registryImages`, `http`
- Databases (requires collectors): `postgres`, `mssql`, `mysql`, `redis`
Output requirements:
- Use strict `.Values` references (no `.Values.analyzers.*` paths) and ensure they match the values snippet.
- Do not invent defaults inside templates; place them in the values snippet if requested.
- Preserve 2-space indentation; avoid tabs; wrap long lines.
- Where lists are templated, prefer clear `range` blocks.
Example skeleton (template):
```yaml
apiVersion: troubleshoot.sh/v1beta3
kind: Preflight
metadata:
name: {{ .Values.meta.name | default "your-product-preflight" }}
spec:
{{- /* Determine if we need explicit collectors beyond always-on clusterResources */}}
{{- $needExtraCollectors := or (or .Values.databases.postgres.enabled .Values.http.enabled) .Values.registryImages.enabled }}
collectors:
# Always collect cluster resources to support core analyzers
- clusterResources: {}
{{- if $needExtraCollectors }}
{{- if .Values.databases.postgres.enabled }}
- postgres:
collectorName: '{{ .Values.databases.postgres.collectorName }}'
uri: '{{ .Values.databases.postgres.uri }}'
{{- end }}
{{- if .Values.http.enabled }}
- http:
collectorName: '{{ .Values.http.collectorName }}'
get:
url: '{{ .Values.http.get.url }}'
{{- end }}
{{- if .Values.registryImages.enabled }}
- registryImages:
collectorName: '{{ .Values.registryImages.collectorName }}'
namespace: '{{ .Values.registryImages.namespace }}'
images:
{{- range .Values.registryImages.images }}
- '{{ . }}'
{{- end }}
{{- end }}
{{- end }}
analyzers:
{{- if .Values.clusterVersion.enabled }}
- docString: |
Title: Kubernetes Control Plane Requirements
Requirement:
- Version:
- Minimum: {{ .Values.clusterVersion.minVersion }}
- Recommended: {{ .Values.clusterVersion.recommendedVersion }}
- Docs: https://kubernetes.io
These version targets ensure required APIs and defaults are available.
clusterVersion:
checkName: Kubernetes version
outcomes:
- fail:
when: '< {{ .Values.clusterVersion.minVersion }}'
message: Requires at least Kubernetes {{ .Values.clusterVersion.minVersion }}.
- warn:
when: '< {{ .Values.clusterVersion.recommendedVersion }}'
message: Recommended {{ .Values.clusterVersion.recommendedVersion }} or later.
- pass:
when: '>= {{ .Values.clusterVersion.recommendedVersion }}'
message: Meets recommended and required Kubernetes versions.
{{- end }}
{{- if .Values.storageClass.enabled }}
- docString: |
Title: Default StorageClass Requirements
Requirement:
- A StorageClass named "{{ .Values.storageClass.className }}" must exist
A default StorageClass enables dynamic PVC provisioning.
storageClass:
checkName: Default StorageClass
storageClassName: '{{ .Values.storageClass.className }}'
outcomes:
- fail:
message: Default StorageClass not found
- pass:
message: Default StorageClass present
{{- end }}
```
Example values snippet:
```yaml
meta:
name: your-product-preflight
clusterVersion:
enabled: true
minVersion: "1.24.0"
recommendedVersion: "1.28.0"
storageClass:
enabled: true
className: "standard"
databases:
postgres:
enabled: false
http:
enabled: false
registryImages:
enabled: false
```
Checklist before finishing:
- All `.Values.*` references exist in the values snippet.
- Optional analyzers are gated by `if .Values.<feature>.enabled`.
- Collectors included only when required by enabled analyzers.
- `checkName` set, outcomes messages are specific and actionable.
- Indentation is consistent; templates render as valid YAML.
-115
View File
@@ -1,115 +0,0 @@
#-------------------------------------------------------------------------------------------------------------
# Modified from Codespaces default container image: https://github.com/microsoft/vscode-dev-containers/blob/main/containers/codespaces-linux/history/1.6.3.md
# - Remove PHP, Ruby, Dotnet, Java, powershell, rust dependencies
# - Remove fish shell
# - Remove Oryx
# - Remove git-lfs
# - Change shell to zsh
#
# TODO (dans): find a better way to pull in library script dynamically from vscode repo
# TODO (dans): AWS CLI - make a common script in the dev-containers repo
# TODO (dans): Gcloud CLI - make a common script in the dev-containers repo
# TODO (dans): add gcommands alias
# TODO (dans): terraform
#-------------------------------------------------------------------------------------------------------------
FROM mcr.microsoft.com/oryx/build:vso-focal-20210902.1 as replicated
ARG USERNAME=codespace
ARG USER_UID=1000
ARG USER_GID=$USER_UID
ARG HOMEDIR=/home/$USERNAME
ARG GO_VERSION="latest"
# Default to bash shell (other shells available at /usr/bin/fish and /usr/bin/zsh)
ENV SHELL=/bin/bash \
ORYX_ENV_TYPE=vsonline-present \
NODE_ROOT="${HOMEDIR}/.nodejs" \
PYTHON_ROOT="${HOMEDIR}/.python" \
HUGO_ROOT="${HOMEDIR}/.hugo" \
NVM_SYMLINK_CURRENT=true \
NVM_DIR="/home/${USERNAME}/.nvm" \
NVS_HOME="/home/${USERNAME}/.nvs" \
NPM_GLOBAL="/home/${USERNAME}/.npm-global" \
KREW_HOME="/home/${USERNAME}/.krew/bin" \
PIPX_HOME="/usr/local/py-utils" \
PIPX_BIN_DIR="/usr/local/py-utils/bin" \
GOROOT="/usr/local/go" \
GOPATH="/go"
ENV PATH="${PATH}:${KREW_HOME}:${NVM_DIR}/current/bin:${NPM_GLOBAL}/bin:${ORIGINAL_PATH}:${GOROOT}/bin:${GOPATH}/bin:${PIPX_BIN_DIR}:/opt/conda/condabin:${NODE_ROOT}/current/bin:${PYTHON_ROOT}/current/bin:${HUGO_ROOT}/current/bin:${ORYX_PATHS}"
COPY library-scripts/* first-run-notice.txt /tmp/scripts/
COPY ./config/* /etc/replicated/
COPY ./lifecycle-scripts/* /var/lib/replicated/scripts/
# Install needed utilities and setup non-root user. Use a separate RUN statement to add your own dependencies.
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# Restore man command
&& yes | unminimize 2>&1 \
# Run common script and setup user
&& bash /tmp/scripts/common-debian.sh "true" "${USERNAME}" "${USER_UID}" "${USER_GID}" "true" "true" "true" \
&& bash /tmp/scripts/setup-user.sh "${USERNAME}" "${PATH}" \
# Change owner of opt contents since Oryx can dynamically install and will run as "codespace"
&& chown ${USERNAME} /opt/* \
&& chsh -s /bin/bash ${USERNAME} \
# Verify expected build and debug tools are present
&& apt-get -y install build-essential cmake python3-dev \
# Install tools and shells not in common script
&& apt-get install -yq vim vim-doc xtail software-properties-common libsecret-1-dev \
# Install additional tools (useful for 'puppeteer' project)
&& apt-get install -y --no-install-recommends libnss3 libnspr4 libatk-bridge2.0-0 libatk1.0-0 libx11-6 libpangocairo-1.0-0 \
libx11-xcb1 libcups2 libxcomposite1 libxdamage1 libxfixes3 libpango-1.0-0 libgbm1 libgtk-3-0 \
&& bash /tmp/scripts/sshd-debian.sh \
&& bash /tmp/scripts/github-debian.sh \
&& bash /tmp/scripts/azcli-debian.sh \
# Install Moby CLI and Engine
&& /bin/bash /tmp/scripts/docker-debian.sh "true" "/var/run/docker-host.sock" "/var/run/docker.sock" "${USERNAME}" "true" \
# && bash /tmp/scripts/docker-in-docker-debian.sh "true" "${USERNAME}" "true" \
&& bash /tmp/scripts/kubectl-helm-debian.sh \
# Build latest git from source
&& bash /tmp/scripts/git-from-src-debian.sh "latest" \
# Clean up
&& apt-get autoremove -y && apt-get clean -y \
# Move first run notice to right spot
&& mkdir -p /usr/local/etc/vscode-dev-containers/ \
&& mv -f /tmp/scripts/first-run-notice.txt /usr/local/etc/vscode-dev-containers/
# Install Python
RUN bash /tmp/scripts/python-debian.sh "none" "/opt/python/latest" "${PIPX_HOME}" "${USERNAME}" "true" \
&& apt-get clean -y
# Setup Node.js, install NVM and NVS
RUN bash /tmp/scripts/node-debian.sh "${NVM_DIR}" "none" "${USERNAME}" \
&& (cd ${NVM_DIR} && git remote get-url origin && echo $(git log -n 1 --pretty=format:%H -- .)) > ${NVM_DIR}/.git-remote-and-commit \
# Install nvs (alternate cross-platform Node.js version-management tool)
&& sudo -u ${USERNAME} git clone -c advice.detachedHead=false --depth 1 https://github.com/jasongin/nvs ${NVS_HOME} 2>&1 \
&& (cd ${NVS_HOME} && git remote get-url origin && echo $(git log -n 1 --pretty=format:%H -- .)) > ${NVS_HOME}/.git-remote-and-commit \
&& sudo -u ${USERNAME} bash ${NVS_HOME}/nvs.sh install \
&& rm ${NVS_HOME}/cache/* \
# Set npm global location
&& sudo -u ${USERNAME} npm config set prefix ${NPM_GLOBAL} \
&& npm config -g set prefix ${NPM_GLOBAL} \
# Clean up
&& rm -rf ${NVM_DIR}/.git ${NVS_HOME}/.git
# Install Go
RUN bash /tmp/scripts/go-debian.sh "${GO_VERSION}" "${GOROOT}" "${GOPATH}" "${USERNAME}"
# Install Replicated Tools
RUN bash /tmp/scripts/replicated-debian.sh \
&& rm -rf /tmp/scripts \
&& apt-get clean -y
# Userspace
ENV SHELL=/bin/zsh
USER ${USERNAME}
COPY --chown=${USERNAME}:root library-scripts/replicated-userspace.sh /tmp/scripts/
RUN bash /usr/local/share/docker-init.sh \
&& bash /tmp/scripts/replicated-userspace.sh \
&& rm -rf /tmp/scripts/scripts
# Fire Docker/Moby script if needed along with Oryx's benv
ENTRYPOINT [ "/usr/local/share/docker-init.sh", "/usr/local/share/ssh-init.sh", "benv" ]
CMD [ "sleep", "infinity" ]
-7
View File
@@ -1,7 +0,0 @@
# Replicated KOTS Codespace Container
Most of the code here is borrowed from this [Microsoft repo of base images](https://github.com/microsoft/vscode-dev-containers), except for replicated specific things.
## Notes
* k3d *DOES NOT* work with DinD. You have to use the docker with docker install instead.
* Might be faster to install kubectl plugins on the `$PATH` in the `Dockerfile` instead of downloading them `onCreate.sh`.
-10
View File
@@ -1,10 +0,0 @@
apiVersion: k3d.io/v1alpha3
kind: Simple
name: replicated
servers: 1
image: rancher/k3s:v1.21.4-k3s1 # v1.21.3-k3s1 default is broken
registries:
create:
name: k3d-replicated-registry.localhost
host: "0.0.0.0"
hostPort: "5000"
-63
View File
@@ -1,63 +0,0 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.162.0/containers/javascript-node
{
"name": "Replicated Codeserver",
"build": {
"dockerfile": "Dockerfile",
"args": {
"GO_VERSION": "1.17",
}
},
// Set *default* container specific settings.json values on container create.
"settings": {
"terminal.integrated.shell.linux": "/usr/bin/zsh",
"go.toolsManagement.checkForUpdates": "local",
"go.useLanguageServer": true,
"go.gopath": "/go",
"go.goroot": "/usr/local/go",
"python.pythonPath": "/opt/python/latest/bin/python",
"python.linting.enabled": true,
"python.linting.pylintEnabled": true,
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
"python.linting.banditPath": "/usr/local/py-utils/bin/bandit",
"python.linting.flake8Path": "/usr/local/py-utils/bin/flake8",
"python.linting.mypyPath": "/usr/local/py-utils/bin/mypy",
"python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle",
"python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle",
"python.linting.pylintPath": "/usr/local/py-utils/bin/pylint",
"lldb.executable": "/usr/bin/lldb",
"files.watcherExclude": {
"**/target/**": true
}
},
"remoteUser": "codespace",
"overrideCommand": false,
"runArgs": [
"--privileged",
"--init"
],
"mounts": [
"source=/var/run/docker.sock,target=/var/run/docker-host.sock,type=bind",
],
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"dbaeumer.vscode-eslint",
"GitHub.vscode-pull-request-github",
"golang.go",
"github.copilot",
"lizebang.bash-extension-pack",
"streetsidesoftware.code-spell-checker",
],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bash /var/lib/replicated/scripts/onCreate.sh",
// Use 'postStartCommand' to run commands after the container is created like starting minikube.
"postStartCommand": "bash /var/lib/replicated/scripts/onStart.sh",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
// "remoteUser": "node"
}
-9
View File
@@ -1,9 +0,0 @@
👋 Welcome to your Replicated Codespace!
There's a local Kubernetes cluster set up for you.
Drivers Manual:
* `k` alias is available for `kubectl` with auto-completion for your pleasure
* This is a `zsh` terminal with Oh My Zsh installed. Just thought you should know.
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
#-------------------------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------
#
# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/azcli.md
# Maintainer: The VS Code and Codespaces Teams
#
# Syntax: ./azcli-debian.sh
set -e
MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc"
if [ "$(id -u)" -ne 0 ]; then
echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
exit 1
fi
# Get central common setting
get_common_setting() {
if [ "${common_settings_file_loaded}" != "true" ]; then
curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping."
common_settings_file_loaded=true
fi
if [ -f "/tmp/vsdc-settings.env" ]; then
local multi_line=""
if [ "$2" = "true" ]; then multi_line="-z"; fi
local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')"
if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi
fi
echo "$1=${!1}"
}
# Function to run apt-get if needed
apt_get_update_if_needed()
{
if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update
else
echo "Skipping apt-get update."
fi
}
# Checks if packages are installed and installs them if not
check_packages() {
if ! dpkg -s "$@" > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install --no-install-recommends "$@"
fi
}
export DEBIAN_FRONTEND=noninteractive
# Install dependencies
check_packages apt-transport-https curl ca-certificates lsb-release gnupg2
# Import key safely (new 'signed-by' method rather than deprecated apt-key approach) and install
. /etc/os-release
get_common_setting MICROSOFT_GPG_KEYS_URI
curl -sSL ${MICROSOFT_GPG_KEYS_URI} | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/azure-cli/ ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/azure-cli.list
apt-get update
apt-get install -y azure-cli
echo "Done!"
@@ -1,478 +0,0 @@
#!/usr/bin/env bash
#-------------------------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------
#
# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/common.md
# Maintainer: The VS Code and Codespaces Teams
#
# Syntax: ./common-debian.sh [install zsh flag] [username] [user UID] [user GID] [upgrade packages flag] [install Oh My Zsh! flag] [Add non-free packages]
set -e
INSTALL_ZSH=${1:-"true"}
USERNAME=${2:-"automatic"}
USER_UID=${3:-"automatic"}
USER_GID=${4:-"automatic"}
UPGRADE_PACKAGES=${5:-"true"}
INSTALL_OH_MYS=${6:-"true"}
ADD_NON_FREE_PACKAGES=${7:-"false"}
SCRIPT_DIR="$(cd $(dirname "${BASH_SOURCE[0]}") && pwd)"
MARKER_FILE="/usr/local/etc/vscode-dev-containers/common"
if [ "$(id -u)" -ne 0 ]; then
echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
exit 1
fi
# Ensure that login shells get the correct path if the user updated the PATH using ENV.
rm -f /etc/profile.d/00-restore-env.sh
echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh
chmod +x /etc/profile.d/00-restore-env.sh
# If in automatic mode, determine if a user already exists, if not use vscode
if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
USERNAME=""
POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)")
for CURRENT_USER in ${POSSIBLE_USERS[@]}; do
if id -u ${CURRENT_USER} > /dev/null 2>&1; then
USERNAME=${CURRENT_USER}
break
fi
done
if [ "${USERNAME}" = "" ]; then
USERNAME=vscode
fi
elif [ "${USERNAME}" = "none" ]; then
USERNAME=root
USER_UID=0
USER_GID=0
fi
# Load markers to see which steps have already run
if [ -f "${MARKER_FILE}" ]; then
echo "Marker file found:"
cat "${MARKER_FILE}"
source "${MARKER_FILE}"
fi
# Ensure apt is in non-interactive to avoid prompts
export DEBIAN_FRONTEND=noninteractive
# Function to call apt-get if needed
apt_get_update_if_needed()
{
if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update
else
echo "Skipping apt-get update."
fi
}
# Run install apt-utils to avoid debconf warning then verify presence of other common developer tools and dependencies
if [ "${PACKAGES_ALREADY_INSTALLED}" != "true" ]; then
package_list="apt-utils \
openssh-client \
gnupg2 \
iproute2 \
procps \
lsof \
htop \
net-tools \
psmisc \
curl \
wget \
rsync \
ca-certificates \
unzip \
zip \
nano \
vim-tiny \
less \
jq \
lsb-release \
apt-transport-https \
dialog \
libc6 \
libgcc1 \
libkrb5-3 \
libgssapi-krb5-2 \
libicu[0-9][0-9] \
liblttng-ust0 \
libstdc++6 \
zlib1g \
locales \
sudo \
ncdu \
man-db \
strace \
manpages \
manpages-dev \
init-system-helpers"
# Needed for adding manpages-posix and manpages-posix-dev which are non-free packages in Debian
if [ "${ADD_NON_FREE_PACKAGES}" = "true" ]; then
# Bring in variables from /etc/os-release like VERSION_CODENAME
. /etc/os-release
sed -i -E "s/deb http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME} main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME} main contrib non-free/" /etc/apt/sources.list
sed -i -E "s/deb-src http:\/\/(deb|httredir)\.debian\.org\/debian ${VERSION_CODENAME} main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME} main contrib non-free/" /etc/apt/sources.list
sed -i -E "s/deb http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME}-updates main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME}-updates main contrib non-free/" /etc/apt/sources.list
sed -i -E "s/deb-src http:\/\/(deb|httpredir)\.debian\.org\/debian ${VERSION_CODENAME}-updates main/deb http:\/\/\1\.debian\.org\/debian ${VERSION_CODENAME}-updates main contrib non-free/" /etc/apt/sources.list
sed -i "s/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main contrib non-free/" /etc/apt/sources.list
sed -i "s/deb-src http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main/deb http:\/\/security\.debian\.org\/debian-security ${VERSION_CODENAME}\/updates main contrib non-free/" /etc/apt/sources.list
sed -i "s/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main contrib non-free/" /etc/apt/sources.list
sed -i "s/deb-src http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main/deb http:\/\/deb\.debian\.org\/debian ${VERSION_CODENAME}-backports main contrib non-free/" /etc/apt/sources.list
echo "Running apt-get update..."
apt-get update
package_list="${package_list} manpages-posix manpages-posix-dev"
else
apt_get_update_if_needed
fi
# Install libssl1.1 if available
if [[ ! -z $(apt-cache --names-only search ^libssl1.1$) ]]; then
package_list="${package_list} libssl1.1"
fi
# Install appropriate version of libssl1.0.x if available
libssl_package=$(dpkg-query -f '${db:Status-Abbrev}\t${binary:Package}\n' -W 'libssl1\.0\.?' 2>&1 || echo '')
if [ "$(echo "$LIlibssl_packageBSSL" | grep -o 'libssl1\.0\.[0-9]:' | uniq | sort | wc -l)" -eq 0 ]; then
if [[ ! -z $(apt-cache --names-only search ^libssl1.0.2$) ]]; then
# Debian 9
package_list="${package_list} libssl1.0.2"
elif [[ ! -z $(apt-cache --names-only search ^libssl1.0.0$) ]]; then
# Ubuntu 18.04, 16.04, earlier
package_list="${package_list} libssl1.0.0"
fi
fi
echo "Packages to verify are installed: ${package_list}"
apt-get -y install --no-install-recommends ${package_list} 2> >( grep -v 'debconf: delaying package configuration, since apt-utils is not installed' >&2 )
# Install git if not already installed (may be more recent than distro version)
if ! type git > /dev/null 2>&1; then
apt-get -y install --no-install-recommends git
fi
PACKAGES_ALREADY_INSTALLED="true"
fi
# Get to latest versions of all packages
if [ "${UPGRADE_PACKAGES}" = "true" ]; then
apt_get_update_if_needed
apt-get -y upgrade --no-install-recommends
apt-get autoremove -y
fi
# Ensure at least the en_US.UTF-8 UTF-8 locale is available.
# Common need for both applications and things like the agnoster ZSH theme.
if [ "${LOCALE_ALREADY_SET}" != "true" ] && ! grep -o -E '^\s*en_US.UTF-8\s+UTF-8' /etc/locale.gen > /dev/null; then
echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen
locale-gen
LOCALE_ALREADY_SET="true"
fi
# Create or update a non-root user to match UID/GID.
if id -u ${USERNAME} > /dev/null 2>&1; then
# User exists, update if needed
if [ "${USER_GID}" != "automatic" ] && [ "$USER_GID" != "$(id -G $USERNAME)" ]; then
groupmod --gid $USER_GID $USERNAME
usermod --gid $USER_GID $USERNAME
fi
if [ "${USER_UID}" != "automatic" ] && [ "$USER_UID" != "$(id -u $USERNAME)" ]; then
usermod --uid $USER_UID $USERNAME
fi
else
# Create user
if [ "${USER_GID}" = "automatic" ]; then
groupadd $USERNAME
else
groupadd --gid $USER_GID $USERNAME
fi
if [ "${USER_UID}" = "automatic" ]; then
useradd -s /bin/bash --gid $USERNAME -m $USERNAME
else
useradd -s /bin/bash --uid $USER_UID --gid $USERNAME -m $USERNAME
fi
fi
# Add add sudo support for non-root user
if [ "${USERNAME}" != "root" ] && [ "${EXISTING_NON_ROOT_USER}" != "${USERNAME}" ]; then
echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME
chmod 0440 /etc/sudoers.d/$USERNAME
EXISTING_NON_ROOT_USER="${USERNAME}"
fi
# ** Shell customization section **
if [ "${USERNAME}" = "root" ]; then
user_rc_path="/root"
else
user_rc_path="/home/${USERNAME}"
fi
# Restore user .bashrc defaults from skeleton file if it doesn't exist or is empty
if [ ! -f "${user_rc_path}/.bashrc" ] || [ ! -s "${user_rc_path}/.bashrc" ] ; then
cp /etc/skel/.bashrc "${user_rc_path}/.bashrc"
fi
# Restore user .profile defaults from skeleton file if it doesn't exist or is empty
if [ ! -f "${user_rc_path}/.profile" ] || [ ! -s "${user_rc_path}/.profile" ] ; then
cp /etc/skel/.profile "${user_rc_path}/.profile"
fi
# .bashrc/.zshrc snippet
rc_snippet="$(cat << 'EOF'
if [ -z "${USER}" ]; then export USER=$(whoami); fi
if [[ "${PATH}" != *"$HOME/.local/bin"* ]]; then export PATH="${PATH}:$HOME/.local/bin"; fi
# Display optional first run image specific notice if configured and terminal is interactive
if [ -t 1 ] && [[ "${TERM_PROGRAM}" = "vscode" || "${TERM_PROGRAM}" = "codespaces" ]] && [ ! -f "$HOME/.config/vscode-dev-containers/first-run-notice-already-displayed" ]; then
if [ -f "/usr/local/etc/vscode-dev-containers/first-run-notice.txt" ]; then
cat "/usr/local/etc/vscode-dev-containers/first-run-notice.txt"
elif [ -f "/workspaces/.codespaces/shared/first-run-notice.txt" ]; then
cat "/workspaces/.codespaces/shared/first-run-notice.txt"
fi
mkdir -p "$HOME/.config/vscode-dev-containers"
# Mark first run notice as displayed after 10s to avoid problems with fast terminal refreshes hiding it
((sleep 10s; touch "$HOME/.config/vscode-dev-containers/first-run-notice-already-displayed") &)
fi
# Set the default git editor if not already set
if [ -z "$(git config --get core.editor)" ] && [ -z "${GIT_EDITOR}" ]; then
if [ "${TERM_PROGRAM}" = "vscode" ]; then
if [[ -n $(command -v code-insiders) && -z $(command -v code) ]]; then
export GIT_EDITOR="code-insiders --wait"
else
export GIT_EDITOR="code --wait"
fi
fi
fi
EOF
)"
# code shim, it fallbacks to code-insiders if code is not available
cat << 'EOF' > /usr/local/bin/code
#!/bin/sh
get_in_path_except_current() {
which -a "$1" | grep -A1 "$0" | grep -v "$0"
}
code="$(get_in_path_except_current code)"
if [ -n "$code" ]; then
exec "$code" "$@"
elif [ "$(command -v code-insiders)" ]; then
exec code-insiders "$@"
else
echo "code or code-insiders is not installed" >&2
exit 127
fi
EOF
chmod +x /usr/local/bin/code
# systemctl shim - tells people to use 'service' if systemd is not running
cat << 'EOF' > /usr/local/bin/systemctl
#!/bin/sh
set -e
if [ -d "/run/systemd/system" ]; then
exec /bin/systemctl/systemctl "$@"
else
echo '\n"systemd" is not running in this container due to its overhead.\nUse the "service" command to start services intead. e.g.: \n\nservice --status-all'
fi
EOF
chmod +x /usr/local/bin/systemctl
# Codespaces bash and OMZ themes - partly inspired by https://github.com/ohmyzsh/ohmyzsh/blob/master/themes/robbyrussell.zsh-theme
codespaces_bash="$(cat \
<<'EOF'
# Codespaces bash prompt theme
__bash_prompt() {
local userpart='`export XIT=$? \
&& [ ! -z "${GITHUB_USER}" ] && echo -n "\[\033[0;32m\]@${GITHUB_USER} " || echo -n "\[\033[0;32m\]\u " \
&& [ "$XIT" -ne "0" ] && echo -n "\[\033[1;31m\]➜" || echo -n "\[\033[0m\]➜"`'
local gitbranch='`\
export BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || git rev-parse --short HEAD 2>/dev/null); \
if [ "${BRANCH}" != "" ]; then \
echo -n "\[\033[0;36m\](\[\033[1;31m\]${BRANCH}" \
&& if git ls-files --error-unmatch -m --directory --no-empty-directory -o --exclude-standard ":/*" > /dev/null 2>&1; then \
echo -n " \[\033[1;33m\]✗"; \
fi \
&& echo -n "\[\033[0;36m\]) "; \
fi`'
local lightblue='\[\033[1;34m\]'
local removecolor='\[\033[0m\]'
PS1="${userpart} ${lightblue}\w ${gitbranch}${removecolor}\$ "
unset -f __bash_prompt
}
__bash_prompt
EOF
)"
codespaces_zsh="$(cat \
<<'EOF'
# Codespaces zsh prompt theme
__zsh_prompt() {
local prompt_username
if [ ! -z "${GITHUB_USER}" ]; then
prompt_username="@${GITHUB_USER}"
else
prompt_username="%n"
fi
PROMPT="%{$fg[green]%}${prompt_username} %(?:%{$reset_color%}➜ :%{$fg_bold[red]%}➜ )" # User/exit code arrow
PROMPT+='%{$fg_bold[blue]%}%(5~|%-1~/…/%3~|%4~)%{$reset_color%} ' # cwd
PROMPT+='$(git_prompt_info)%{$fg[white]%}$ %{$reset_color%}' # Git status
unset -f __zsh_prompt
}
ZSH_THEME_GIT_PROMPT_PREFIX="%{$fg_bold[cyan]%}(%{$fg_bold[red]%}"
ZSH_THEME_GIT_PROMPT_SUFFIX="%{$reset_color%} "
ZSH_THEME_GIT_PROMPT_DIRTY=" %{$fg_bold[yellow]%}✗%{$fg_bold[cyan]%})"
ZSH_THEME_GIT_PROMPT_CLEAN="%{$fg_bold[cyan]%})"
__zsh_prompt
EOF
)"
# Add notice that Oh My Bash! has been removed from images and how to provide information on how to install manually
omb_readme="$(cat \
<<'EOF'
"Oh My Bash!" has been removed from this image in favor of a simple shell prompt. If you
still wish to use it, remove "~/.oh-my-bash" and install it from: https://github.com/ohmybash/oh-my-bash
You may also want to consider "Bash-it" as an alternative: https://github.com/bash-it/bash-it
See here for infomation on adding it to your image or dotfiles: https://aka.ms/codespaces/omb-remove
EOF
)"
omb_stub="$(cat \
<<'EOF'
#!/usr/bin/env bash
if [ -t 1 ]; then
cat $HOME/.oh-my-bash/README.md
fi
EOF
)"
# Add RC snippet and custom bash prompt
if [ "${RC_SNIPPET_ALREADY_ADDED}" != "true" ]; then
echo "${rc_snippet}" >> /etc/bash.bashrc
echo "${codespaces_bash}" >> "${user_rc_path}/.bashrc"
echo 'export PROMPT_DIRTRIM=4' >> "${user_rc_path}/.bashrc"
if [ "${USERNAME}" != "root" ]; then
echo "${codespaces_bash}" >> "/root/.bashrc"
echo 'export PROMPT_DIRTRIM=4' >> "/root/.bashrc"
fi
chown ${USERNAME}:${USERNAME} "${user_rc_path}/.bashrc"
RC_SNIPPET_ALREADY_ADDED="true"
fi
# Add stub for Oh My Bash!
if [ ! -d "${user_rc_path}/.oh-my-bash}" ] && [ "${INSTALL_OH_MYS}" = "true" ]; then
mkdir -p "${user_rc_path}/.oh-my-bash" "/root/.oh-my-bash"
echo "${omb_readme}" >> "${user_rc_path}/.oh-my-bash/README.md"
echo "${omb_stub}" >> "${user_rc_path}/.oh-my-bash/oh-my-bash.sh"
chmod +x "${user_rc_path}/.oh-my-bash/oh-my-bash.sh"
if [ "${USERNAME}" != "root" ]; then
echo "${omb_readme}" >> "/root/.oh-my-bash/README.md"
echo "${omb_stub}" >> "/root/.oh-my-bash/oh-my-bash.sh"
chmod +x "/root/.oh-my-bash/oh-my-bash.sh"
fi
chown -R "${USERNAME}:${USERNAME}" "${user_rc_path}/.oh-my-bash"
fi
# Optionally install and configure zsh and Oh My Zsh!
if [ "${INSTALL_ZSH}" = "true" ]; then
if ! type zsh > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get install -y zsh
fi
if [ "${ZSH_ALREADY_INSTALLED}" != "true" ]; then
echo "${rc_snippet}" >> /etc/zsh/zshrc
ZSH_ALREADY_INSTALLED="true"
fi
# Adapted, simplified inline Oh My Zsh! install steps that adds, defaults to a codespaces theme.
# See https://github.com/ohmyzsh/ohmyzsh/blob/master/tools/install.sh for official script.
oh_my_install_dir="${user_rc_path}/.oh-my-zsh"
if [ ! -d "${oh_my_install_dir}" ] && [ "${INSTALL_OH_MYS}" = "true" ]; then
template_path="${oh_my_install_dir}/templates/zshrc.zsh-template"
user_rc_file="${user_rc_path}/.zshrc"
umask g-w,o-w
mkdir -p ${oh_my_install_dir}
git clone --depth=1 \
-c core.eol=lf \
-c core.autocrlf=false \
-c fsck.zeroPaddedFilemode=ignore \
-c fetch.fsck.zeroPaddedFilemode=ignore \
-c receive.fsck.zeroPaddedFilemode=ignore \
"https://github.com/ohmyzsh/ohmyzsh" "${oh_my_install_dir}" 2>&1
echo -e "$(cat "${template_path}")\nDISABLE_AUTO_UPDATE=true\nDISABLE_UPDATE_PROMPT=true" > ${user_rc_file}
sed -i -e 's/ZSH_THEME=.*/ZSH_THEME="codespaces"/g' ${user_rc_file}
mkdir -p ${oh_my_install_dir}/custom/themes
echo "${codespaces_zsh}" > "${oh_my_install_dir}/custom/themes/codespaces.zsh-theme"
# Shrink git while still enabling updates
cd "${oh_my_install_dir}"
git repack -a -d -f --depth=1 --window=1
# Copy to non-root user if one is specified
if [ "${USERNAME}" != "root" ]; then
cp -rf "${user_rc_file}" "${oh_my_install_dir}" /root
chown -R ${USERNAME}:${USERNAME} "${user_rc_path}"
fi
fi
fi
# Persist image metadata info, script if meta.env found in same directory
meta_info_script="$(cat << 'EOF'
#!/bin/sh
. /usr/local/etc/vscode-dev-containers/meta.env
# Minimal output
if [ "$1" = "version" ] || [ "$1" = "image-version" ]; then
echo "${VERSION}"
exit 0
elif [ "$1" = "release" ]; then
echo "${GIT_REPOSITORY_RELEASE}"
exit 0
elif [ "$1" = "content" ] || [ "$1" = "content-url" ] || [ "$1" = "contents" ] || [ "$1" = "contents-url" ]; then
echo "${CONTENTS_URL}"
exit 0
fi
#Full output
echo
echo "Development container image information"
echo
if [ ! -z "${VERSION}" ]; then echo "- Image version: ${VERSION}"; fi
if [ ! -z "${DEFINITION_ID}" ]; then echo "- Definition ID: ${DEFINITION_ID}"; fi
if [ ! -z "${VARIANT}" ]; then echo "- Variant: ${VARIANT}"; fi
if [ ! -z "${GIT_REPOSITORY}" ]; then echo "- Source code repository: ${GIT_REPOSITORY}"; fi
if [ ! -z "${GIT_REPOSITORY_RELEASE}" ]; then echo "- Source code release/branch: ${GIT_REPOSITORY_RELEASE}"; fi
if [ ! -z "${BUILD_TIMESTAMP}" ]; then echo "- Timestamp: ${BUILD_TIMESTAMP}"; fi
if [ ! -z "${CONTENTS_URL}" ]; then echo && echo "More info: ${CONTENTS_URL}"; fi
echo
EOF
)"
if [ -f "${SCRIPT_DIR}/meta.env" ]; then
mkdir -p /usr/local/etc/vscode-dev-containers/
cp -f "${SCRIPT_DIR}/meta.env" /usr/local/etc/vscode-dev-containers/meta.env
echo "${meta_info_script}" > /usr/local/bin/devcontainer-info
chmod +x /usr/local/bin/devcontainer-info
fi
# Write marker file
mkdir -p "$(dirname "${MARKER_FILE}")"
echo -e "\
PACKAGES_ALREADY_INSTALLED=${PACKAGES_ALREADY_INSTALLED}\n\
LOCALE_ALREADY_SET=${LOCALE_ALREADY_SET}\n\
EXISTING_NON_ROOT_USER=${EXISTING_NON_ROOT_USER}\n\
RC_SNIPPET_ALREADY_ADDED=${RC_SNIPPET_ALREADY_ADDED}\n\
ZSH_ALREADY_INSTALLED=${ZSH_ALREADY_INSTALLED}" > "${MARKER_FILE}"
echo "Done!"
@@ -1,224 +0,0 @@
#!/usr/bin/env bash
#-------------------------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------
#
# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/docker.md
# Maintainer: The VS Code and Codespaces Teams
#
# Syntax: ./docker-debian.sh [enable non-root docker socket access flag] [source socket] [target socket] [non-root user] [use moby]
ENABLE_NONROOT_DOCKER=${1:-"true"}
SOURCE_SOCKET=${2:-"/var/run/docker-host.sock"}
TARGET_SOCKET=${3:-"/var/run/docker.sock"}
USERNAME=${4:-"automatic"}
USE_MOBY=${5:-"true"}
MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc"
set -e
if [ "$(id -u)" -ne 0 ]; then
echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
exit 1
fi
# Determine the appropriate non-root user
if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
USERNAME=""
POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)")
for CURRENT_USER in ${POSSIBLE_USERS[@]}; do
if id -u ${CURRENT_USER} > /dev/null 2>&1; then
USERNAME=${CURRENT_USER}
break
fi
done
if [ "${USERNAME}" = "" ]; then
USERNAME=root
fi
elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then
USERNAME=root
fi
# Get central common setting
get_common_setting() {
if [ "${common_settings_file_loaded}" != "true" ]; then
curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping."
common_settings_file_loaded=true
fi
if [ -f "/tmp/vsdc-settings.env" ]; then
local multi_line=""
if [ "$2" = "true" ]; then multi_line="-z"; fi
local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')"
if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi
fi
echo "$1=${!1}"
}
# Function to run apt-get if needed
apt_get_update_if_needed()
{
if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update
else
echo "Skipping apt-get update."
fi
}
# Checks if packages are installed and installs them if not
check_packages() {
if ! dpkg -s "$@" > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install --no-install-recommends "$@"
fi
}
# Ensure apt is in non-interactive to avoid prompts
export DEBIAN_FRONTEND=noninteractive
# Install dependencies
check_packages apt-transport-https curl ca-certificates gnupg2
# Install Docker / Moby CLI if not already installed
if type docker > /dev/null 2>&1; then
echo "Docker / Moby CLI already installed."
else
# Source /etc/os-release to get OS info
. /etc/os-release
if [ "${USE_MOBY}" = "true" ]; then
# Import key safely (new 'signed-by' method rather than deprecated apt-key approach) and install
get_common_setting MICROSOFT_GPG_KEYS_URI
curl -sSL ${MICROSOFT_GPG_KEYS_URI} | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/microsoft-${ID}-${VERSION_CODENAME}-prod ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/microsoft.list
apt-get update
apt-get -y install --no-install-recommends moby-cli moby-buildx moby-compose
else
# Import key safely (new 'signed-by' method rather than deprecated apt-key approach) and install
curl -fsSL https://download.docker.com/linux/${ID}/gpg | gpg --dearmor > /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list
apt-get update
apt-get -y install --no-install-recommends docker-ce-cli
fi
fi
# Install Docker Compose if not already installed and is on a supported architecture
if type docker-compose > /dev/null 2>&1; then
echo "Docker Compose already installed."
else
TARGET_COMPOSE_ARCH="$(uname -m)"
if [ "${TARGET_COMPOSE_ARCH}" = "amd64" ]; then
TARGET_COMPOSE_ARCH="x86_64"
fi
if [ "${TARGET_COMPOSE_ARCH}" != "x86_64" ]; then
# Use pip to get a version that runns on this architecture
if ! dpkg -s python3-minimal python3-pip libffi-dev python3-venv pipx > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install python3-minimal python3-pip libffi-dev python3-venv pipx
fi
export PIPX_HOME=/usr/local/pipx
mkdir -p ${PIPX_HOME}
export PIPX_BIN_DIR=/usr/local/bin
export PIP_CACHE_DIR=/tmp/pip-tmp/cache
pipx install --system-site-packages --pip-args '--no-cache-dir --force-reinstall' docker-compose
rm -rf /tmp/pip-tmp
else
LATEST_COMPOSE_VERSION=$(basename "$(curl -fsSL -o /dev/null -w "%{url_effective}" https://github.com/docker/compose/releases/latest)")
curl -fsSL "https://github.com/docker/compose/releases/download/${LATEST_COMPOSE_VERSION}/docker-compose-$(uname -s)-${TARGET_COMPOSE_ARCH}" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
fi
fi
# If init file already exists, exit
if [ -f "/usr/local/share/docker-init.sh" ]; then
exit 0
fi
# By default, make the source and target sockets the same
if [ "${SOURCE_SOCKET}" != "${TARGET_SOCKET}" ]; then
touch "${SOURCE_SOCKET}"
ln -s "${SOURCE_SOCKET}" "${TARGET_SOCKET}"
fi
# Add a stub if not adding non-root user access, user is root
if [ "${ENABLE_NONROOT_DOCKER}" = "false" ] || [ "${USERNAME}" = "root" ]; then
echo '/usr/bin/env bash -c "\$@"' > /usr/local/share/docker-init.sh
chmod +x /usr/local/share/docker-init.sh
exit 0
fi
# If enabling non-root access and specified user is found, setup socat and add script
chown -h "${USERNAME}":root "${TARGET_SOCKET}"
if ! dpkg -s socat > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install socat
fi
tee /usr/local/share/docker-init.sh > /dev/null \
<< EOF
#!/usr/bin/env bash
#-------------------------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------
set -e
SOCAT_PATH_BASE=/tmp/vscr-docker-from-docker
SOCAT_LOG=\${SOCAT_PATH_BASE}.log
SOCAT_PID=\${SOCAT_PATH_BASE}.pid
# Wrapper function to only use sudo if not already root
sudoIf()
{
if [ "\$(id -u)" -ne 0 ]; then
sudo "\$@"
else
"\$@"
fi
}
# Log messages
log()
{
echo -e "[\$(date)] \$@" | sudoIf tee -a \${SOCAT_LOG} > /dev/null
}
echo -e "\n** \$(date) **" | sudoIf tee -a \${SOCAT_LOG} > /dev/null
log "Ensuring ${USERNAME} has access to ${SOURCE_SOCKET} via ${TARGET_SOCKET}"
# If enabled, try to add a docker group with the right GID. If the group is root,
# fall back on using socat to forward the docker socket to another unix socket so
# that we can set permissions on it without affecting the host.
if [ "${ENABLE_NONROOT_DOCKER}" = "true" ] && [ "${SOURCE_SOCKET}" != "${TARGET_SOCKET}" ] && [ "${USERNAME}" != "root" ] && [ "${USERNAME}" != "0" ]; then
SOCKET_GID=\$(stat -c '%g' ${SOURCE_SOCKET})
if [ "\${SOCKET_GID}" != "0" ]; then
log "Adding user to group with GID \${SOCKET_GID}."
if [ "\$(cat /etc/group | grep :\${SOCKET_GID}:)" = "" ]; then
sudoIf groupadd --gid \${SOCKET_GID} docker-host
fi
# Add user to group if not already in it
if [ "\$(id ${USERNAME} | grep -E "groups.*(=|,)\${SOCKET_GID}\(")" = "" ]; then
sudoIf usermod -aG \${SOCKET_GID} ${USERNAME}
fi
else
# Enable proxy if not already running
if [ ! -f "\${SOCAT_PID}" ] || ! ps -p \$(cat \${SOCAT_PID}) > /dev/null; then
log "Enabling socket proxy."
log "Proxying ${SOURCE_SOCKET} to ${TARGET_SOCKET} for vscode"
sudoIf rm -rf ${TARGET_SOCKET}
(sudoIf socat UNIX-LISTEN:${TARGET_SOCKET},fork,mode=660,user=${USERNAME} UNIX-CONNECT:${SOURCE_SOCKET} 2>&1 | sudoIf tee -a \${SOCAT_LOG} > /dev/null & echo "\$!" | sudoIf tee \${SOCAT_PID} > /dev/null)
else
log "Socket proxy already running."
fi
fi
log "Success"
fi
# Execute whatever commands were passed in (if any). This allows us
# to set this script to ENTRYPOINT while still executing the default CMD.
set +e
exec "\$@"
EOF
chmod +x /usr/local/share/docker-init.sh
chown ${USERNAME}:root /usr/local/share/docker-init.sh
echo "Done!"
@@ -1,237 +0,0 @@
#!/usr/bin/env bash
#-------------------------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------
#
# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/docker-in-docker.md
# Maintainer: The VS Code and Codespaces Teams
#
# Syntax: ./docker-in-docker-debian.sh [enable non-root docker access flag] [non-root user] [use moby]
ENABLE_NONROOT_DOCKER=${1:-"true"}
USERNAME=${2:-"automatic"}
USE_MOBY=${3:-"true"}
MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc"
set -e
if [ "$(id -u)" -ne 0 ]; then
echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
exit 1
fi
# Determine the appropriate non-root user
if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
USERNAME=""
POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)")
for CURRENT_USER in ${POSSIBLE_USERS[@]}; do
if id -u ${CURRENT_USER} > /dev/null 2>&1; then
USERNAME=${CURRENT_USER}
break
fi
done
if [ "${USERNAME}" = "" ]; then
USERNAME=root
fi
elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then
USERNAME=root
fi
# Get central common setting
get_common_setting() {
if [ "${common_settings_file_loaded}" != "true" ]; then
curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping."
common_settings_file_loaded=true
fi
if [ -f "/tmp/vsdc-settings.env" ]; then
local multi_line=""
if [ "$2" = "true" ]; then multi_line="-z"; fi
local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')"
if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi
fi
echo "$1=${!1}"
}
# Function to run apt-get if needed
apt_get_update_if_needed()
{
if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update
else
echo "Skipping apt-get update."
fi
}
# Checks if packages are installed and installs them if not
check_packages() {
if ! dpkg -s "$@" > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install --no-install-recommends "$@"
fi
}
# Ensure apt is in non-interactive to avoid prompts
export DEBIAN_FRONTEND=noninteractive
# Install dependencies
check_packages apt-transport-https curl ca-certificates lxc pigz iptables gnupg2
# Swap to legacy iptables for compatibility
if type iptables-legacy > /dev/null 2>&1; then
update-alternatives --set iptables /usr/sbin/iptables-legacy
update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy
fi
# Install Docker / Moby CLI if not already installed
if type docker > /dev/null 2>&1 && type dockerd > /dev/null 2>&1; then
echo "Docker / Moby CLI and Engine already installed."
else
# Source /etc/os-release to get OS info
. /etc/os-release
if [ "${USE_MOBY}" = "true" ]; then
# Import key safely (new 'signed-by' method rather than deprecated apt-key approach) and install
get_common_setting MICROSOFT_GPG_KEYS_URI
curl -sSL ${MICROSOFT_GPG_KEYS_URI} | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/microsoft-${ID}-${VERSION_CODENAME}-prod ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/microsoft.list
apt-get update
apt-get -y install --no-install-recommends moby-cli moby-buildx moby-compose moby-engine
else
# Import key safely (new 'signed-by' method rather than deprecated apt-key approach) and install
curl -fsSL https://download.docker.com/linux/${ID}/gpg | gpg --dearmor > /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/${ID} ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list
apt-get update
apt-get -y install --no-install-recommends docker-ce-cli docker-ce
fi
fi
echo "Finished installing docker / moby"
# Install Docker Compose if not already installed and is on a supported architecture
if type docker-compose > /dev/null 2>&1; then
echo "Docker Compose already installed."
else
TARGET_COMPOSE_ARCH="$(uname -m)"
if [ "${TARGET_COMPOSE_ARCH}" = "amd64" ]; then
TARGET_COMPOSE_ARCH="x86_64"
fi
if [ "${TARGET_COMPOSE_ARCH}" != "x86_64" ]; then
# Use pip to get a version that runns on this architecture
if ! dpkg -s python3-minimal python3-pip libffi-dev python3-venv > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install python3-minimal python3-pip libffi-dev python3-venv
fi
export PIPX_HOME=/usr/local/pipx
mkdir -p ${PIPX_HOME}
export PIPX_BIN_DIR=/usr/local/bin
export PYTHONUSERBASE=/tmp/pip-tmp
export PIP_CACHE_DIR=/tmp/pip-tmp/cache
pipx_bin=pipx
if ! type pipx > /dev/null 2>&1; then
pip3 install --disable-pip-version-check --no-warn-script-location --no-cache-dir --user pipx
pipx_bin=/tmp/pip-tmp/bin/pipx
fi
${pipx_bin} install --system-site-packages --pip-args '--no-cache-dir --force-reinstall' docker-compose
rm -rf /tmp/pip-tmp
else
LATEST_COMPOSE_VERSION=$(basename "$(curl -fsSL -o /dev/null -w "%{url_effective}" https://github.com/docker/compose/releases/latest)")
curl -fsSL "https://github.com/docker/compose/releases/download/${LATEST_COMPOSE_VERSION}/docker-compose-$(uname -s)-${TARGET_COMPOSE_ARCH}" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
fi
fi
# If init file already exists, exit
if [ -f "/usr/local/share/docker-init.sh" ]; then
echo "/usr/local/share/docker-init.sh already exists, so exiting."
exit 0
fi
echo "docker-init doesnt exist..."
# Add user to the docker group
if [ "${ENABLE_NONROOT_DOCKER}" = "true" ]; then
if ! getent group docker > /dev/null 2>&1; then
groupadd docker
fi
usermod -aG docker ${USERNAME}
fi
tee /usr/local/share/docker-init.sh > /dev/null \
<< 'EOF'
#!/usr/bin/env bash
#-------------------------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------
sudoIf()
{
if [ "$(id -u)" -ne 0 ]; then
sudo "$@"
else
"$@"
fi
}
# explicitly remove dockerd and containerd PID file to ensure that it can start properly if it was stopped uncleanly
# ie: docker kill <ID>
sudoIf find /run /var/run -iname 'docker*.pid' -delete || :
sudoIf find /run /var/run -iname 'container*.pid' -delete || :
set -e
## Dind wrapper script from docker team
# Maintained: https://github.com/moby/moby/blob/master/hack/dind
export container=docker
if [ -d /sys/kernel/security ] && ! sudoIf mountpoint -q /sys/kernel/security; then
sudoIf mount -t securityfs none /sys/kernel/security || {
echo >&2 'Could not mount /sys/kernel/security.'
echo >&2 'AppArmor detection and --privileged mode might break.'
}
fi
# Mount /tmp (conditionally)
if ! sudoIf mountpoint -q /tmp; then
sudoIf mount -t tmpfs none /tmp
fi
# cgroup v2: enable nesting
if [ -f /sys/fs/cgroup/cgroup.controllers ]; then
# move the init process (PID 1) from the root group to the /init group,
# otherwise writing subtree_control fails with EBUSY.
sudoIf mkdir -p /sys/fs/cgroup/init
sudoIf echo 1 > /sys/fs/cgroup/init/cgroup.procs
# enable controllers
sudoIf sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers \
> /sys/fs/cgroup/cgroup.subtree_control
fi
## Dind wrapper over.
# Handle DNS
set +e
cat /etc/resolv.conf | grep -i 'internal.cloudapp.net'
if [ $? -eq 0 ]
then
echo "Setting dockerd Azure DNS."
CUSTOMDNS="--dns 168.63.129.16"
else
echo "Not setting dockerd DNS manually."
CUSTOMDNS=""
fi
set -e
# Start docker/moby engine
( sudoIf dockerd $CUSTOMDNS > /tmp/dockerd.log 2>&1 ) &
set +e
# Execute whatever commands were passed in (if any). This allows us
# to set this script to ENTRYPOINT while still executing the default CMD.
exec "$@"
EOF
chmod +x /usr/local/share/docker-init.sh
chown ${USERNAME}:root /usr/local/share/docker-init.sh
@@ -1,140 +0,0 @@
#!/usr/bin/env bash
#-------------------------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------
#
# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/git-from-src.md
# Maintainer: The VS Code and Codespaces Teams
#
# Syntax: ./git-from-src-debian.sh [version] [use PPA if available]
GIT_VERSION=${1:-"latest"}
USE_PPA_IF_AVAILABLE=${2:-"false"}
GIT_CORE_PPA_ARCHIVE_GPG_KEY=E1DD270288B4E6030699E45FA1715D88E1DF1F24
GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com:80
keyserver hkps://keys.openpgp.org
keyserver hkp://keyserver.pgp.com"
set -e
if [ "$(id -u)" -ne 0 ]; then
echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
exit 1
fi
# Get central common setting
get_common_setting() {
if [ "${common_settings_file_loaded}" != "true" ]; then
curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping."
common_settings_file_loaded=true
fi
if [ -f "/tmp/vsdc-settings.env" ]; then
local multi_line=""
if [ "$2" = "true" ]; then multi_line="-z"; fi
local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')"
if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi
fi
echo "$1=${!1}"
}
# Import the specified key in a variable name passed in as
receive_gpg_keys() {
get_common_setting $1
local keys=${!1}
get_common_setting GPG_KEY_SERVERS true
local keyring_args=""
if [ ! -z "$2" ]; then
mkdir -p "$(dirname \"$2\")"
keyring_args="--no-default-keyring --keyring $2"
fi
# Use a temporary locaiton for gpg keys to avoid polluting image
export GNUPGHOME="/tmp/tmp-gnupg"
mkdir -p ${GNUPGHOME}
chmod 700 ${GNUPGHOME}
echo -e "disable-ipv6\n${GPG_KEY_SERVERS}" > ${GNUPGHOME}/dirmngr.conf
# GPG key download sometimes fails for some reason and retrying fixes it.
local retry_count=0
local gpg_ok="false"
set +e
until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ];
do
echo "(*) Downloading GPG key..."
( echo "${keys}" | xargs -n 1 gpg -q ${keyring_args} --recv-keys) 2>&1 && gpg_ok="true"
if [ "${gpg_ok}" != "true" ]; then
echo "(*) Failed getting key, retring in 10s..."
(( retry_count++ ))
sleep 10s
fi
done
set -e
if [ "${gpg_ok}" = "false" ]; then
echo "(!) Failed to install rvm."
exit 1
fi
}
# Function to run apt-get if needed
apt_get_update_if_needed()
{
if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update
else
echo "Skipping apt-get update."
fi
}
# Checks if packages are installed and installs them if not
check_packages() {
if ! dpkg -s "$@" > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install --no-install-recommends "$@"
fi
}
export DEBIAN_FRONTEND=noninteractive
# Source /etc/os-release to get OS info
. /etc/os-release
# If ubuntu, PPAs allowed, and latest - install from there
if ([ "${GIT_VERSION}" = "latest" ] || [ "${GIT_VERSION}" = "lts" ] || [ "${GIT_VERSION}" = "current" ]) && [ "${ID}" = "ubuntu" ] && [ "${USE_PPA_IF_AVAILABLE}" = "true" ]; then
echo "Using PPA to install latest git..."
check_packages apt-transport-https curl ca-certificates gnupg2
receive_gpg_keys GIT_CORE_PPA_ARCHIVE_GPG_KEY /usr/share/keyrings/gitcoreppa-archive-keyring.gpg
echo -e "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gitcoreppa-archive-keyring.gpg] http://ppa.launchpad.net/git-core/ppa/ubuntu ${VERSION_CODENAME} main\ndeb-src [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gitcoreppa-archive-keyring.gpg] http://ppa.launchpad.net/git-core/ppa/ubuntu ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/git-core-ppa.list
apt-get update
apt-get -y install --no-install-recommends git
rm -rf "/tmp/tmp-gnupg"
exit 0
fi
# Install required packages to build if missing
check_packages build-essential curl ca-certificates tar gettext libssl-dev zlib1g-dev libcurl?-openssl-dev libexpat1-dev
# Partial version matching
if [ "$(echo "${GIT_VERSION}" | grep -o '\.' | wc -l)" != "2" ]; then
requested_version="${GIT_VERSION}"
version_list="$(curl -sSL -H "Accept: application/vnd.github.v3+json" "https://api.github.com/repos/git/git/tags" | grep -oP '"name":\s*"v\K[0-9]+\.[0-9]+\.[0-9]+"' | tr -d '"' | sort -rV )"
if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "lts" ] || [ "${requested_version}" = "current" ]; then
GIT_VERSION="$(echo "${version_list}" | head -n 1)"
else
set +e
GIT_VERSION="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")"
set -e
fi
if [ -z "${GIT_VERSION}" ] || ! echo "${version_list}" | grep "^${GIT_VERSION//./\\.}$" > /dev/null 2>&1; then
echo "Invalid git version: ${requested_version}" >&2
exit 1
fi
fi
echo "Downloading source for ${GIT_VERSION}..."
curl -sL https://github.com/git/git/archive/v${GIT_VERSION}.tar.gz | tar -xzC /tmp 2>&1
echo "Building..."
cd /tmp/git-${GIT_VERSION}
make -s prefix=/usr/local all && make -s prefix=/usr/local install 2>&1
rm -rf /tmp/git-${GIT_VERSION}
echo "Done!"
@@ -1,188 +0,0 @@
#!/usr/bin/env bash
#-------------------------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------
#
# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/github.md
# Maintainer: The VS Code and Codespaces Teams
#
# Syntax: ./github-debian.sh [version]
CLI_VERSION=${1:-"latest"}
GITHUB_CLI_ARCHIVE_GPG_KEY=C99B11DEB97541F0
GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com:80
keyserver hkps://keys.openpgp.org
keyserver hkp://keyserver.pgp.com"
set -e
if [ "$(id -u)" -ne 0 ]; then
echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
exit 1
fi
# Get central common setting
get_common_setting() {
if [ "${common_settings_file_loaded}" != "true" ]; then
curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping."
common_settings_file_loaded=true
fi
if [ -f "/tmp/vsdc-settings.env" ]; then
local multi_line=""
if [ "$2" = "true" ]; then multi_line="-z"; fi
local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')"
if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi
fi
echo "$1=${!1}"
}
# Import the specified key in a variable name passed in as
receive_gpg_keys() {
get_common_setting $1
local keys=${!1}
get_common_setting GPG_KEY_SERVERS true
# Use a temporary locaiton for gpg keys to avoid polluting image
export GNUPGHOME="/tmp/tmp-gnupg"
mkdir -p ${GNUPGHOME}
chmod 700 ${GNUPGHOME}
echo -e "disable-ipv6\n${GPG_KEY_SERVERS}" > ${GNUPGHOME}/dirmngr.conf
# GPG key download sometimes fails for some reason and retrying fixes it.
local retry_count=0
local gpg_ok="false"
set +e
until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ];
do
echo "(*) Downloading GPG key..."
( echo "${keys}" | xargs -n 1 gpg --recv-keys) 2>&1 && gpg_ok="true"
if [ "${gpg_ok}" != "true" ]; then
echo "(*) Failed getting key, retring in 10s..."
(( retry_count++ ))
sleep 10s
fi
done
set -e
if [ "${gpg_ok}" = "false" ]; then
echo "(!) Failed to install rvm."
exit 1
fi
}
# Figure out correct version of a three part version number is not passed
find_version_from_git_tags() {
local variable_name=$1
local requested_version=${!variable_name}
if [ "${requested_version}" = "none" ]; then return; fi
local repository=$2
local prefix=${3:-"tags/v"}
local separator=${4:-"."}
local last_part_optional=${5:-"false"}
if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then
local escaped_separator=${separator//./\\.}
local last_part
if [ "${last_part_optional}" = "true" ]; then
last_part="(${escaped_separator}[0-9]+)?"
else
last_part="${escaped_separator}[0-9]+"
fi
local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$"
local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)"
if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then
declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)"
else
set +e
declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")"
set -e
fi
fi
if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then
echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2
exit 1
fi
echo "${variable_name}=${!variable_name}"
}
# Import the specified key in a variable name passed in as
receive_gpg_keys() {
get_common_setting $1
local keys=${!1}
get_common_setting GPG_KEY_SERVERS true
local keyring_args=""
if [ ! -z "$2" ]; then
keyring_args="--no-default-keyring --keyring $2"
fi
# Use a temporary locaiton for gpg keys to avoid polluting image
export GNUPGHOME="/tmp/tmp-gnupg"
mkdir -p ${GNUPGHOME}
chmod 700 ${GNUPGHOME}
echo -e "disable-ipv6\n${GPG_KEY_SERVERS}" > ${GNUPGHOME}/dirmngr.conf
# GPG key download sometimes fails for some reason and retrying fixes it.
local retry_count=0
local gpg_ok="false"
set +e
until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ];
do
echo "(*) Downloading GPG key..."
( echo "${keys}" | xargs -n 1 gpg -q ${keyring_args} --recv-keys) 2>&1 && gpg_ok="true"
if [ "${gpg_ok}" != "true" ]; then
echo "(*) Failed getting key, retring in 10s..."
(( retry_count++ ))
sleep 10s
fi
done
set -e
if [ "${gpg_ok}" = "false" ]; then
echo "(!) Failed to install rvm."
exit 1
fi
}
# Function to run apt-get if needed
apt_get_update_if_needed()
{
if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update
else
echo "Skipping apt-get update."
fi
}
# Checks if packages are installed and installs them if not
check_packages() {
if ! dpkg -s "$@" > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install --no-install-recommends "$@"
fi
}
export DEBIAN_FRONTEND=noninteractive
# Install curl, apt-transport-https, curl, gpg, or dirmngr, git if missing
check_packages curl ca-certificates apt-transport-https dirmngr gnupg2
if ! type git > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install --no-install-recommends git
fi
# Soft version matching
if [ "${CLI_VERSION}" != "latest" ] && [ "${CLI_VERSION}" != "lts" ] && [ "${CLI_VERSION}" != "stable" ]; then
find_version_from_git_tags CLI_VERSION "https://github.com/cli/cli"
version_suffix="=${CLI_VERSION}"
else
version_suffix=""
fi
# Install the GitHub CLI
echo "Downloading github CLI..."
# Import key safely (new method rather than deprecated apt-key approach) and install
. /etc/os-release
receive_gpg_keys GITHUB_CLI_ARCHIVE_GPG_KEY /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/github-cli.list
apt-get update
apt-get -y install "gh${version_suffix}"
rm -rf "/tmp/gh/gnupg"
echo "Done!"
-201
View File
@@ -1,201 +0,0 @@
#!/usr/bin/env bash
#-------------------------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------
#
# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/go.md
# Maintainer: The VS Code and Codespaces Teams
#
# Syntax: ./go-debian.sh [Go version] [GOROOT] [GOPATH] [non-root user] [Add GOPATH, GOROOT to rc files flag] [Install tools flag]
TARGET_GO_VERSION=${1:-"latest"}
TARGET_GOROOT=${2:-"/usr/local/go"}
TARGET_GOPATH=${3:-"/go"}
USERNAME=${4:-"automatic"}
UPDATE_RC=${5:-"true"}
INSTALL_GO_TOOLS=${6:-"true"}
set -e
if [ "$(id -u)" -ne 0 ]; then
echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
exit 1
fi
# Ensure that login shells get the correct path if the user updated the PATH using ENV.
rm -f /etc/profile.d/00-restore-env.sh
echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh
chmod +x /etc/profile.d/00-restore-env.sh
# Determine the appropriate non-root user
if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
USERNAME=""
POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)")
for CURRENT_USER in ${POSSIBLE_USERS[@]}; do
if id -u ${CURRENT_USER} > /dev/null 2>&1; then
USERNAME=${CURRENT_USER}
break
fi
done
if [ "${USERNAME}" = "" ]; then
USERNAME=root
fi
elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then
USERNAME=root
fi
updaterc() {
if [ "${UPDATE_RC}" = "true" ]; then
echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..."
echo -e "$1" >> /etc/bash.bashrc
if [ -f "/etc/zsh/zshrc" ]; then
echo -e "$1" >> /etc/zsh/zshrc
fi
fi
}
# Figure out correct version of a three part version number is not passed
find_version_from_git_tags() {
local variable_name=$1
local requested_version=${!variable_name}
if [ "${requested_version}" = "none" ]; then return; fi
local repository=$2
local prefix=${3:-"tags/v"}
local separator=${4:-"."}
local last_part_optional=${5:-"false"}
if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then
local escaped_separator=${separator//./\\.}
local last_part
if [ "${last_part_optional}" = "true" ]; then
last_part="(${escaped_separator}[0-9]+)?"
else
last_part="${escaped_separator}[0-9]+"
fi
local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$"
local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)"
if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then
declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)"
else
set +e
declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")"
set -e
fi
fi
if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then
echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2
exit 1
fi
echo "${variable_name}=${!variable_name}"
}
# Function to run apt-get if needed
apt_get_update_if_needed()
{
if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update
else
echo "Skipping apt-get update."
fi
}
# Checks if packages are installed and installs them if not
check_packages() {
if ! dpkg -s "$@" > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install --no-install-recommends "$@"
fi
}
export DEBIAN_FRONTEND=noninteractive
# Install curl, tar, git, other dependencies if missing
check_packages curl ca-certificates tar g++ gcc libc6-dev make pkg-config
if ! type git > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install --no-install-recommends git
fi
# Get closest match for version number specified
find_version_from_git_tags TARGET_GO_VERSION "https://go.googlesource.com/go" "tags/go" "." "true"
architecture="$(uname -m)"
case $architecture in
x86_64) architecture="amd64";;
aarch64 | armv8*) architecture="arm64";;
aarch32 | armv7* | armvhf*) architecture="armv6l";;
i?86) architecture="386";;
*) echo "(!) Architecture $architecture unsupported"; exit 1 ;;
esac
# Install Go
GO_INSTALL_SCRIPT="$(cat <<EOF
set -e
echo "Downloading Go ${TARGET_GO_VERSION}..."
curl -sSL -o /tmp/go.tar.gz "https://golang.org/dl/go${TARGET_GO_VERSION}.linux-${architecture}.tar.gz"
echo "Extracting Go ${TARGET_GO_VERSION}..."
tar -xzf /tmp/go.tar.gz -C "${TARGET_GOROOT}" --strip-components=1
rm -f /tmp/go.tar.gz
EOF
)"
if [ "${TARGET_GO_VERSION}" != "none" ] && ! type go > /dev/null 2>&1; then
mkdir -p "${TARGET_GOROOT}" "${TARGET_GOPATH}"
chown -R ${USERNAME} "${TARGET_GOROOT}" "${TARGET_GOPATH}"
su ${USERNAME} -c "${GO_INSTALL_SCRIPT}"
else
echo "Go already installed. Skipping."
fi
# Install Go tools that are isImportant && !replacedByGopls based on
# https://github.com/golang/vscode-go/blob/0ff533d408e4eb8ea54ce84d6efa8b2524d62873/src/goToolsInformation.ts
# Exception `dlv-dap` is a copy of github.com/go-delve/delve/cmd/dlv built from the master.
GO_TOOLS="\
golang.org/x/tools/gopls@latest \
honnef.co/go/tools/cmd/staticcheck@latest \
golang.org/x/lint/golint@latest \
github.com/mgechev/revive@latest \
github.com/uudashr/gopkgs/v2/cmd/gopkgs@latest \
github.com/ramya-rao-a/go-outline@latest \
github.com/go-delve/delve/cmd/dlv@latest \
github.com/golangci/golangci-lint/cmd/golangci-lint@latest"
if [ "${INSTALL_GO_TOOLS}" = "true" ]; then
echo "Installing common Go tools..."
export PATH=${TARGET_GOROOT}/bin:${PATH}
mkdir -p /tmp/gotools /usr/local/etc/vscode-dev-containers ${TARGET_GOPATH}/bin
cd /tmp/gotools
export GOPATH=/tmp/gotools
export GOCACHE=/tmp/gotools/cache
# Use go get for versions of go under 1.17
go_install_command=install
if [[ "1.16" > "$(go version | grep -oP 'go\K[0-9]+\.[0-9]+(\.[0-9]+)?')" ]]; then
export GO111MODULE=on
go_install_command=get
echo "Go version < 1.17, using go get."
fi
(echo "${GO_TOOLS}" | xargs -n 1 go ${go_install_command} -v )2>&1 | tee -a /usr/local/etc/vscode-dev-containers/go.log
# Move Go tools into path and clean up
mv /tmp/gotools/bin/* ${TARGET_GOPATH}/bin/
# install dlv-dap (dlv@master)
go ${go_install_command} -v github.com/go-delve/delve/cmd/dlv@master 2>&1 | tee -a /usr/local/etc/vscode-dev-containers/go.log
mv /tmp/gotools/bin/dlv ${TARGET_GOPATH}/bin/dlv-dap
rm -rf /tmp/gotools
chown -R ${USERNAME} "${TARGET_GOPATH}"
fi
# Add GOPATH variable and bin directory into PATH in bashrc/zshrc files (unless disabled)
updaterc "$(cat << EOF
export GOPATH="${TARGET_GOPATH}"
if [[ "\${PATH}" != *"\${GOPATH}/bin"* ]]; then export PATH="\${PATH}:\${GOPATH}/bin"; fi
export GOROOT="${TARGET_GOROOT}"
if [[ "\${PATH}" != *"\${GOROOT}/bin"* ]]; then export PATH="\${PATH}:\${GOROOT}/bin"; fi
EOF
)"
echo "Done!"
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env bash
#
# This is a replicated script.
#
# Syntax: ./k3s-debian.sh [k3s version] [k3s SHA256]
set -e
K3S_VERSION="${1:-"latest"}" # latest is also valid
K3S_SHA256="${2:-"automatic"}"
GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com:80
keyserver hkps://keys.openpgp.org
keyserver hkp://keyserver.pgp.com"
architecture="$(uname -m)"
case $architecture in
x86_64) architecture="amd64";;
aarch64 | armv8*) architecture="arm64";;
aarch32 | armv7* | armvhf*) architecture="armhf";;
*) echo "(!) Architecture $architecture unsupported"; exit 1 ;;
esac
# Figure out correct version of a three part version number is not passed
find_version_from_git_tags() {
local variable_name=$1
local requested_version=${!variable_name}
if [ "${requested_version}" = "none" ]; then return; fi
local repository=$2
local prefix=${3:-"tags/v"}
local separator=${4:-"."}
local last_part_optional=${5:-"false"}
if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then
local escaped_separator=${separator//./\\.}
local last_part
if [ "${last_part_optional}" = "true" ]; then
last_part="(${escaped_separator}[0-9ks\+]+)?"
else
last_part="${escaped_separator}[0-9ks\+]+"
fi
local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$"
local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)"
echo $version_list
if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then
declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)"
else
set +e
declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s+]|$)")"
set -e
fi
fi
if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then
echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2
exit 1
fi
echo "${variable_name}=${!variable_name}"
}
# Install K3s, verify checksum
if [ "${K3S_VERSION}" != "none" ]; then
echo "Downloading k3s..."
urlPrefix=
if [ "${K3S_VERSION}" = "latest" ] || [ "${K3S_VERSION}" = "lts" ] || [ "${K3S_VERSION}" = "current" ] || [ "${K3S_VERSION}" = "stable" ]; then
K3S_VERSION="latest"
urlPrefix="https://github.com/k3s-io/k3s/releases/latest/download"
else
find_version_from_git_tags K3S_VERSION https://github.com/k3s-io/k3s
if [ "${K3S_VERSION::1}" != "v" ]; then
K3S_VERSION="v${K3S_VERSION}"
fi
urlPrefix="https://github.com/k3s-io/k3s/releases/download/${K3S_VERSION}"
fi
# URL encode plus sign
K3S_VERSION="$(echo $K3S_VERSION | sed --expression='s/+/%2B/g')"
# latest is also valid in the download URLs
downloadUrl="${urlPrefix}/k3s${architecture}"
if [ "${architecture}" = "amd64" ]; then
downloadUrl="${urlPrefix}/k3s"
fi
curl -sSL -o /usr/local/bin/k3s "${downloadUrl}"
chmod 0755 /usr/local/bin/k3s
if [ "$K3S_SHA256" = "automatic" ]; then
shaUrl="${urlPrefix}/sha256sum-${architecture}.txt"
if [ "${architecture}" = "armhf" ]; then
shaUrl="${urlPrefix}/sha256sum-arm.txt"
fi
# Manifest contains image hashes, but we only need the binary
K3S_SHA256="$(curl -sSL $shaUrl | grep -P '(^|\s)\Kk3s(?=\s|$)' | cut -d ' ' -f1 )"
fi
echo $K3S_SHA256
([ "${K3S_SHA256}" = "dev-mode" ] || (echo "${K3S_SHA256} */usr/local/bin/k3s" | sha256sum -c -))
if ! type k3s > /dev/null 2>&1; then
echo '(!) k3s installation failed!'
exit 1
fi
fi
echo -e "\nDone!"
@@ -1,218 +0,0 @@
#!/usr/bin/env bash
#-------------------------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------
#
# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/kubectl-helm.md
# Maintainer: The VS Code and Codespaces Teams
#
# Syntax: ./kubectl-helm-debian.sh [kubectl verison] [Helm version] [minikube version] [kubectl SHA256] [Helm SHA256] [minikube SHA256]
set -e
KUBECTL_VERSION="${1:-"latest"}"
HELM_VERSION="${2:-"latest"}"
MINIKUBE_VERSION="${3:-"none"}" # latest is also valid
KUBECTL_SHA256="${4:-"automatic"}"
HELM_SHA256="${5:-"automatic"}"
MINIKUBE_SHA256="${6:-"automatic"}"
HELM_GPG_KEYS_URI="https://raw.githubusercontent.com/helm/helm/main/KEYS"
GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com:80
keyserver hkps://keys.openpgp.org
keyserver hkp://keyserver.pgp.com"
if [ "$(id -u)" -ne 0 ]; then
echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
exit 1
fi
# Get central common setting
get_common_setting() {
if [ "${common_settings_file_loaded}" != "true" ]; then
curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping."
common_settings_file_loaded=true
fi
if [ -f "/tmp/vsdc-settings.env" ]; then
local multi_line=""
if [ "$2" = "true" ]; then multi_line="-z"; fi
local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')"
if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi
fi
echo "$1=${!1}"
}
# Figure out correct version of a three part version number is not passed
find_version_from_git_tags() {
local variable_name=$1
local requested_version=${!variable_name}
if [ "${requested_version}" = "none" ]; then return; fi
local repository=$2
local prefix=${3:-"tags/v"}
local separator=${4:-"."}
local last_part_optional=${5:-"false"}
if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then
local escaped_separator=${separator//./\\.}
local last_part
if [ "${last_part_optional}" = "true" ]; then
last_part="(${escaped_separator}[0-9]+)?"
else
last_part="${escaped_separator}[0-9]+"
fi
local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$"
local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)"
if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then
declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)"
else
set +e
declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")"
set -e
fi
fi
if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then
echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2
exit 1
fi
echo "${variable_name}=${!variable_name}"
}
# Function to run apt-get if needed
apt_get_update_if_needed()
{
if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update
else
echo "Skipping apt-get update."
fi
}
# Checks if packages are installed and installs them if not
check_packages() {
if ! dpkg -s "$@" > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install --no-install-recommends "$@"
fi
}
# Ensure apt is in non-interactive to avoid prompts
export DEBIAN_FRONTEND=noninteractive
# Install dependencies
check_packages curl ca-certificates coreutils gnupg2 dirmngr bash-completion
if ! type git > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install --no-install-recommends git
fi
architecture="$(uname -m)"
case $architecture in
x86_64) architecture="amd64";;
aarch64 | armv8*) architecture="arm64";;
aarch32 | armv7* | armvhf*) architecture="arm";;
i?86) architecture="386";;
*) echo "(!) Architecture $architecture unsupported"; exit 1 ;;
esac
# Install the kubectl, verify checksum
echo "Downloading kubectl..."
if [ "${KUBECTL_VERSION}" = "latest" ] || [ "${KUBECTL_VERSION}" = "lts" ] || [ "${KUBECTL_VERSION}" = "current" ] || [ "${KUBECTL_VERSION}" = "stable" ]; then
KUBECTL_VERSION="$(curl -sSL https://dl.k8s.io/release/stable.txt)"
else
find_version_from_git_tags KUBECTL_VERSION https://github.com/kubernetes/kubernetes
fi
if [ "${KUBECTL_VERSION::1}" != 'v' ]; then
KUBECTL_VERSION="v${KUBECTL_VERSION}"
fi
curl -sSL -o /usr/local/bin/kubectl "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${architecture}/kubectl"
chmod 0755 /usr/local/bin/kubectl
if [ "$KUBECTL_SHA256" = "automatic" ]; then
KUBECTL_SHA256="$(curl -sSL "https://dl.k8s.io/${KUBECTL_VERSION}/bin/linux/${architecture}/kubectl.sha256")"
fi
([ "${KUBECTL_SHA256}" = "dev-mode" ] || (echo "${KUBECTL_SHA256} */usr/local/bin/kubectl" | sha256sum -c -))
if ! type kubectl > /dev/null 2>&1; then
echo '(!) kubectl installation failed!'
exit 1
fi
# kubectl bash completion
kubectl completion bash > /etc/bash_completion.d/kubectl
# kubectl zsh completion
mkdir -p /home/${USERNAME}/.oh-my-zsh/completions
kubectl completion zsh > /home/${USERNAME}/.oh-my-zsh/completions/_kubectl
# Install Helm, verify signature and checksum
echo "Downloading Helm..."
find_version_from_git_tags HELM_VERSION "https://github.com/helm/helm"
if [ "${HELM_VERSION::1}" != 'v' ]; then
HELM_VERSION="v${HELM_VERSION}"
fi
mkdir -p /tmp/helm
helm_filename="helm-${HELM_VERSION}-linux-${architecture}.tar.gz"
tmp_helm_filename="/tmp/helm/${helm_filename}"
curl -sSL "https://get.helm.sh/${helm_filename}" -o "${tmp_helm_filename}"
curl -sSL "https://github.com/helm/helm/releases/download/${HELM_VERSION}/${helm_filename}.asc" -o "${tmp_helm_filename}.asc"
export GNUPGHOME="/tmp/helm/gnupg"
mkdir -p "${GNUPGHOME}"
chmod 700 ${GNUPGHOME}
get_common_setting HELM_GPG_KEYS_URI
get_common_setting GPG_KEY_SERVERS true
curl -sSL "${HELM_GPG_KEYS_URI}" -o /tmp/helm/KEYS
echo -e "disable-ipv6\n${GPG_KEY_SERVERS}" > ${GNUPGHOME}/dirmngr.conf
gpg -q --import "/tmp/helm/KEYS"
if ! gpg --verify "${tmp_helm_filename}.asc" > ${GNUPGHOME}/verify.log 2>&1; then
echo "Verification failed!"
cat /tmp/helm/gnupg/verify.log
exit 1
fi
if [ "${HELM_SHA256}" = "automatic" ]; then
curl -sSL "https://get.helm.sh/${helm_filename}.sha256" -o "${tmp_helm_filename}.sha256"
curl -sSL "https://github.com/helm/helm/releases/download/${HELM_VERSION}/${helm_filename}.sha256.asc" -o "${tmp_helm_filename}.sha256.asc"
if ! gpg --verify "${tmp_helm_filename}.sha256.asc" > /tmp/helm/gnupg/verify.log 2>&1; then
echo "Verification failed!"
cat /tmp/helm/gnupg/verify.log
exit 1
fi
HELM_SHA256="$(cat "${tmp_helm_filename}.sha256")"
fi
([ "${HELM_SHA256}" = "dev-mode" ] || (echo "${HELM_SHA256} *${tmp_helm_filename}" | sha256sum -c -))
tar xf "${tmp_helm_filename}" -C /tmp/helm
mv -f "/tmp/helm/linux-${architecture}/helm" /usr/local/bin/
chmod 0755 /usr/local/bin/helm
rm -rf /tmp/helm
if ! type helm > /dev/null 2>&1; then
echo '(!) Helm installation failed!'
exit 1
fi
# Install Minikube, verify checksum
if [ "${MINIKUBE_VERSION}" != "none" ]; then
echo "Downloading minikube..."
if [ "${MINIKUBE_VERSION}" = "latest" ] || [ "${MINIKUBE_VERSION}" = "lts" ] || [ "${MINIKUBE_VERSION}" = "current" ] || [ "${MINIKUBE_VERSION}" = "stable" ]; then
MINIKUBE_VERSION="latest"
else
find_version_from_git_tags MINIKUBE_VERSION https://github.com/kubernetes/minikube
if [ "${MINIKUBE_VERSION::1}" != "v" ]; then
MINIKUBE_VERSION="v${MINIKUBE_VERSION}"
fi
fi
# latest is also valid in the download URLs
curl -sSL -o /usr/local/bin/minikube "https://storage.googleapis.com/minikube/releases/${MINIKUBE_VERSION}/minikube-linux-${architecture}"
chmod 0755 /usr/local/bin/minikube
if [ "$MINIKUBE_SHA256" = "automatic" ]; then
MINIKUBE_SHA256="$(curl -sSL "https://storage.googleapis.com/minikube/releases/${MINIKUBE_VERSION}/minikube-linux-${architecture}.sha256")"
fi
([ "${MINIKUBE_SHA256}" = "dev-mode" ] || (echo "${MINIKUBE_SHA256} */usr/local/bin/minikube" | sha256sum -c -))
if ! type minikube > /dev/null 2>&1; then
echo '(!) minikube installation failed!'
exit 1
fi
fi
if ! type docker > /dev/null 2>&1; then
echo -e '\n(*) Warning: The docker command was not found.\n\nYou can use one of the following scripts to install it:\n\nhttps://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/docker-in-docker.md\n\nor\n\nhttps://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/docker.md'
fi
echo -e "\nDone!"
-1
View File
@@ -1 +0,0 @@
VERSION='dev'
@@ -1,141 +0,0 @@
#!/bin/bash
#-------------------------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------
#
# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/node.md
# Maintainer: The VS Code and Codespaces Teams
#
# Syntax: ./node-debian.sh [directory to install nvm] [node version to install (use "none" to skip)] [non-root user] [Update rc files flag]
export NVM_DIR=${1:-"/usr/local/share/nvm"}
export NODE_VERSION=${2:-"lts"}
USERNAME=${3:-"automatic"}
UPDATE_RC=${4:-"true"}
export NVM_VERSION="0.38.0"
set -e
if [ "$(id -u)" -ne 0 ]; then
echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
exit 1
fi
# Ensure that login shells get the correct path if the user updated the PATH using ENV.
rm -f /etc/profile.d/00-restore-env.sh
echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh
chmod +x /etc/profile.d/00-restore-env.sh
# Determine the appropriate non-root user
if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
USERNAME=""
POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)")
for CURRENT_USER in ${POSSIBLE_USERS[@]}; do
if id -u ${CURRENT_USER} > /dev/null 2>&1; then
USERNAME=${CURRENT_USER}
break
fi
done
if [ "${USERNAME}" = "" ]; then
USERNAME=root
fi
elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then
USERNAME=root
fi
updaterc() {
if [ "${UPDATE_RC}" = "true" ]; then
echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..."
echo -e "$1" >> /etc/bash.bashrc
if [ -f "/etc/zsh/zshrc" ]; then
echo -e "$1" >> /etc/zsh/zshrc
fi
fi
}
# Function to run apt-get if needed
apt_get_update_if_needed()
{
if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update
else
echo "Skipping apt-get update."
fi
}
# Checks if packages are installed and installs them if not
check_packages() {
if ! dpkg -s "$@" > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install --no-install-recommends "$@"
fi
}
# Ensure apt is in non-interactive to avoid prompts
export DEBIAN_FRONTEND=noninteractive
# Install dependencies
check_packages apt-transport-https curl ca-certificates tar gnupg2
# Install yarn
if type yarn > /dev/null 2>&1; then
echo "Yarn already installed."
else
# Import key safely (new method rather than deprecated apt-key approach) and install
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor > /usr/share/keyrings/yarn-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/yarn-archive-keyring.gpg] https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list
apt-get update
apt-get -y install --no-install-recommends yarn
fi
# Adjust node version if required
if [ "${NODE_VERSION}" = "none" ]; then
export NODE_VERSION=
elif [ "${NODE_VERSION}" = "lts" ]; then
export NODE_VERSION="lts/*"
fi
# Install the specified node version if NVM directory already exists, then exit
if [ -d "${NVM_DIR}" ]; then
echo "NVM already installed."
if [ "${NODE_VERSION}" != "" ]; then
su ${USERNAME} -c ". $NVM_DIR/nvm.sh && nvm install ${NODE_VERSION} && nvm clear-cache"
fi
exit 0
fi
# Create nvm group, nvm dir, and set sticky bit
if ! cat /etc/group | grep -e "^nvm:" > /dev/null 2>&1; then
groupadd -r nvm
fi
umask 0002
usermod -a -G nvm ${USERNAME}
mkdir -p ${NVM_DIR}
chown :nvm ${NVM_DIR}
chmod g+s ${NVM_DIR}
su ${USERNAME} -c "$(cat << EOF
set -e
umask 0002
# Do not update profile - we'll do this manually
export PROFILE=/dev/null
curl -so- https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh | bash
source ${NVM_DIR}/nvm.sh
if [ "${NODE_VERSION}" != "" ]; then
nvm alias default ${NODE_VERSION}
fi
nvm clear-cache
EOF
)" 2>&1
# Update rc files
if [ "${UPDATE_RC}" = "true" ]; then
updaterc "$(cat <<EOF
export NVM_DIR="${NVM_DIR}"
[ -s "\$NVM_DIR/nvm.sh" ] && . "\$NVM_DIR/nvm.sh"
[ -s "\$NVM_DIR/bash_completion" ] && . "\$NVM_DIR/bash_completion"
EOF
)"
fi
echo "Done!"
@@ -1,307 +0,0 @@
#!/usr/bin/env bash
#-------------------------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------
#
# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/python.md
# Maintainer: The VS Code and Codespaces Teams
#
# Syntax: ./python-debian.sh [Python Version] [Python intall path] [PIPX_HOME] [non-root user] [Update rc files flag] [install tools]
PYTHON_VERSION=${1:-"latest"}
PYTHON_INSTALL_PATH=${2:-"/usr/local/python"}
export PIPX_HOME=${3:-"/usr/local/py-utils"}
USERNAME=${4:-"automatic"}
UPDATE_RC=${5:-"true"}
INSTALL_PYTHON_TOOLS=${6:-"true"}
USE_PPA_IF_AVAILABLE=${7:-"true"}
DEADSNAKES_PPA_ARCHIVE_GPG_KEY="F23C5A6CF475977595C89F51BA6932366A755776"
PYTHON_SOURCE_GPG_KEYS="64E628F8D684696D B26995E310250568 2D347EA6AA65421D FB9921286F5E1540 3A5CA953F73C700D 04C367C218ADD4FF 0EDDC5F26A45C816 6AF053F07D9DC8D2 C9BE28DEE6DF025C 126EB563A74B06BF D9866941EA5BBD71 ED9D77D5"
GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com:80
keyserver hkps://keys.openpgp.org
keyserver hkp://keyserver.pgp.com"
set -e
if [ "$(id -u)" -ne 0 ]; then
echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
exit 1
fi
# Ensure that login shells get the correct path if the user updated the PATH using ENV.
rm -f /etc/profile.d/00-restore-env.sh
echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" > /etc/profile.d/00-restore-env.sh
chmod +x /etc/profile.d/00-restore-env.sh
# Determine the appropriate non-root user
if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
USERNAME=""
POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)")
for CURRENT_USER in ${POSSIBLE_USERS[@]}; do
if id -u ${CURRENT_USER} > /dev/null 2>&1; then
USERNAME=${CURRENT_USER}
break
fi
done
if [ "${USERNAME}" = "" ]; then
USERNAME=root
fi
elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then
USERNAME=root
fi
updaterc() {
if [ "${UPDATE_RC}" = "true" ]; then
echo "Updating /etc/bash.bashrc and /etc/zsh/zshrc..."
echo -e "$1" >> /etc/bash.bashrc
if [ -f "/etc/zsh/zshrc" ]; then
echo -e "$1" >> /etc/zsh/zshrc
fi
fi
}
# Get central common setting
get_common_setting() {
if [ "${common_settings_file_loaded}" != "true" ]; then
curl -sfL "https://aka.ms/vscode-dev-containers/script-library/settings.env" 2>/dev/null -o /tmp/vsdc-settings.env || echo "Could not download settings file. Skipping."
common_settings_file_loaded=true
fi
if [ -f "/tmp/vsdc-settings.env" ]; then
local multi_line=""
if [ "$2" = "true" ]; then multi_line="-z"; fi
local result="$(grep ${multi_line} -oP "$1=\"?\K[^\"]+" /tmp/vsdc-settings.env | tr -d '\0')"
if [ ! -z "${result}" ]; then declare -g $1="${result}"; fi
fi
echo "$1=${!1}"
}
# Import the specified key in a variable name passed in as
receive_gpg_keys() {
get_common_setting $1
local keys=${!1}
get_common_setting GPG_KEY_SERVERS true
local keyring_args=""
if [ ! -z "$2" ]; then
mkdir -p "$(dirname \"$2\")"
keyring_args="--no-default-keyring --keyring $2"
fi
# Use a temporary locaiton for gpg keys to avoid polluting image
export GNUPGHOME="/tmp/tmp-gnupg"
mkdir -p ${GNUPGHOME}
chmod 700 ${GNUPGHOME}
echo -e "disable-ipv6\n${GPG_KEY_SERVERS}" > ${GNUPGHOME}/dirmngr.conf
# GPG key download sometimes fails for some reason and retrying fixes it.
local retry_count=0
local gpg_ok="false"
set +e
until [ "${gpg_ok}" = "true" ] || [ "${retry_count}" -eq "5" ];
do
echo "(*) Downloading GPG key..."
( echo "${keys}" | xargs -n 1 gpg -q ${keyring_args} --recv-keys) 2>&1 && gpg_ok="true"
if [ "${gpg_ok}" != "true" ]; then
echo "(*) Failed getting key, retring in 10s..."
(( retry_count++ ))
sleep 10s
fi
done
set -e
if [ "${gpg_ok}" = "false" ]; then
echo "(!) Failed to install rvm."
exit 1
fi
}
# Figure out correct version of a three part version number is not passed
find_version_from_git_tags() {
local variable_name=$1
local requested_version=${!variable_name}
if [ "${requested_version}" = "none" ]; then return; fi
local repository=$2
local prefix=${3:-"tags/v"}
local separator=${4:-"."}
local last_part_optional=${5:-"false"}
if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then
local escaped_separator=${separator//./\\.}
local last_part
if [ "${last_part_optional}" = "true" ]; then
last_part="(${escaped_separator}[0-9]+)?"
else
last_part="${escaped_separator}[0-9]+"
fi
local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$"
local version_list="$(git ls-remote --tags ${repository} | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)"
if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then
declare -g ${variable_name}="$(echo "${version_list}" | head -n 1)"
else
set +e
declare -g ${variable_name}="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")"
set -e
fi
fi
if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then
echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2
exit 1
fi
echo "${variable_name}=${!variable_name}"
}
# Function to run apt-get if needed
apt_get_update_if_needed()
{
if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update
else
echo "Skipping apt-get update."
fi
}
# Checks if packages are installed and installs them if not
check_packages() {
if ! dpkg -s "$@" > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install --no-install-recommends "$@"
fi
}
install_from_ppa() {
local requested_version="python${PYTHON_VERSION}"
echo "Using PPA to install Python..."
check_packages apt-transport-https curl ca-certificates gnupg2
receive_gpg_keys DEADSNAKES_PPA_ARCHIVE_GPG_KEY /usr/share/keyrings/deadsnakes-archive-keyring.gpg
echo -e "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/deadsnakes-archive-keyring.gpg] http://ppa.launchpad.net/deadsnakes/ppa/ubuntu ${VERSION_CODENAME} main\ndeb-src [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/deadsnakes-archive-keyring.gpg] http://ppa.launchpad.net/deadsnakes/ppa/ubuntu ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/deadsnakes-ppa.list
apt-get update
if [ "${PYTHON_VERSION}" = "latest" ] || [ "${PYTHON_VERSION}" = "current" ] || [ "${PYTHON_VERSION}" = "lts" ]; then
requested_version="$(apt-cache search '^python3\.[0-9]$' | grep -oE '^python3\.[0-9]' | sort -rV | head -n 1)"
echo "Using ${requested_version} in place of ${PYTHON_VERSION}."
fi
apt-get -y install ${requested_version}
rm -rf /tmp/tmp-gnupg
exit 0
}
install_from_source() {
if [ -d "${PYTHON_INSTALL_PATH}" ]; then
echo "Path ${PYTHON_INSTALL_PATH} already exists. Remove this existing path or select a different one."
exit 1
else
echo "Building Python ${PYTHON_VERSION} from source..."
# Install prereqs if missing
check_packages curl ca-certificates tar make build-essential libssl-dev zlib1g-dev \
wget libbz2-dev libreadline-dev libxml2-dev xz-utils tk-dev gnupg2 \
libxmlsec1-dev libsqlite3-dev libffi-dev liblzma-dev llvm dirmngr
if ! type git > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install --no-install-recommends git
fi
# Find version using soft match
find_version_from_git_tags PYTHON_VERSION "https://github.com/python/cpython"
# Download tgz of source
mkdir -p /tmp/python-src "${PYTHON_INSTALL_PATH}"
cd /tmp/python-src
TGZ_FILENAME="Python-${PYTHON_VERSION}.tgz"
TGZ_URL="https://www.python.org/ftp/python/${PYTHON_VERSION}/${TGZ_FILENAME}"
echo "Downloading ${TGZ_FILENAME}..."
curl -sSL -o "/tmp/python-src/${TGZ_FILENAME}" "${TGZ_URL}"
# Verify signature
if [ "${SKIP_SIGNATURE_CHECK}" != "true" ]; then
receive_gpg_keys PYTHON_SOURCE_GPG_KEYS
echo "Downloading ${TGZ_FILENAME}.asc..."
curl -sSL -o "/tmp/python-src/${TGZ_FILENAME}.asc" "${TGZ_URL}.asc"
gpg --verify "${TGZ_FILENAME}.asc"
fi
# Update min protocol for testing only - https://bugs.python.org/issue41561
cp /etc/ssl/openssl.cnf /tmp/python-src/
sed -i -E 's/MinProtocol[=\ ]+.*/MinProtocol = TLSv1.0/g' /tmp/python-src/openssl.cnf
export OPENSSL_CONF=/tmp/python-src/openssl.cnf
# Untar and build
tar -xzf "/tmp/python-src/${TGZ_FILENAME}" -C "/tmp/python-src" --strip-components=1
./configure --prefix="${PYTHON_INSTALL_PATH}" --enable-optimizations --with-ensurepip=install
make -j 8
make install
cd /tmp
rm -rf /tmp/python-src ${GNUPGHOME} /tmp/vscdc-settings.env
chown -R ${USERNAME} "${PYTHON_INSTALL_PATH}"
ln -s ${PYTHON_INSTALL_PATH}/bin/python3 ${PYTHON_INSTALL_PATH}/bin/python
ln -s ${PYTHON_INSTALL_PATH}/bin/pip3 ${PYTHON_INSTALL_PATH}/bin/pip
ln -s ${PYTHON_INSTALL_PATH}/bin/idle3 ${PYTHON_INSTALL_PATH}/bin/idle
ln -s ${PYTHON_INSTALL_PATH}/bin/pydoc3 ${PYTHON_INSTALL_PATH}/bin/pydoc
ln -s ${PYTHON_INSTALL_PATH}/bin/python3-config ${PYTHON_INSTALL_PATH}/bin/python-config
updaterc "export PATH=${PYTHON_INSTALL_PATH}/bin:\${PATH}"
fi
}
# Ensure apt is in non-interactive to avoid prompts
export DEBIAN_FRONTEND=noninteractive
# Install python from source if needed
if [ "${PYTHON_VERSION}" != "none" ]; then
# Source /etc/os-release to get OS info
. /etc/os-release
# If ubuntu, PPAs allowed - install from there
if [ "${ID}" = "ubuntu" ] && [ "${USE_PPA_IF_AVAILABLE}" = "true" ]; then
install_from_ppa
else
install_from_source
fi
fi
# If not installing python tools, exit
if [ "${INSTALL_PYTHON_TOOLS}" != "true" ]; then
echo "Done!"
exit 0;
fi
DEFAULT_UTILS="\
pylint \
flake8 \
autopep8 \
black \
yapf \
mypy \
pydocstyle \
pycodestyle \
bandit \
pipenv \
virtualenv"
export PIPX_BIN_DIR=${PIPX_HOME}/bin
export PATH=${PYTHON_INSTALL_PATH}/bin:${PIPX_BIN_DIR}:${PATH}
# Update pip
echo "Updating pip..."
python3 -m pip install --no-cache-dir --upgrade pip
# Create pipx group, dir, and set sticky bit
if ! cat /etc/group | grep -e "^pipx:" > /dev/null 2>&1; then
groupadd -r pipx
fi
usermod -a -G pipx ${USERNAME}
umask 0002
mkdir -p ${PIPX_BIN_DIR}
chown :pipx ${PIPX_HOME} ${PIPX_BIN_DIR}
chmod g+s ${PIPX_HOME} ${PIPX_BIN_DIR}
# Install tools
echo "Installing Python tools..."
export PYTHONUSERBASE=/tmp/pip-tmp
export PIP_CACHE_DIR=/tmp/pip-tmp/cache
pip3 install --disable-pip-version-check --no-warn-script-location --no-cache-dir --user pipx
/tmp/pip-tmp/bin/pipx install --pip-args=--no-cache-dir pipx
echo "${DEFAULT_UTILS}" | xargs -n 1 /tmp/pip-tmp/bin/pipx install --system-site-packages --pip-args '--no-cache-dir --force-reinstall'
rm -rf /tmp/pip-tmp
updaterc "$(cat << EOF
export PIPX_HOME="${PIPX_HOME}"
export PIPX_BIN_DIR="${PIPX_BIN_DIR}"
if [[ "\${PATH}" != *"\${PIPX_BIN_DIR}"* ]]; then export PATH="\${PATH}:\${PIPX_BIN_DIR}"; fi
EOF
)"
@@ -1,13 +0,0 @@
#!/usr/bin/env bash
# k3d
# v5 RC is needed to deterministically set the Registry port. Should be replaces with official release
curl -s https://raw.githubusercontent.com/rancher/k3d/main/install.sh | TAG=v5.0.0-rc.4 bash
# kustomize
pushd /tmp
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
popd
sudo mv /tmp/kustomize /usr/local/bin/
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
# install Krew
# TODO (dans): ditch krew and just download the latest binaries on the path in Dockerfile
(
set -x; cd "$(mktemp -d)" &&
OS="$(uname | tr '[:upper:]' '[:lower:]')" &&
ARCH="$(uname -m | sed -e 's/x86_64/amd64/' -e 's/\(arm\)\(64\)\?.*/\1\2/' -e 's/aarch64$/arm64/')" &&
curl -fsSLO "https://github.com/kubernetes-sigs/krew/releases/latest/download/krew.tar.gz" &&
tar zxvf krew.tar.gz &&
KREW=./krew-"${OS}_${ARCH}" &&
"$KREW" install krew
)
# install krew plugins
kubectl krew install schemahero
kubectl krew install support-bundle
kubectl krew install preflights
kubectl krew install view-secret
# Make the cache from master branch
pushd /tmp
git clone https://github.com/replicatedhq/troubleshoot.git
pushd troubleshoot
# TODO (dans): find a way to cache images on image build
go mod download
popd
rm -rf kots
popd
# Clone any extra repos here
# Autocomplete Kubernetes
cat >> ~/.zshrc << EOF
source <(kubectl completion zsh)
alias k=kubectl
complete -F __start_kubectl k
EOF
# Set Git Editor Preference
cat >> ~/.zshrc << EOF
export VISUAL=vim
export EDITOR="$VISUAL"
EOF
@@ -1,16 +0,0 @@
#!/bin/bash
# modified from https://github.com/microsoft/vscode-dev-containers/blob/main/containers/codespaces-linux/.devcontainer/setup-user.sh
# not part of the standard script library
USERNAME=${1:-codespace}
SECURE_PATH_BASE=${2:-$PATH}
echo "Defaults secure_path=\"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin:${SECURE_PATH_BASE}\"" >> /etc/sudoers.d/securepath
# Add user to a Docker group
sudo -u ${USERNAME} mkdir /home/${USERNAME}/.vsonline
groupadd -g 800 docker
usermod -a -G docker ${USERNAME}
# Create user's .local/bin
sudo -u ${USERNAME} mkdir -p /home/${USERNAME}/.local/bin
@@ -1,165 +0,0 @@
#!/usr/bin/env bash
#-------------------------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
#-------------------------------------------------------------------------------------------------------------
#
# Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/sshd.md
# Maintainer: The VS Code and Codespaces Teams
#
# Syntax: ./sshd-debian.sh [SSH Port (don't use 22)] [non-root user] [start sshd now flag] [new password for user] [fix environment flag]
#
# Note: You can change your user's password with "sudo passwd $(whoami)" (or just "passwd" if running as root).
SSHD_PORT=${1:-"2222"}
USERNAME=${2:-"automatic"}
START_SSHD=${3:-"false"}
NEW_PASSWORD=${4:-"skip"}
FIX_ENVIRONMENT=${5:-"true"}
set -e
if [ "$(id -u)" -ne 0 ]; then
echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
exit 1
fi
# Determine the appropriate non-root user
if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
USERNAME=""
POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)")
for CURRENT_USER in ${POSSIBLE_USERS[@]}; do
if id -u ${CURRENT_USER} > /dev/null 2>&1; then
USERNAME=${CURRENT_USER}
break
fi
done
if [ "${USERNAME}" = "" ]; then
USERNAME=root
fi
elif [ "${USERNAME}" = "none" ] || ! id -u ${USERNAME} > /dev/null 2>&1; then
USERNAME=root
fi
# Function to run apt-get if needed
apt_get_update_if_needed()
{
if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update
else
echo "Skipping apt-get update."
fi
}
# Checks if packages are installed and installs them if not
check_packages() {
if ! dpkg -s "$@" > /dev/null 2>&1; then
apt_get_update_if_needed
apt-get -y install --no-install-recommends "$@"
fi
}
# Ensure apt is in non-interactive to avoid prompts
export DEBIAN_FRONTEND=noninteractive
# Install openssh-server openssh-client
check_packages openssh-server openssh-client lsof
# Generate password if new password set to the word "random"
if [ "${NEW_PASSWORD}" = "random" ]; then
NEW_PASSWORD="$(openssl rand -hex 16)"
EMIT_PASSWORD="true"
elif [ "${NEW_PASSWORD}" != "skip" ]; then
# If new password not set to skip, set it for the specified user
echo "${USERNAME}:${NEW_PASSWORD}" | chpasswd
fi
# Add user to ssh group
if [ "${USERNAME}" != "root" ]; then
usermod -aG ssh ${USERNAME}
fi
# Setup sshd
mkdir -p /var/run/sshd
sed -i 's/session\s*required\s*pam_loginuid\.so/session optional pam_loginuid.so/g' /etc/pam.d/sshd
sed -i 's/#*PermitRootLogin prohibit-password/PermitRootLogin yes/g' /etc/ssh/sshd_config
sed -i -E "s/#*\s*Port\s+.+/Port ${SSHD_PORT}/g" /etc/ssh/sshd_config
# Need to UsePAM so /etc/environment is processed
sed -i -E "s/#?\s*UsePAM\s+.+/UsePAM yes/g" /etc/ssh/sshd_config
# Script to store variables that exist at the time the ENTRYPOINT is fired
store_env_script="$(cat << 'EOF'
# Wire in codespaces secret processing to zsh if present (since may have been added to image after script was run)
if [ -f /etc/zsh/zlogin ] && ! grep '/etc/profile.d/00-restore-secrets.sh' /etc/zsh/zlogin > /dev/null 2>&1; then
echo -e "if [ -f /etc/profile.d/00-restore-secrets.sh ]; then . /etc/profile.d/00-restore-secrets.sh; fi\n$(cat /etc/zsh/zlogin 2>/dev/null || echo '')" | sudoIf tee /etc/zsh/zlogin > /dev/null
fi
EOF
)"
# Script to ensure login shells get the latest Codespaces secrets
restore_secrets_script="$(cat << 'EOF'
#!/bin/sh
if [ "${CODESPACES}" != "true" ] || [ "${VSCDC_FIXED_SECRETS}" = "true" ] || [ ! -z "${GITHUB_CODESPACES_TOKEN}" ]; then
# Not codespaces, already run, or secrets already in environment, so return
return
fi
if [ -f /workspaces/.codespaces/shared/.env ]; then
set -o allexport
. /workspaces/.codespaces/shared/.env
set +o allexport
fi
export VSCDC_FIXED_SECRETS=true
EOF
)"
# Write out a scripts that can be referenced as an ENTRYPOINT to auto-start sshd and fix login environments
tee /usr/local/share/ssh-init.sh > /dev/null \
<< 'EOF'
#!/usr/bin/env bash
# This script is intended to be run as root with a container that runs as root (even if you connect with a different user)
# However, it supports running as a user other than root if passwordless sudo is configured for that same user.
set -e
sudoIf()
{
if [ "$(id -u)" -ne 0 ]; then
sudo "$@"
else
"$@"
fi
}
EOF
if [ "${FIX_ENVIRONMENT}" = "true" ]; then
echo "${store_env_script}" >> /usr/local/share/ssh-init.sh
echo "${restore_secrets_script}" > /etc/profile.d/00-restore-secrets.sh
chmod +x /etc/profile.d/00-restore-secrets.sh
# Wire in zsh if present
if type zsh > /dev/null 2>&1; then
echo -e "if [ -f /etc/profile.d/00-restore-secrets.sh ]; then . /etc/profile.d/00-restore-secrets.sh; fi\n$(cat /etc/zsh/zlogin 2>/dev/null || echo '')" > /etc/zsh/zlogin
fi
fi
tee -a /usr/local/share/ssh-init.sh > /dev/null \
<< 'EOF'
# ** Start SSH server **
sudoIf /etc/init.d/ssh start 2>&1 | sudoIf tee /tmp/sshd.log > /dev/null
set +e
exec "$@"
EOF
chmod +x /usr/local/share/ssh-init.sh
# If we should start sshd now, do so
if [ "${START_SSHD}" = "true" ]; then
/usr/local/share/ssh-init.sh
fi
# Output success details
echo -e "Done!\n\n- Port: ${SSHD_PORT}\n- User: ${USERNAME}"
if [ "${EMIT_PASSWORD}" = "true" ]; then
echo "- Password: ${NEW_PASSWORD}"
fi
echo -e "\nForward port ${SSHD_PORT} to your local machine and run:\n\n ssh -p ${SSHD_PORT} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o GlobalKnownHostsFile=/dev/null ${USERNAME}@localhost\n"
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
# Setup the cluster
k3d cluster create --config /etc/replicated/k3d-cluster.yaml --kubeconfig-update-default
# Clone any extra repos here
@@ -1,5 +0,0 @@
#!/usr/bin/env bash
# Start the cluster here
k3d cluster start replicated
-21
View File
@@ -1,21 +0,0 @@
# Lines starting with '#' are comments.
# Each line is a file pattern followed by one or more owners.
# More details are here: https://help.github.com/articles/about-codeowners/
# The '*' pattern is global owners.
# Order is important. The last matching pattern has the most precedence.
# The folders are ordered as follows:
# In each subsection folders are ordered first by depth, then alphabetically.
# This should make it easy to add new rules without breaking existing ones.
## RULES
* @replicatedhq/troubleshoot
*.md @replicatedhq/cre
go.mod
go.sum
/examples/sdk/helm-template/go.mod
/examples/sdk/helm-template/go.sum
+39
View File
@@ -0,0 +1,39 @@
name: 'Setup Go Environment'
description: 'Setup Go with caching and common environment variables'
inputs:
go-version-file:
description: 'Path to go.mod file'
required: false
default: 'go.mod'
outputs:
go-version:
description: 'The Go version that was installed'
value: ${{ steps.setup-go.outputs.go-version }}
cache-hit:
description: 'Whether the Go cache was hit'
value: ${{ steps.setup-go.outputs.cache-hit }}
runs:
using: 'composite'
steps:
- name: Setup Go
id: setup-go
uses: actions/setup-go@v5
with:
go-version-file: ${{ inputs.go-version-file }}
cache: true
- name: Set Go environment variables
shell: bash
run: |
echo "GOMAXPROCS=2" >> $GITHUB_ENV
echo "GOCACHE=$(go env GOCACHE)" >> $GITHUB_ENV
echo "GOMODCACHE=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Print Go environment
shell: bash
run: |
echo "Go version: $(go version)"
echo "GOOS: $(go env GOOS)"
echo "GOARCH: $(go env GOARCH)"
echo "Cache directory: $(go env GOCACHE)"
echo "Module cache: $(go env GOMODCACHE)"
@@ -1,97 +0,0 @@
name: Automated PRs Manager
on:
schedule:
- cron: "0 */6 * * *" # every 6 hours
workflow_dispatch: {}
jobs:
list-prs:
runs-on: ubuntu-latest
outputs:
prs: ${{ steps.list-prs.outputs.prs }}
env:
GH_TOKEN: ${{ secrets.REPLICATED_GH_PAT }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: List PRs
id: list-prs
run: |
set -euo pipefail
# list prs that are less than 24h old and exclude prs from forks
dependabot_prs=$(
gh pr list \
--author 'dependabot[bot]' \
--json url,createdAt,headRefName,headRepository,headRepositoryOwner \
-q '.[] | select((.createdAt | fromdateiso8601 > now - 24*60*60) and .headRepositoryOwner.login == "replicatedhq" and .headRepository.name == "troubleshoot")'
)
prs=$(echo "$dependabot_prs" | jq -sc '. | unique')
echo "prs=$prs" >> "$GITHUB_OUTPUT"
process-prs:
needs: list-prs
runs-on: ubuntu-latest
if: needs.list-prs.outputs.prs != '[]'
strategy:
matrix:
pr: ${{ fromJson(needs.list-prs.outputs.prs) }}
fail-fast: false
max-parallel: 1
env:
GH_TOKEN: ${{ secrets.REPLICATED_GH_PAT }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: ${{ matrix.pr.headRefName }}
- name: Process PR
run: |
set -euo pipefail
echo "Ensuring required labels..."
gh pr edit "${{ matrix.pr.url }}" --add-label "type::security"
echo "Checking status of tests..."
run_id=$(gh run list --branch "${{ matrix.pr.headRefName }}" --workflow build-test-deploy --limit 1 --json databaseId -q '.[0].databaseId')
# If there are still pending jobs, skip.
num_of_pending_jobs=$(gh run view "$run_id" --json jobs -q '.jobs[] | select(.conclusion == "") | .name' | wc -l)
if [ "$num_of_pending_jobs" -gt 0 ]; then
echo "There are still pending jobs. Skipping."
exit 0
fi
# If all checks passed, approve and merge.
if gh run view "$run_id" --json jobs -q '.jobs[] | select(.name == "validate-success") | .conclusion' | grep -q "success"; then
if gh pr checks "${{ matrix.pr.url }}"; then
echo "All tests passed. Approving and merging."
echo -e "LGTM :thumbsup: \n\nThis PR was automatically approved and merged by the [automated-prs-manager](https://github.com/replicatedhq/troubleshoot/blob/main/.github/workflows/automated-prs-manager.yaml) GitHub action" > body.txt
gh pr review --approve "${{ matrix.pr.url }}" --body-file body.txt
sleep 10
gh pr merge --auto --squash "${{ matrix.pr.url }}"
exit 0
else
echo "Some checks did not pass. Skipping."
exit 0
fi
fi
# If more than half of the jobs are successful, re-run the failed jobs.
num_of_jobs=$(gh run view "$run_id" --json jobs -q '.jobs[].name ' | wc -l)
num_of_successful_jobs=$(gh run view "$run_id" --json jobs -q '.jobs[] | select(.conclusion == "success") | .name' | wc -l)
if [ "$num_of_successful_jobs" -gt $((num_of_jobs / 2)) ]; then
echo "More than half of the jobs are successful. Re-running failed jobs."
gh run rerun "$run_id" --failed
exit 0
fi
echo "Less than half of the jobs are successful. Skipping."
-281
View File
@@ -1,281 +0,0 @@
name: build-test-deploy
on:
pull_request:
types:
- opened
- reopened
- synchronize
- ready_for_review
branches:
- main
push:
branches:
- "main"
tags:
- "v*.*.*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
fail_if_pull_request_is_draft:
if: github.event.pull_request.draft == true
runs-on: ubuntu-latest
steps:
- name: Fails in order to indicate that pull request needs to be marked as ready to review and unit tests workflow needs to pass.
run: exit 1
tidy-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- run: make tidy-diff
test-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- uses: replicatedhq/action-k3s@main
id: k3s
with:
version: v1.31.2-k3s1
# test-integration includes unit tests
- run: make test-integration
ensure-schemas-are-generated:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- run: |
make check-schemas
compile-preflight:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- run: make generate preflight
- uses: actions/upload-artifact@v4
with:
name: preflight
path: bin/preflight
validate-preflight-e2e:
runs-on: ubuntu-latest
needs: compile-preflight
steps:
- uses: actions/checkout@v5
- uses: replicatedhq/action-k3s@main
id: k3s
with:
version: v1.31.2-k3s1
- name: Download preflight binary
uses: actions/download-artifact@v5
with:
name: preflight
path: bin/
- run: chmod +x bin/preflight
- run: make preflight-e2e-test
run-examples:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: make run-examples
compile-supportbundle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- run: make generate support-bundle
- uses: actions/upload-artifact@v4
with:
name: support-bundle
path: bin/support-bundle
validate-supportbundle-e2e:
runs-on: ubuntu-latest
needs: compile-supportbundle
steps:
- uses: actions/checkout@v5
- uses: replicatedhq/action-k3s@main
id: k3s
with:
version: v1.31.2-k3s1
- name: Download support bundle binary
uses: actions/download-artifact@v5
with:
name: support-bundle
path: bin/
- run: chmod +x bin/support-bundle
- run: make support-bundle-e2e-test
# 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
steps:
- uses: actions/checkout@v5
- name: Download support bundle binary
uses: actions/download-artifact@v5
with:
name: support-bundle
path: bin/
- run: chmod +x bin/support-bundle
- name: Download preflight binary
uses: actions/download-artifact@v5
with:
name: preflight
path: bin/
- run: chmod +x bin/preflight
- run: make support-bundle-e2e-go-test
compile-collect:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- run: make generate collect
- uses: actions/upload-artifact@v4
with:
name: collect
path: bin/collect
goreleaser-test:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') != true
strategy:
matrix:
goarch: [amd64, arm64]
goos: [darwin, linux]
include:
- goarch: arm
goos: linux
- goarch: riscv64
goos: linux
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: "v0.183.0"
args: build --rm-dist --snapshot --config deploy/.goreleaser.yaml --single-target
env:
GOARCH: ${{ matrix.goarch }}
GOOS: ${{ matrix.goos }}
goreleaser:
runs-on: ubuntu-latest
needs:
- validate-preflight-e2e
- validate-supportbundle-e2e
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: azure/docker-login@v2
with:
username: ${{ secrets.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- uses: sigstore/cosign-installer@v3.9.2
- name: Get Cosign Key
run: |
echo $COSIGN_KEY | base64 -d > ./cosign.key
env:
COSIGN_KEY: ${{secrets.COSIGN_KEY}}
- name: Generate SBOM
run: |
make sbom
env:
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_KEY: ${{ secrets.COSIGN_KEY }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: "v1.x" # Binary version to install
args: release --clean --config deploy/.goreleaser.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update new preflight version in krew-index
uses: rajatjindal/krew-release-bot@v0.0.47
with:
krew_template_file: deploy/krew/preflight.yaml
- name: Update new support-bundle version in krew-index
uses: rajatjindal/krew-release-bot@v0.0.47
with:
krew_template_file: deploy/krew/support-bundle.yaml
# summary jobs, these jobs will only run if all the other jobs have succeeded
validate-pr-tests:
runs-on: ubuntu-latest
needs:
- tidy-check
- test-integration
- run-examples
- compile-collect
- validate-preflight-e2e
- validate-supportbundle-e2e
- validate-supportbundle-e2e-go
- ensure-schemas-are-generated
steps:
- run: echo "All PR tests passed"
# this job will validate that the validation did not fail and that all pr-tests succeed
# it is used for the github branch protection rule
validate-success:
runs-on: ubuntu-latest
needs:
- validate-pr-tests
if: always()
steps:
# https://docs.github.com/en/actions/learn-github-actions/contexts#needs-context
# if the validate-pr-tests job was not successful, this job will fail
- name: fail if validate-pr-tests job was not successful
if: needs.validate-pr-tests.result != 'success'
run: exit 1
# if the validate-pr-tests job was successful, this job will succeed
- name: succeed if validate-pr-tests job succeeded
if: needs.validate-pr-tests.result == 'success'
run: echo "Validation succeeded"
+149
View File
@@ -0,0 +1,149 @@
name: build-test
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
branches: [v1beta3]
push:
branches: [v1beta3]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Detect changes to optimize test execution
changes:
runs-on: ubuntu-latest
outputs:
go-files: ${{ steps.filter.outputs.go-files }}
preflight: ${{ steps.filter.outputs.preflight }}
support-bundle: ${{ steps.filter.outputs.support-bundle }}
examples: ${{ steps.filter.outputs.examples }}
steps:
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
go-files:
- '**/*.go'
- 'go.{mod,sum}'
- 'Makefile'
preflight:
- 'cmd/preflight/**'
- 'pkg/preflight/**'
support-bundle:
- 'cmd/troubleshoot/**'
- 'pkg/supportbundle/**'
# Lint
lint:
if: needs.changes.outputs.go-files == 'true'
needs: changes
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/setup-go
- name: Check go mod tidy
run: |
go mod tidy
git diff --exit-code go.mod go.sum || {
echo "::error::Please run 'go mod tidy' and commit changes"
exit 1
}
- name: Format and vet
run: |
make fmt
git diff --exit-code || {
echo "::error::Please run 'make fmt' and commit changes"
exit 1
}
make vet
# Unit and integration tests
test:
if: needs.changes.outputs.go-files == 'true'
needs: [changes, lint]
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/setup-go
- name: Setup K3s
uses: replicatedhq/action-k3s@main
with:
version: v1.31.2-k3s1
- name: Run tests
run: make test-integration
# Build binaries
build:
if: needs.changes.outputs.go-files == 'true'
needs: [changes, lint]
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: ./.github/actions/setup-go
- run: make build
- uses: actions/upload-artifact@v4
with:
name: binaries
path: bin/
retention-days: 1
# E2E tests
e2e:
if: needs.changes.outputs.go-files == 'true' || github.event_name == 'push'
needs: [changes, build]
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include:
- name: preflight
target: preflight-e2e-test
needs-k3s: true
- name: support-bundle-shell
target: support-bundle-e2e-test
needs-k3s: true
- name: support-bundle-go
target: support-bundle-e2e-go-test
needs-k3s: false
steps:
- uses: actions/checkout@v5
- name: Setup K3s
if: matrix.needs-k3s
uses: replicatedhq/action-k3s@main
with:
version: v1.31.2-k3s1
- uses: actions/download-artifact@v4
with:
name: binaries
path: bin/
- run: chmod +x bin/*
- run: make ${{ matrix.target }}
# Success summary
success:
if: always()
needs: [lint, test, build, e2e]
runs-on: ubuntu-latest
steps:
- name: Check results
run: |
if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
echo "::error::Some jobs failed or were cancelled"
exit 1
fi
echo "✅ All tests passed!"
-27
View File
@@ -1,27 +0,0 @@
name: Scan vulnerabilities
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
jobs:
scan_troubleshoot_files_systems:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Run Trivy vulnerability scanner in repo mode
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
ignore-unfixed: true
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'HIGH,CRITICAL'
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
-35
View File
@@ -1,35 +0,0 @@
on:
push:
branches:
- main
pull_request:
env:
TRIVY_VERSION: 0.44.1
name: License scan
jobs:
license:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install trivy
run: |
wget https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.deb
sudo dpkg -i trivy_${TRIVY_VERSION}_Linux-64bit.deb
- name: Create license report artifact
run: trivy fs --scanners license --skip-dirs ".github" . | tee license-report.txt
- name: Upload license report artifact
uses: actions/upload-artifact@v4
with:
name: license-report
path: license-report.txt
- name: Check for unknown licenses
run: trivy fs --scanners license --skip-dirs ".github" --exit-code 1 --severity UNKNOWN . || echo "::warning::Unknown licenses found, please verify"
- name: Check for forbidden licenses and fail
run: trivy fs --scanners license --skip-dirs ".github" --exit-code 1 --severity CRITICAL,HIGH .
+48
View File
@@ -0,0 +1,48 @@
name: release
on:
push:
tags:
- 'v*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
goreleaser:
runs-on: troubleshoot_release
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: azure/docker-login@v2
with:
username: ${{ secrets.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: "v2.12.3"
args: release --clean --config deploy/.goreleaser.yaml
env:
GITHUB_TOKEN: ${{ secrets.HOMEBREW_GITHUB_TOKEN }}
- name: Update new preflight version in krew-index
if: ${{ !contains(github.ref_name, '-') }}
uses: rajatjindal/krew-release-bot@v0.0.47
with:
krew_template_file: deploy/krew/preflight.yaml
- name: Update new support-bundle version in krew-index
if: ${{ !contains(github.ref_name, '-') }}
uses: rajatjindal/krew-release-bot@v0.0.47
with:
krew_template_file: deploy/krew/support-bundle.yaml
-15
View File
@@ -1,15 +0,0 @@
# https://golangci-lint.run/usage/configuration/#config-file
run:
allow-parallel-runners: true
timeout: 10m
linters:
enable:
- gocritic
- gocyclo
- gofmt
- gosec
- govet
disable:
- errcheck
-128
View File
@@ -1,128 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
oss@replicated.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+7 -76
View File
@@ -1,12 +1,10 @@
# Contributing to Troubleshoot
Thank you for your interest in Troubleshoot, we welcome your participation. Please familiarize yourself with our [Code of Conduct](https://github.com/replicatedhq/troubleshoot/blob/main/CODE_OF_CONDUCT.md) prior to contributing. There are a number of ways to participate in Troubleshoot as outlined below:
Thank you for your interest in Troubleshoot, we welcome your participation. There are a number of ways to participate in Troubleshoot as outlined below:
# Community
For discussions about developing Troubleshoot, there's an [#app-troubleshoot channel in Kubernetes Slack](https://kubernetes.slack.com/channels/app-troubleshoot), plus IRC using [Libera](ircs://irc.libera.chat:6697/#troubleshoot) (#troubleshoot).
There are [community meetings](https://calendar.google.com/calendar/u/0?cid=Y19mMGx1aGhiZGtscGllOGo5dWpicXMwNnN1a0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t) on a regular basis, with a shared calendar and [public notes](https://hackmd.io/yZbotEHdTg6TfRZBzb8Tcg)
For discussions about developing Troubleshoot, there's an [#app-troubleshoot channel in Kubernetes Slack](https://kubernetes.slack.com/channels/app-troubleshoot).
## Issues
@@ -21,44 +19,15 @@ When implementing a new feature please review the [design principles](./docs/des
To get started we recommend:
1. Go (v1.20 or later)
2. A Kubernetes cluster (we recommend <https://k3d.io/>. This requires Docker v20.10.5 or later)
1. Go (v1.24 or later)
2. For cluster-based collectors, you will need access to a Kubernetes cluster
3. Fork and clone repo
4. Run `make clean build` to generate binaries
5. Run `make run-support-bundle` to generate a support bundle with the `sample-troubleshoot.yaml` in the root of the repo
5. You can now run `./bin/preflight` and/or `./bin/support-bundle` to use the code you've been writing
> Note: recent versions of Go support easy cross-compilation. For example, to cross-compile a Linux binary from MacOS:
> Note: to cross-compile a Linux binary from MacOS:
> `GOOS=linux GOARCH=amd64 make clean build`
6. Install [golangci-lint] linter and run `make lint` to execute additional code linters.
### Build automatically on save with `watch`
1. Install `npm`
2. Run `make watch` to build binaries automatically on saving. Note: you may still have to run `make schemas` if you've added API changes, like a new collector or analyzer type.
### Syncing to a test cluster with `watchrsync`
1. Install `npm`
2. Export `REMOTES=<user>@<ip>` so that `watchrsync` knows where to sync.
3. Maybe run `export GOOS=linux` and `export GOARCH=amd64` so that you build Linux binaries.
4. run `make watchrsync` to build and sync binaries automatically on saving.
```
ssh-add --apple-use-keychain ~/.ssh/google_compute_engine
export REMOTES=ada@35.229.61.56
export GOOS=linux
export GOARCH=amd64
make watchrsync
# bin/watchrsync.js
# make support-bundle
# go build -tags "netgo containers_image_ostree_stub exclude_graphdriver_devicemapper exclude_graphdriver_btrfs containers_image_openpgp" -installsuffix netgo -ldflags " -s -w -X github.com/replicatedhq/troubleshoot/pkg/version.version=`git describe --tags --dirty` -X github.com/replicatedhq/troubleshoot/pkg/version.gitSHA=`git rev-parse HEAD` -X github.com/replicatedhq/troubleshoot/pkg/version.buildTime=`date -u +"%Y-%m-%dT%H:%M:%SZ"` " -o bin/support-bundle github.com/replicatedhq/troubleshoot/cmd/troubleshoot
# rsync bin/support-bundle ada@35.229.61.56:
# date
# Tue May 16 14:14:13 EDT 2023
# synced
```
### Testing
To run the tests locally run the following:
@@ -104,42 +73,4 @@ More on profiling please visit https://go.dev/doc/diagnostics#profiling
## Contribution workflow
This is a rough outline of how to prepare a contribution:
- Create a fork of this repo.
- Create a topic branch from where you want to base your work (branched from `main` is a safe choice).
- Make commits of logical units.
- When your changes are ready to merge, squash your history to 1 commit.
- For example, if you want to squash your last 3 commits and write a new commit message:
```
git reset --soft HEAD~3 &&
git commit
```
- If you want to keep the previous commit messages and concatenate them all into a new commit, you can do something like this instead:
```
git reset --soft HEAD~3 &&
git commit --edit -m"$(git log --format=%B --reverse HEAD..HEAD@{1})"
```
- Push your changes to a topic branch in your fork of the repository.
- Submit a pull request to the original repository. It will be reviewed in a timely manner.
### Pull Requests
A pull request should address a single issue, feature or bug. For example, lets say you've written code that fixes two issues. That's great! However, you should submit two small pull requests, one for each issue as opposed to combining them into a single larger pull request. In general the size of the pull request should be kept small in order to make it easy for a reviewer to understand, and to minimize risks from integrating many changes at the same time. For example, if you are working on a large feature you should break it into several smaller PRs by implementing the feature as changes to several packages and submitting a separate pull request for each one. Squash commit history when preparing your PR so it merges as 1 commit.
Code submitted in pull requests must be properly documented, formatted and tested in order to be approved and merged. The following guidelines describe the things a reviewer will look for when they evaluate your pull request. Here's a tip. If your reviewer doesn't understand what the code is doing, they won't approve the pull request. Strive to make code clear and well documented. If possible, request a reviewer that has some context on the PR.
### Commit messages
Commit messages should follow the general guidelines:
- Breaking changes should be highlighted in the heading of the commit message.
- Commits should be clear about their purpose (and a single commit per thing that changed)
- Messages should be descriptive:
- First line, 50 chars or less, as a heading/title that people can find
- Then a paragraph explaining things
- Consider a footer with links to which bugs they fix etc, bearing in mind that Github does some of this magic already
We'd love to talk before you dig into a a large feature.
+4 -190
View File
@@ -38,17 +38,13 @@ BUILDTAGS = "netgo containers_image_ostree_stub exclude_graphdriver_devicemapper
BUILDFLAGS = -tags ${BUILDTAGS} -installsuffix netgo
BUILDPATHS = ./pkg/... ./cmd/... ./internal/...
E2EPATHS = ./test/e2e/...
TESTFLAGS ?= -v -coverprofile cover.out
TESTFLAGS ?=
.DEFAULT_GOAL := all
all: clean build test
.PHONY: ffi
ffi: fmt vet
go build ${BUILDFLAGS} ${LDFLAGS} -o bin/troubleshoot.so -buildmode=c-shared ffi/main.go
.PHONY: test
test: generate fmt vet
test: fmt vet
if [ -n $(RUN) ]; then \
go test ${BUILDFLAGS} ${BUILDPATHS} ${TESTFLAGS} -run $(RUN); \
else \
@@ -58,17 +54,13 @@ test: generate fmt vet
# Go tests that require a K8s instance
# TODOLATER: merge with test, so we get unified coverage reports? it'll add 21~sec to the test job though...
.PHONY: test-integration
test-integration: generate fmt vet
test-integration: fmt vet
go test -v --tags="integration exclude_graphdriver_devicemapper exclude_graphdriver_btrfs" ${BUILDPATHS}
.PHONY: preflight-e2e-test
preflight-e2e-test:
./test/validate-preflight-e2e.sh
.PHONY: run-examples
run-examples:
./test/run-examples.sh
.PHONY: support-bundle-e2e-test
support-bundle-e2e-test:
./test/validate-support-bundle-e2e.sh
@@ -83,31 +75,19 @@ support-bundle-e2e-go-test:
rebuild: clean build
# Build all binaries in parallel ( -j )
build: tidy
@echo "Build cli binaries"
$(MAKE) -j bin/support-bundle bin/preflight bin/analyze bin/collect
$(MAKE) bin/support-bundle bin/preflight
.PHONY: clean
clean:
@rm -f bin/analyze
@rm -f bin/support-bundle
@rm -f bin/collect
@rm -f bin/preflight
@rm -f bin/troubleshoot.h
@rm -f bin/troubleshoot.so
@rm -f bin/schemagen
@rm -f bin/docsgen
.PHONY: tidy
tidy:
go mod tidy
# Prints the diff of the changes that would be made by `go mod tidy`. Used in CI
.PHONY: tidy-diff
tidy-diff:
go mod tidy -diff
# Only build when any of the files in SOURCES changes, or if bin/<file> is absent
MAKEFILE_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
SOURCES := $(shell find $(MAKEFILE_DIR) -type f \( -name "*.go" -o -name "go.mod" -o -name "go.sum" \))
@@ -117,28 +97,12 @@ bin/support-bundle: $(SOURCES)
bin/preflight: $(SOURCES)
go build ${BUILDFLAGS} ${LDFLAGS} -o bin/preflight github.com/replicatedhq/troubleshoot/cmd/preflight
bin/analyze: $(SOURCES)
go build ${BUILDFLAGS} ${LDFLAGS} -o bin/analyze github.com/replicatedhq/troubleshoot/cmd/analyze
bin/collect: $(SOURCES)
go build ${BUILDFLAGS} ${LDFLAGS} -o bin/collect github.com/replicatedhq/troubleshoot/cmd/collect
.PHONY: support-bundle
support-bundle: bin/support-bundle
.PHONY: preflight
preflight: bin/preflight
.PHONY: analyze
analyze: bin/analyze
.PHONY: collect
collect: bin/collect
build-linux: tidy
@echo "Build cli binaries for Linux"
GOOS=linux GOARCH=amd64 $(MAKE) -j bin/support-bundle bin/preflight bin/analyze bin/collect
.PHONY: fmt
fmt:
go fmt ${BUILDPATHS}
@@ -146,153 +110,3 @@ fmt:
.PHONY: vet
vet:
go vet ${BUILDFLAGS} ${BUILDPATHS}
.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 \
--go-header-file ./hack/boilerplate.go.txt
cp -r troubleshootclientset pkg/client
rm -rf troubleshootclientset
.PHONY: openapischema
openapischema: controller-gen
controller-gen crd +output:dir=./config/crds paths=./pkg/apis/troubleshoot/v1beta1
controller-gen crd +output:dir=./config/crds paths=./pkg/apis/troubleshoot/v1beta2
check-schemas: generate schemas
@if [ -n "$$(git status --short)" ]; then \
echo -e "\033[31mThe git repo is dirty :( Ensure all generated files are committed e.g CRD schema files\033[0;m"; \
git status --short; \
exit 1; \
fi
.PHONY: schemas
schemas: openapischema bin/schemagen
./bin/schemagen --output-dir ./schemas
bin/schemagen:
go build ${LDFLAGS} -o bin/schemagen github.com/replicatedhq/troubleshoot/cmd/schemagen
.PHONY: docs
docs: fmt vet bin/docsgen
./bin/docsgen
bin/docsgen:
go build ${LDFLAGS} -o bin/docsgen github.com/replicatedhq/troubleshoot/cmd/docsgen
controller-gen:
go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.19.0
CONTROLLER_GEN=$(shell which 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)
.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
.PHONY: run-support-bundle
run-support-bundle: bin/support-bundle
./bin/support-bundle ./examples/support-bundle/sample-supportbundle.yaml
.PHONY: run-analyze
run-analyze: bin/analyze
./bin/analyze --analyzers ./examples/support-bundle/sample-analyzers.yaml ./support-bundle.tar.gz
.PHONY: init-sbom
init-sbom:
mkdir -p sbom/spdx sbom/assets
.PHONY: install-spdx-sbom-generator
install-spdx-sbom-generator: init-sbom
./scripts/initialize-sbom-build.sh
SPDX_GENERATOR=./sbom/spdx-sbom-generator
.PHONY: generate-sbom
generate-sbom: install-spdx-sbom-generator
$(SPDX_GENERATOR) -o ./sbom/spdx
sbom/assets/troubleshoot-sbom.tgz: generate-sbom
tar -czf sbom/assets/troubleshoot-sbom.tgz sbom/spdx/*.spdx
sbom: sbom/assets/troubleshoot-sbom.tgz
cosign sign-blob \
--key ./cosign.key \
--tlog-upload \
--yes \
--rekor-url=https://rekor.sigstore.dev \
sbom/assets/troubleshoot-sbom.tgz > sbom/assets/troubleshoot-sbom.tgz.sig
cosign public-key --key cosign.key --outfile sbom/assets/key.pub
.PHONY: scan
scan:
trivy fs \
--scanners vuln \
--exit-code=1 \
--severity="HIGH,CRITICAL" \
--ignore-unfixed \
./
.PHONY: watch
watch: npm-install
bin/watch.js
## Syncronize the code with a remote server. More info: CONTRIBUTING.md
.PHONY: watchrsync
watchrsync: npm-install
bin/watchrsync.js
.PHONY: npm-install
npm-install:
npm --version 2>&1 >/dev/null || ( echo "npm not installed; install npm to set up watchrsync" && exit 1 )
npm list gaze-run-interrupt || npm install install --no-save gaze-run-interrupt@~2.0.0
######## Lagacy make targets ###########
# Deprecated: These can be removed
.PHONY: run-troubleshoot
run-troubleshoot: run-support-bundle
longhorn:
git clone https://github.com/longhorn/longhorn-manager.git
cd longhorn-manager && git checkout v1.2.2 && cd ..
rm -rf pkg/longhorn
mv longhorn-manager/k8s/pkg pkg/longhorn
mv longhorn-manager/types pkg/longhorn/types
mv longhorn-manager/util pkg/longhorn/util
rm -rf pkg/longhorn/util/daemon
rm -rf pkg/longhorn/util/server
find pkg/longhorn -type f | xargs sed -i "s/github.com\/longhorn\/longhorn-manager\/k8s\/pkg/github.com\/replicatedhq\/troubleshoot\/pkg\/longhorn/g"
find pkg/longhorn -type f | xargs sed -i "s/github.com\/longhorn\/longhorn-manager\/types/github.com\/replicatedhq\/troubleshoot\/pkg\/longhorn\/types/g"
find pkg/longhorn -type f | xargs sed -i "s/github.com\/longhorn\/longhorn-manager\/util/github.com\/replicatedhq\/troubleshoot\/pkg\/longhorn\/util/g"
rm -rf longhorn-manager
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/env node
const gri = require('gaze-run-interrupt');
const commands = [
// {
// command: 'rm',
// args: binList,
// },
{
command: 'make',
args: ['build'],
},
];
commands.push({
command: "date",
args: [],
});
commands.push({
command: "echo",
args: ["synced"],
});
gri([
'cmd/**/*.go',
'pkg/**/*.go',
], commands);
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env node
const gri = require('gaze-run-interrupt');
if (!process.env.REMOTES) {
console.log("Usage: `REMOTES='user@h1.1.1.1,user@1.1.1.2' ./watchrsync.js`");
process.exit(1);
}
process.env.GOOS = 'linux';
process.env.GOARCH = 'amd64';
const binList = [
// 'bin/analyze',
// 'bin/preflight',
'bin/support-bundle',
// 'bin/collect'
]
const commands = [
// {
// command: 'rm',
// args: binList,
// },
{
command: 'make',
args: ['build'],
},
];
process.env.REMOTES.split(",").forEach(function (remote) {
commands.push({
command: 'rsync',
args: binList.concat(`${remote}:`),
});
});
commands.push({
command: "date",
args: [],
});
commands.push({
command: "echo",
args: ["synced"],
});
gri([
'cmd/**/*.go',
'pkg/**/*.go',
], commands);
-75
View File
@@ -1,75 +0,0 @@
package cli
import (
"os"
"strings"
"github.com/replicatedhq/troubleshoot/cmd/internal/util"
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
"github.com/replicatedhq/troubleshoot/pkg/logger"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"k8s.io/klog/v2"
)
func RootCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "analyze [url]",
Args: cobra.MinimumNArgs(1),
Short: "Analyze a support bundle",
Long: `Run a series of analyzers on a support bundle archive`,
SilenceUsage: true,
PreRun: func(cmd *cobra.Command, args []string) {
v := viper.GetViper()
v.BindPFlags(cmd.Flags())
logger.SetupLogger(v)
if err := util.StartProfiling(); err != nil {
klog.Errorf("Failed to start profiling: %v", err)
}
},
RunE: func(cmd *cobra.Command, args []string) error {
v := viper.GetViper()
return runAnalyzers(v, args[0])
},
PostRun: func(cmd *cobra.Command, args []string) {
if err := util.StopProfiling(); err != nil {
klog.Errorf("Failed to stop profiling: %v", err)
}
},
}
cobra.OnInitialize(initConfig)
cmd.AddCommand(util.VersionCmd())
cmd.Flags().String("analyzers", "", "filename or url of the analyzers to use")
cmd.Flags().Bool("debug", false, "enable debug logging")
viper.BindPFlags(cmd.Flags())
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
// Initialize klog flags
logger.InitKlogFlags(cmd)
k8sutil.AddFlags(cmd.Flags())
// CPU and memory profiling flags
util.AddProfilingFlags(cmd)
return cmd
}
func InitAndExecute() {
if err := RootCmd().Execute(); err != nil {
os.Exit(1)
}
}
func initConfig() {
viper.SetEnvPrefix("TROUBLESHOOT")
viper.AutomaticEnv()
}
-68
View File
@@ -1,68 +0,0 @@
package cli
import (
"fmt"
"io/ioutil"
"net/http"
"os"
"github.com/pkg/errors"
"github.com/replicatedhq/troubleshoot/internal/util"
analyzer "github.com/replicatedhq/troubleshoot/pkg/analyze"
"github.com/spf13/viper"
)
func runAnalyzers(v *viper.Viper, bundlePath string) error {
specPath := v.GetString("analyzers")
specContent := ""
var err error
if _, err = os.Stat(specPath); err == nil {
b, err := os.ReadFile(specPath)
if err != nil {
return err
}
specContent = string(b)
} else {
if !util.IsURL(specPath) {
// TODO: Better error message when we do not have a file/url etc
return fmt.Errorf("%s is not a URL and was not found", specPath)
}
req, err := http.NewRequest("GET", specPath, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Replicated_Analyzer/v1beta1")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
specContent = string(body)
}
analyzeResults, err := analyzer.DownloadAndAnalyze(bundlePath, specContent)
if err != nil {
return errors.Wrap(err, "failed to download and analyze bundle")
}
for _, analyzeResult := range analyzeResults {
if analyzeResult.IsPass {
fmt.Printf("Pass: %s\n %s\n", analyzeResult.Title, analyzeResult.Message)
} else if analyzeResult.IsWarn {
fmt.Printf("Warn: %s\n %s\n", analyzeResult.Title, analyzeResult.Message)
} else if analyzeResult.IsFail {
fmt.Printf("Fail: %s\n %s\n", analyzeResult.Title, analyzeResult.Message)
}
}
return nil
}
-10
View File
@@ -1,10 +0,0 @@
package main
import (
"github.com/replicatedhq/troubleshoot/cmd/analyze/cli"
_ "k8s.io/client-go/plugin/pkg/client/auth"
)
func main() {
cli.InitAndExecute()
}
-21
View File
@@ -1,21 +0,0 @@
package cli
import (
"errors"
"syscall"
"github.com/replicatedhq/troubleshoot/internal/util"
)
func checkAndSetChroot(newroot string) error {
if newroot == "" {
return nil
}
if !util.IsRunningAsRoot() {
return errors.New("Can only chroot when run as root")
}
if err := syscall.Chroot(newroot); err != nil {
return err
}
return nil
}
-21
View File
@@ -1,21 +0,0 @@
package cli
import (
"errors"
"syscall"
"github.com/replicatedhq/troubleshoot/internal/util"
)
func checkAndSetChroot(newroot string) error {
if newroot == "" {
return nil
}
if !util.IsRunningAsRoot() {
return errors.New("Can only chroot when run as root")
}
if err := syscall.Chroot(newroot); err != nil {
return err
}
return nil
}
-9
View File
@@ -1,9 +0,0 @@
package cli
import (
"errors"
)
func checkAndSetChroot(newroot string) error {
return errors.New("chroot is only implimented in linux/darwin")
}
-90
View File
@@ -1,90 +0,0 @@
package cli
import (
"os"
"strings"
"github.com/replicatedhq/troubleshoot/cmd/internal/util"
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
"github.com/replicatedhq/troubleshoot/pkg/logger"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"k8s.io/klog/v2"
)
func RootCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "collect [url]",
Args: cobra.MinimumNArgs(1),
Short: "Run a collector",
Long: `Run a collector and output the results.`,
SilenceUsage: true,
PreRun: func(cmd *cobra.Command, args []string) {
v := viper.GetViper()
v.BindPFlags(cmd.Flags())
logger.SetupLogger(v)
if err := util.StartProfiling(); err != nil {
klog.Errorf("Failed to start profiling: %v", err)
}
},
RunE: func(cmd *cobra.Command, args []string) error {
v := viper.GetViper()
if err := checkAndSetChroot(v.GetString("chroot")); err != nil {
return err
}
return runCollect(v, args[0])
},
PostRun: func(cmd *cobra.Command, args []string) {
if err := util.StopProfiling(); err != nil {
klog.Errorf("Failed to stop profiling: %v", err)
}
},
}
cobra.OnInitialize(initConfig)
cmd.AddCommand(util.VersionCmd())
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.")
cmd.Flags().String("collector-image", "", "the full name of the collector image to use")
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.Flags().String("chroot", "", "Chroot to path")
// hidden in favor of the `insecure-skip-tls-verify` flag
cmd.Flags().Bool("allow-insecure-connections", false, "when set, do not verify TLS certs when retrieving spec and reporting results")
cmd.Flags().MarkHidden("allow-insecure-connections")
viper.BindPFlags(cmd.Flags())
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
k8sutil.AddFlags(cmd.Flags())
// Initialize klog flags
logger.InitKlogFlags(cmd)
// CPU and memory profiling flags
util.AddProfilingFlags(cmd)
return cmd
}
func InitAndExecute() {
if err := RootCmd().Execute(); err != nil {
os.Exit(1)
}
}
func initConfig() {
viper.SetEnvPrefix("TROUBLESHOOT")
viper.AutomaticEnv()
}
-189
View File
@@ -1,189 +0,0 @@
package cli
import (
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"os/signal"
"strings"
"time"
"github.com/pkg/errors"
"github.com/replicatedhq/troubleshoot/internal/util"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme"
"github.com/replicatedhq/troubleshoot/pkg/collect"
"github.com/replicatedhq/troubleshoot/pkg/docrewrite"
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
"github.com/replicatedhq/troubleshoot/pkg/specs"
"github.com/replicatedhq/troubleshoot/pkg/supportbundle"
"github.com/spf13/viper"
"k8s.io/apimachinery/pkg/labels"
)
const (
defaultTimeout = 30 * time.Second
)
func runCollect(v *viper.Viper, arg string) error {
go func() {
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, os.Interrupt)
<-signalChan
os.Exit(0)
}()
var collectorContent []byte
var err error
if strings.HasPrefix(arg, "secret/") {
// format secret/namespace-name/secret-name
pathParts := strings.Split(arg, "/")
if len(pathParts) != 3 {
return errors.Errorf("path %s must have 3 components", arg)
}
spec, err := specs.LoadFromSecret(pathParts[1], pathParts[2], "collect-spec")
if err != nil {
return errors.Wrap(err, "failed to get spec from secret")
}
collectorContent = spec
} else if arg == "-" {
b, err := io.ReadAll(os.Stdin)
if err != nil {
return err
}
collectorContent = b
} else if _, err = os.Stat(arg); err == nil {
b, err := os.ReadFile(arg)
if err != nil {
return err
}
collectorContent = b
} else {
if !util.IsURL(arg) {
return fmt.Errorf("%s is not a URL and was not found", arg)
}
req, err := http.NewRequest("GET", arg, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Replicated_Collect/v1beta2")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
collectorContent = body
}
collectorContent, err = docrewrite.ConvertToV1Beta2(collectorContent)
if err != nil {
return errors.Wrap(err, "failed to convert to v1beta2")
}
multidocs := strings.Split(string(collectorContent), "\n---\n")
decode := scheme.Codecs.UniversalDeserializer().Decode
redactors, err := supportbundle.GetRedactorsFromURIs(v.GetStringSlice("redactors"))
if err != nil {
return errors.Wrap(err, "failed to get redactors")
}
additionalRedactors := &troubleshootv1beta2.Redactor{
Spec: troubleshootv1beta2.RedactorSpec{
Redactors: redactors,
},
}
for i, additionalDoc := range multidocs {
if i == 0 {
continue
}
additionalDoc, err := docrewrite.ConvertToV1Beta2([]byte(additionalDoc))
if err != nil {
return errors.Wrap(err, "failed to convert to v1beta2")
}
obj, _, err := decode(additionalDoc, nil, nil)
if err != nil {
return errors.Wrapf(err, "failed to parse additional doc %d", i)
}
multidocRedactors, ok := obj.(*troubleshootv1beta2.Redactor)
if !ok {
continue
}
additionalRedactors.Spec.Redactors = append(additionalRedactors.Spec.Redactors, multidocRedactors.Spec.Redactors...)
}
// make sure we don't block any senders
progressCh := make(chan interface{})
defer close(progressCh)
go func() {
for range progressCh {
}
}()
restConfig, err := k8sutil.GetRESTConfig()
if err != nil {
return errors.Wrap(err, "failed to convert kube flags to rest config")
}
labelSelector, err := labels.Parse(v.GetString("selector"))
if err != nil {
return errors.Wrap(err, "unable to parse selector")
}
namespace := v.GetString("namespace")
if namespace == "" {
namespace = "default"
}
timeout := v.GetDuration("request-timeout")
if timeout == 0 {
timeout = defaultTimeout
}
createOpts := collect.CollectorRunOpts{
CollectWithoutPermissions: v.GetBool("collect-without-permissions"),
KubernetesRestConfig: restConfig,
Image: v.GetString("collector-image"),
PullPolicy: v.GetString("collector-pullpolicy"),
LabelSelector: labelSelector.String(),
Namespace: namespace,
Timeout: timeout,
ProgressChan: progressCh,
}
// we only support HostCollector or RemoteCollector kinds.
hostCollector, err := collect.ParseHostCollectorFromDoc([]byte(multidocs[0]))
if err == nil {
results, err := collect.CollectHost(hostCollector, additionalRedactors, createOpts)
if err != nil {
return errors.Wrap(err, "failed to collect from host")
}
return showHostStdoutResults(v.GetString("format"), hostCollector.Name, results)
}
remoteCollector, err := collect.ParseRemoteCollectorFromDoc([]byte(multidocs[0]))
if err == nil {
results, err := collect.CollectRemote(remoteCollector, additionalRedactors, createOpts)
if err != nil {
return errors.Wrap(err, "failed to collect from remote host(s)")
}
return showRemoteStdoutResults(v.GetString("format"), remoteCollector.Name, results)
}
return errors.New("failed to parse hostCollector or remoteCollector")
}
-103
View File
@@ -1,103 +0,0 @@
package cli
import (
"encoding/json"
"fmt"
"github.com/pkg/errors"
"github.com/replicatedhq/troubleshoot/pkg/collect"
)
const (
// FormatJSON is intended for CLI output.
FormatJSON = "json"
// FormatRaw is intended for consumption by a remote collector. Output is a
// string of quoted JSON.
FormatRaw = "raw"
)
func showHostStdoutResults(format string, collectName string, results *collect.HostCollectResult) error {
switch format {
case FormatJSON:
return showHostStdoutResultsJSON(collectName, results.AllCollectedData)
case FormatRaw:
return showHostStdoutResultsRaw(collectName, results.AllCollectedData)
default:
return errors.Errorf("unknown output format: %q", format)
}
}
func showRemoteStdoutResults(format string, collectName string, results *collect.RemoteCollectResult) error {
switch format {
case FormatJSON:
return showRemoteStdoutResultsJSON(collectName, results.AllCollectedData)
case FormatRaw:
return errors.Errorf("raw format not supported for remote collectors")
default:
return errors.Errorf("unknown output format: %q", format)
}
}
func showHostStdoutResultsJSON(collectName string, results map[string][]byte) error {
output := make(map[string]interface{})
for file, collectorResult := range results {
var collectedItems map[string]interface{}
if err := json.Unmarshal([]byte(collectorResult), &collectedItems); err != nil {
return errors.Wrap(err, "failed to marshal collector results")
}
output[file] = collectedItems
}
formatted, err := json.MarshalIndent(output, "", " ")
if err != nil {
return errors.Wrap(err, "failed to convert output to json")
}
fmt.Print(string(formatted))
return nil
}
// showHostStdoutResultsRaw outputs the collector output as a string of quoted json.
func showHostStdoutResultsRaw(collectName string, results map[string][]byte) error {
strData := map[string]string{}
for k, v := range results {
strData[k] = string(v)
}
formatted, err := json.MarshalIndent(strData, "", " ")
if err != nil {
return errors.Wrap(err, "failed to convert output to json")
}
fmt.Print(string(formatted))
return nil
}
func showRemoteStdoutResultsJSON(collectName string, results map[string][]byte) error {
type CollectorResult map[string]interface{}
type NodeResult map[string]CollectorResult
var output = make(map[string]NodeResult)
for node, result := range results {
var nodeResult map[string]string
if err := json.Unmarshal(result, &nodeResult); err != nil {
return errors.Wrap(err, "failed to marshal node results")
}
nr := make(NodeResult)
for file, collectorResult := range nodeResult {
var collectedItems map[string]interface{}
if err := json.Unmarshal([]byte(collectorResult), &collectedItems); err != nil {
return errors.Wrap(err, "failed to marshal collector results")
}
nr[file] = collectedItems
}
output[node] = nr
}
formatted, err := json.MarshalIndent(output, "", " ")
if err != nil {
return errors.Wrap(err, "failed to convert output to json")
}
fmt.Print(string(formatted))
return nil
}
-10
View File
@@ -1,10 +0,0 @@
package main
import (
"github.com/replicatedhq/troubleshoot/cmd/collect/cli"
_ "k8s.io/client-go/plugin/pkg/client/auth"
)
func main() {
cli.InitAndExecute()
}
-37
View File
@@ -1,37 +0,0 @@
package cli
import (
"log"
"os"
preflightcli "github.com/replicatedhq/troubleshoot/cmd/preflight/cli"
troubleshootcli "github.com/replicatedhq/troubleshoot/cmd/troubleshoot/cli"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
)
func RootCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "docsgen",
Short: "Generate markdown docs for the commands in this project",
}
preflight := preflightcli.RootCmd()
troubleshoot := troubleshootcli.RootCmd()
commands := []*cobra.Command{preflight, troubleshoot}
for _, command := range commands {
err := doc.GenMarkdownTree(command, "./docs")
if err != nil {
log.Fatal(err)
}
}
return cmd
}
func InitAndExecute() {
if err := RootCmd().Execute(); err != nil {
os.Exit(1)
}
}
-10
View File
@@ -1,10 +0,0 @@
package main
import (
"github.com/replicatedhq/troubleshoot/cmd/docsgen/cli"
_ "k8s.io/client-go/plugin/pkg/client/auth"
)
func main() {
cli.InitAndExecute()
}
+132
View File
@@ -0,0 +1,132 @@
package cli
import (
"fmt"
"io/ioutil"
"path/filepath"
"strings"
"github.com/pkg/errors"
"github.com/replicatedhq/troubleshoot/pkg/convert"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func ConvertCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "convert [input-file]",
Args: cobra.ExactArgs(1),
Short: "Convert v1beta2 preflight specs to v1beta3 format",
Long: `Convert v1beta2 preflight specs to v1beta3 format with templating and values.
This command converts a v1beta2 preflight spec to the new v1beta3 templated format. It will:
- Update the apiVersion to troubleshoot.sh/v1beta3
- Extract hardcoded values and create a values.yaml file
- Add conditional templating ({{- if .Values.feature.enabled }})
- Add placeholder docString comments for you to fill in
- Template hardcoded values with {{ .Values.* }} expressions
The conversion will create two files:
- [input-file]-v1beta3.yaml: The templated v1beta3 spec
- [input-file]-values.yaml: The values file with extracted configuration
Example:
preflight convert my-preflight.yaml
This creates:
my-preflight-v1beta3.yaml
my-preflight-values.yaml`,
PreRun: func(cmd *cobra.Command, args []string) {
viper.BindPFlags(cmd.Flags())
},
RunE: func(cmd *cobra.Command, args []string) error {
v := viper.GetViper()
inputFile := args[0]
outputSpec := v.GetString("output-spec")
outputValues := v.GetString("output-values")
// Generate default output filenames if not specified
if outputSpec == "" {
ext := filepath.Ext(inputFile)
base := strings.TrimSuffix(inputFile, ext)
outputSpec = base + "-v1beta3" + ext
}
if outputValues == "" {
ext := filepath.Ext(inputFile)
base := strings.TrimSuffix(inputFile, ext)
outputValues = base + "-values" + ext
}
return runConvert(v, inputFile, outputSpec, outputValues)
},
}
cmd.Flags().String("output-spec", "", "Output file for the templated v1beta3 spec (default: [input]-v1beta3.yaml)")
cmd.Flags().String("output-values", "", "Output file for the values (default: [input]-values.yaml)")
cmd.Flags().Bool("dry-run", false, "Preview the conversion without writing files")
return cmd
}
func runConvert(v *viper.Viper, inputFile, outputSpec, outputValues string) error {
// Read input file
inputData, err := ioutil.ReadFile(inputFile)
if err != nil {
return errors.Wrapf(err, "failed to read input file %s", inputFile)
}
// Check if it's a valid v1beta2 preflight spec
if !strings.Contains(string(inputData), "troubleshoot.sh/v1beta2") {
return fmt.Errorf("input file does not appear to be a v1beta2 troubleshoot spec")
}
if !strings.Contains(string(inputData), "kind: Preflight") {
return fmt.Errorf("input file does not appear to be a Preflight spec")
}
// Convert to v1beta3
result, err := convert.ConvertToV1Beta3(inputData)
if err != nil {
return errors.Wrap(err, "failed to convert spec")
}
dryRun := v.GetBool("dry-run")
if dryRun {
fmt.Println("=== Templated v1beta3 Spec ===")
fmt.Println(result.TemplatedSpec)
fmt.Println("\n=== Values File ===")
fmt.Println(result.ValuesFile)
fmt.Println("\n=== Conversion Summary ===")
fmt.Printf("Would write templated spec to: %s\n", outputSpec)
fmt.Printf("Would write values to: %s\n", outputValues)
return nil
}
// Write templated spec
err = ioutil.WriteFile(outputSpec, []byte(result.TemplatedSpec), 0644)
if err != nil {
return errors.Wrapf(err, "failed to write templated spec to %s", outputSpec)
}
// Write values file
err = ioutil.WriteFile(outputValues, []byte(result.ValuesFile), 0644)
if err != nil {
return errors.Wrapf(err, "failed to write values to %s", outputValues)
}
fmt.Printf("Successfully converted %s to v1beta3 format:\n", inputFile)
fmt.Printf(" Templated spec: %s\n", outputSpec)
fmt.Printf(" Values file: %s\n", outputValues)
fmt.Println("\nNext steps:")
fmt.Println("1. Add docStrings with Title, Requirement, and rationale for each check")
fmt.Println("2. Customize the values in the values file")
fmt.Println("3. Test the conversion with:")
fmt.Printf(" preflight template %s --values %s\n", outputSpec, outputValues)
fmt.Println("4. Run the templated preflight:")
fmt.Printf(" preflight run %s --values %s\n", outputSpec, outputValues)
return nil
}
+387
View File
@@ -0,0 +1,387 @@
package cli
import (
"bytes"
"fmt"
"os"
"strings"
"text/template"
"github.com/Masterminds/sprig/v3"
"github.com/pkg/errors"
"github.com/replicatedhq/troubleshoot/pkg/preflight"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"gopkg.in/yaml.v2"
"helm.sh/helm/v3/pkg/strvals"
)
func DocsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "docs [preflight-file...]",
Short: "Extract and display documentation from a preflight spec",
Long: `Extract all docString fields from enabled requirements in one or more preflight YAML files.
This command processes templated preflight specs, evaluates conditionals, and outputs
only the documentation for requirements that would be included based on the provided values.
Examples:
# Extract docs with default values
preflight docs ml-platform-preflight.yaml
# Extract docs from multiple specs with values from files
preflight docs spec1.yaml spec2.yaml --values base-values.yaml --values prod-values.yaml
# Extract docs with inline values
preflight docs ml-platform-preflight.yaml --set jupyter.enabled=true --set monitoring.enabled=false
# Extract docs and save to file
preflight docs ml-platform-preflight.yaml --output requirements.md`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
v := viper.GetViper()
templateFiles := args
valuesFiles := v.GetStringSlice("values")
outputFile := v.GetString("output")
setValues := v.GetStringSlice("set")
return extractDocs(templateFiles, valuesFiles, setValues, outputFile)
},
}
cmd.Flags().StringSlice("values", []string{}, "Path to YAML files containing template values (can be used multiple times)")
cmd.Flags().StringSlice("set", []string{}, "Set template values on the command line (can be used multiple times)")
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
// Bind flags to viper
viper.BindPFlag("values", cmd.Flags().Lookup("values"))
viper.BindPFlag("set", cmd.Flags().Lookup("set"))
viper.BindPFlag("output", cmd.Flags().Lookup("output"))
return cmd
}
// PreflightDoc supports both legacy (requirements) and beta3 (spec.analyzers)
type PreflightDoc struct {
APIVersion string `yaml:"apiVersion"`
Kind string `yaml:"kind"`
Metadata map[string]interface{} `yaml:"metadata"`
Spec struct {
Analyzers []map[string]interface{} `yaml:"analyzers"`
} `yaml:"spec"`
// Legacy (pre-beta3 drafts)
Requirements []Requirement `yaml:"requirements"`
}
type Requirement struct {
Name string `yaml:"name"`
DocString string `yaml:"docString"`
Checks []map[string]interface{} `yaml:"checks,omitempty"`
}
func extractDocs(templateFiles []string, valuesFiles []string, setValues []string, outputFile string) error {
// Prepare the values map (merge all files, then apply sets)
values := make(map[string]interface{})
for _, valuesFile := range valuesFiles {
fileValues, err := loadValuesFile(valuesFile)
if err != nil {
return errors.Wrapf(err, "failed to load values file %s", valuesFile)
}
values = mergeMaps(values, fileValues)
}
// Normalize maps for Helm set merging
values = normalizeStringMaps(values)
for _, setValue := range setValues {
if err := applySetValue(values, setValue); err != nil {
return errors.Wrapf(err, "failed to apply set value: %s", setValue)
}
}
var combinedDocs strings.Builder
for _, templateFile := range templateFiles {
templateContent, err := os.ReadFile(templateFile)
if err != nil {
return errors.Wrapf(err, "failed to read template file %s", templateFile)
}
useHelm := shouldUseHelmEngine(string(templateContent))
var rendered string
if useHelm {
// Seed default-false for referenced boolean values to avoid nil map errors
preflight.SeedDefaultBooleans(string(templateContent), values)
rendered, err = preflight.RenderWithHelmTemplate(string(templateContent), values)
if err != nil {
execValues := legacyContext(values)
rendered, err = renderTemplate(string(templateContent), execValues)
if err != nil {
return errors.Wrap(err, "failed to render template (helm fallback also failed)")
}
}
} else {
execValues := legacyContext(values)
rendered, err = renderTemplate(string(templateContent), execValues)
if err != nil {
return errors.Wrap(err, "failed to render template")
}
}
docs, err := extractDocStrings(rendered)
if err != nil {
return errors.Wrap(err, "failed to extract documentation")
}
if strings.TrimSpace(docs) != "" {
if combinedDocs.Len() > 0 {
combinedDocs.WriteString("\n\n")
}
combinedDocs.WriteString(docs)
}
}
if outputFile != "" {
if err := os.WriteFile(outputFile, []byte(combinedDocs.String()), 0644); err != nil {
return errors.Wrapf(err, "failed to write output file %s", outputFile)
}
fmt.Printf("Documentation extracted successfully to %s\n", outputFile)
} else {
fmt.Print(combinedDocs.String())
}
return nil
}
func shouldUseHelmEngine(content string) bool {
return strings.Contains(content, ".Values")
}
func legacyContext(values map[string]interface{}) map[string]interface{} {
ctx := make(map[string]interface{}, len(values)+1)
for k, v := range values {
ctx[k] = v
}
ctx["Values"] = values
return ctx
}
func normalizeStringMaps(v interface{}) map[string]interface{} {
// Avoid unsafe type assertion; normalizeMap may return non-map types.
if v == nil {
return map[string]interface{}{}
}
normalized := normalizeMap(v)
if m, ok := normalized.(map[string]interface{}); ok {
return m
}
return map[string]interface{}{}
}
func normalizeMap(v interface{}) interface{} {
switch t := v.(type) {
case map[string]interface{}:
m := make(map[string]interface{}, len(t))
for k, val := range t {
m[k] = normalizeMap(val)
}
return m
case map[interface{}]interface{}:
m := make(map[string]interface{}, len(t))
for k, val := range t {
key := fmt.Sprintf("%v", k)
m[key] = normalizeMap(val)
}
return m
case []interface{}:
a := make([]interface{}, len(t))
for i, val := range t {
a[i] = normalizeMap(val)
}
return a
default:
return v
}
}
func extractDocStrings(yamlContent string) (string, error) {
var preflightDoc PreflightDoc
if err := yaml.Unmarshal([]byte(yamlContent), &preflightDoc); err != nil {
return "", errors.Wrap(err, "failed to parse YAML")
}
var docs strings.Builder
first := true
// Prefer beta3 analyzers docStrings
if len(preflightDoc.Spec.Analyzers) > 0 {
for _, analyzer := range preflightDoc.Spec.Analyzers {
if raw, ok := analyzer["docString"]; ok {
text, _ := raw.(string)
text = strings.TrimSpace(text)
if text == "" {
continue
}
if !first {
docs.WriteString("\n\n")
}
first = false
writeMarkdownSection(&docs, text, "")
}
}
return docs.String(), nil
}
// Fallback: legacy requirements with docString
for _, req := range preflightDoc.Requirements {
if strings.TrimSpace(req.DocString) == "" {
continue
}
if !first {
docs.WriteString("\n\n")
}
first = false
writeMarkdownSection(&docs, req.DocString, req.Name)
}
return docs.String(), nil
}
// writeMarkdownSection prints a heading from Title: or name, then the rest
func writeMarkdownSection(b *strings.Builder, docString string, fallbackName string) {
lines := strings.Split(docString, "\n")
title := strings.TrimSpace(fallbackName)
contentStart := 0
for i, line := range lines {
trim := strings.TrimSpace(line)
if strings.HasPrefix(trim, "Title:") {
parts := strings.SplitN(trim, ":", 2)
if len(parts) == 2 {
t := strings.TrimSpace(parts[1])
if t != "" {
title = t
}
}
contentStart = i + 1
break
}
}
if title != "" {
b.WriteString("### ")
b.WriteString(title)
b.WriteString("\n\n")
}
remaining := strings.Join(lines[contentStart:], "\n")
remaining = strings.TrimSpace(remaining)
if remaining != "" {
b.WriteString(remaining)
b.WriteString("\n")
}
}
// loadValuesFile loads values from a YAML file
func loadValuesFile(filename string) (map[string]interface{}, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
var values map[string]interface{}
if err := yaml.Unmarshal(data, &values); err != nil {
return nil, errors.Wrap(err, "failed to parse values file as YAML")
}
return values, nil
}
// applySetValue applies a single --set value to the values map (Helm semantics)
func applySetValue(values map[string]interface{}, setValue string) error {
if idx := strings.Index(setValue, "="); idx > 0 {
key := setValue[:idx]
val := setValue[idx+1:]
if strings.HasPrefix(key, "Values.") {
key = strings.TrimPrefix(key, "Values.")
setValue = key + "=" + val
}
}
if err := strvals.ParseInto(setValue, values); err != nil {
return fmt.Errorf("parsing --set: %w", err)
}
return nil
}
// setNestedValue sets a value in a nested map structure
func setNestedValue(m map[string]interface{}, keys []string, value interface{}) {
if len(keys) == 0 {
return
}
if len(keys) == 1 {
m[keys[0]] = value
return
}
if _, ok := m[keys[0]]; !ok {
m[keys[0]] = make(map[string]interface{})
}
if nextMap, ok := m[keys[0]].(map[string]interface{}); ok {
setNestedValue(nextMap, keys[1:], value)
} else {
m[keys[0]] = make(map[string]interface{})
setNestedValue(m[keys[0]].(map[string]interface{}), keys[1:], value)
}
}
func mergeMaps(base, overlay map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{})
for k, v := range base {
result[k] = v
}
for k, v := range overlay {
if baseVal, exists := result[k]; exists {
if baseMap, ok := baseVal.(map[string]interface{}); ok {
if overlayMap, ok := v.(map[string]interface{}); ok {
result[k] = mergeMaps(baseMap, overlayMap)
continue
}
}
}
result[k] = v
}
return result
}
func renderTemplate(templateContent string, values map[string]interface{}) (string, error) {
tmpl := template.New("preflight").Funcs(sprig.FuncMap())
tmpl, err := tmpl.Parse(templateContent)
if err != nil {
return "", errors.Wrap(err, "failed to parse template")
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, values); err != nil {
return "", errors.Wrap(err, "failed to execute template")
}
result := cleanRenderedYAML(buf.String())
return result, nil
}
func cleanRenderedYAML(content string) string {
lines := strings.Split(content, "\n")
var cleaned []string
var lastWasEmpty bool
for _, line := range lines {
trimmed := strings.TrimRight(line, " \t")
if trimmed == "" {
if !lastWasEmpty {
cleaned = append(cleaned, "")
lastWasEmpty = true
}
} else {
cleaned = append(cleaned, trimmed)
lastWasEmpty = false
}
}
for len(cleaned) > 0 && cleaned[len(cleaned)-1] == "" {
cleaned = cleaned[:len(cleaned)-1]
}
return strings.Join(cleaned, "\n") + "\n"
}
+29
View File
@@ -13,6 +13,7 @@ import (
"github.com/replicatedhq/troubleshoot/pkg/logger"
"github.com/replicatedhq/troubleshoot/pkg/preflight"
"github.com/replicatedhq/troubleshoot/pkg/types"
"github.com/replicatedhq/troubleshoot/pkg/updater"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"k8s.io/klog/v2"
@@ -37,6 +38,25 @@ that a cluster meets the requirements to run an application.`,
if err := util.StartProfiling(); err != nil {
klog.Errorf("Failed to start profiling: %v", err)
}
// Auto-update preflight unless disabled by flag or env
envAuto := os.Getenv("PREFLIGHT_AUTO_UPDATE")
autoFromEnv := true
if envAuto != "" {
if strings.EqualFold(envAuto, "0") || strings.EqualFold(envAuto, "false") {
autoFromEnv = false
}
}
if v.GetBool("auto-update") && autoFromEnv {
exe, err := os.Executable()
if err == nil {
_ = updater.CheckAndUpdate(cmd.Context(), updater.Options{
BinaryName: "preflight",
CurrentPath: exe,
Printf: func(f string, a ...interface{}) { fmt.Fprintf(os.Stderr, f, a...) },
})
}
}
},
RunE: func(cmd *cobra.Command, args []string) error {
v := viper.GetViper()
@@ -66,12 +86,21 @@ that a cluster meets the requirements to run an application.`,
cmd.AddCommand(util.VersionCmd())
cmd.AddCommand(OciFetchCmd())
cmd.AddCommand(TemplateCmd())
cmd.AddCommand(DocsCmd())
cmd.AddCommand(ConvertCmd())
preflight.AddFlags(cmd.PersistentFlags())
// Dry run flag should be in cmd.PersistentFlags() flags made available to all subcommands
// Adding here to avoid that
cmd.Flags().Bool("dry-run", false, "print the preflight spec without running preflight checks")
cmd.Flags().Bool("no-uri", false, "When this flag is used, Preflight does not attempt to retrieve the spec referenced by the uri: field`")
cmd.Flags().Bool("auto-update", true, "enable automatic binary self-update check and install")
// Template values for v1beta3 specs
cmd.Flags().StringSlice("values", []string{}, "Path to YAML files containing template values for v1beta3 specs (can be used multiple times)")
cmd.Flags().StringSlice("set", []string{}, "Set template values on the command line for v1beta3 specs (can be used multiple times)")
k8sutil.AddFlags(cmd.Flags())
+42
View File
@@ -0,0 +1,42 @@
package cli
import (
"github.com/replicatedhq/troubleshoot/pkg/preflight"
"github.com/spf13/cobra"
)
func TemplateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "template [template-file]",
Short: "Render a templated preflight spec with values",
Long: `Process a templated preflight YAML file, substituting variables and removing conditional sections based on provided values.
Examples:
# Render template with default values
preflight template sample-preflight-templated.yaml
# Render template with values from files
preflight template sample-preflight-templated.yaml --values values-base.yaml --values values-prod.yaml
# Render template with inline values
preflight template sample-preflight-templated.yaml --set postgres.enabled=true --set cluster.minNodes=5
# Render template and save to file
preflight template sample-preflight-templated.yaml --output rendered.yaml`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
templateFile := args[0]
valuesFiles, _ := cmd.Flags().GetStringSlice("values")
outputFile, _ := cmd.Flags().GetString("output")
setValues, _ := cmd.Flags().GetStringSlice("set")
return preflight.RunTemplate(templateFile, valuesFiles, setValues, outputFile)
},
}
cmd.Flags().StringSlice("values", []string{}, "Path to YAML files containing template values (can be used multiple times)")
cmd.Flags().StringSlice("set", []string{}, "Set template values on the command line (can be used multiple times)")
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
return cmd
}
-174
View File
@@ -1,174 +0,0 @@
package cli
import (
"encoding/json"
"io/ioutil"
"os"
"path"
"path/filepath"
"strings"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/spf13/viper"
extensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
extensionsscheme "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/scheme"
"k8s.io/client-go/kubernetes/scheme"
)
func RootCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "schemagen",
Short: "Generate openapischemas for the kinds in this project",
SilenceUsage: true,
PreRun: func(cmd *cobra.Command, args []string) {
viper.BindPFlags(cmd.Flags())
},
RunE: func(cmd *cobra.Command, args []string) error {
v := viper.GetViper()
return generateSchemas(v)
},
}
cobra.OnInitialize(initConfig)
cmd.Flags().String("output-dir", "./schemas", "directory to save the schemas in")
viper.BindPFlags(cmd.Flags())
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
return cmd
}
func InitAndExecute() {
if err := RootCmd().Execute(); err != nil {
os.Exit(1)
}
}
func initConfig() {
viper.SetEnvPrefix("TROUBLESHOOT")
viper.AutomaticEnv()
}
func generateSchemas(v *viper.Viper) error {
// we generate schemas from the config/crds in the root of this project
// those crds can be created from controller-gen or by running `make openapischema`
workdir, err := os.Getwd()
if err != nil {
return errors.Wrap(err, "failed to get workdir")
}
files := []struct {
inFilename string
outFilename string
}{
{
"troubleshoot.replicated.com_preflights.yaml",
"preflight-troubleshoot-v1beta1.json",
},
{
"troubleshoot.replicated.com_analyzers.yaml",
"analyzer-troubleshoot-v1beta1.json",
},
{
"troubleshoot.replicated.com_collectors.yaml",
"collector-troubleshoot-v1beta1.json",
},
{
"troubleshoot.replicated.com_redactors.yaml",
"redactor-troubleshoot-v1beta1.json",
},
{
"troubleshoot.replicated.com_supportbundles.yaml",
"supportbundle-troubleshoot-v1beta1.json",
},
{
"troubleshoot.sh_analyzers.yaml",
"analyzer-troubleshoot-v1beta2.json",
},
{
"troubleshoot.sh_collectors.yaml",
"collector-troubleshoot-v1beta2.json",
},
{
"troubleshoot.sh_preflights.yaml",
"preflight-troubleshoot-v1beta2.json",
},
{
"troubleshoot.sh_redactors.yaml",
"redactor-troubleshoot-v1beta2.json",
},
{
"troubleshoot.sh_supportbundles.yaml",
"supportbundle-troubleshoot-v1beta2.json",
},
}
for _, file := range files {
contents, err := ioutil.ReadFile(filepath.Join(workdir, "config", "crds", file.inFilename))
if err != nil {
return errors.Wrapf(err, "failed to read crd from %s", file.inFilename)
}
if err := generateSchemaFromCRD(contents, filepath.Join(workdir, v.GetString("output-dir"), file.outFilename)); err != nil {
return errors.Wrapf(err, "failed to write crd schema to %s", file.outFilename)
}
}
return nil
}
func generateSchemaFromCRD(crd []byte, outfile string) error {
extensionsscheme.AddToScheme(scheme.Scheme)
decode := scheme.Codecs.UniversalDeserializer().Decode
obj, _, err := decode(crd, nil, nil)
if err != nil {
return errors.Wrap(err, "failed to decode crd")
}
customResourceDefinition := obj.(*extensionsv1.CustomResourceDefinition)
if len(customResourceDefinition.Spec.Versions) == 0 {
return errors.New("no versions found for CRD")
}
crdSchema := customResourceDefinition.Spec.Versions[0].Schema
if crdSchema == nil {
return errors.New("CRD has a nil schema")
}
b, err := json.MarshalIndent(crdSchema.OpenAPIV3Schema, "", " ")
if err != nil {
return errors.Wrap(err, "failed to marshal json")
}
_, err = os.Stat(outfile)
if err == nil {
if err := os.Remove(outfile); err != nil {
return errors.Wrap(err, "failed to remove file")
}
}
d, _ := path.Split(outfile)
_, err = os.Stat(d)
if os.IsNotExist(err) {
if err = os.MkdirAll(d, 0755); err != nil {
return errors.Wrap(err, "failed to mkdir")
}
}
// whoa now
// working around the fact that controller-gen doesn't have tags to generate oneOf schemas, so this is hacky.
// going to work to add an issue there to support and if they accept, this terrible thing can go away
boolStringed := strings.ReplaceAll(string(b), `"type": "BoolString"`, `"oneOf": [{"type": "string"},{"type": "boolean"}]`)
err = ioutil.WriteFile(outfile, []byte(boolStringed), 0644)
if err != nil {
return errors.Wrap(err, "failed to write file")
}
return nil
}
-9
View File
@@ -1,9 +0,0 @@
package main
import (
"github.com/replicatedhq/troubleshoot/cmd/schemagen/cli"
)
func main() {
cli.InitAndExecute()
}
+372
View File
@@ -0,0 +1,372 @@
package cli
import (
"context"
"fmt"
"time"
"github.com/pkg/errors"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/collect/autodiscovery"
"github.com/replicatedhq/troubleshoot/pkg/collect/images"
"github.com/spf13/viper"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/klog/v2"
)
// AutoDiscoveryConfig contains configuration for auto-discovery
type AutoDiscoveryConfig struct {
Enabled bool
IncludeImages bool
RBACCheck bool
Profile string
ExcludeNamespaces []string
IncludeNamespaces []string
IncludeSystemNamespaces bool
Timeout time.Duration
}
// DiscoveryProfile defines different levels of auto-discovery
type DiscoveryProfile struct {
Name string
Description string
IncludeImages bool
RBACCheck bool
MaxDepth int
Timeout time.Duration
}
// GetAutoDiscoveryConfig extracts auto-discovery configuration from viper
func GetAutoDiscoveryConfig(v *viper.Viper) AutoDiscoveryConfig {
return AutoDiscoveryConfig{
Enabled: v.GetBool("auto"),
IncludeImages: v.GetBool("include-images"),
RBACCheck: v.GetBool("rbac-check"),
Profile: v.GetString("discovery-profile"),
ExcludeNamespaces: v.GetStringSlice("exclude-namespaces"),
IncludeNamespaces: v.GetStringSlice("include-namespaces"),
IncludeSystemNamespaces: v.GetBool("include-system-namespaces"),
Timeout: 30 * time.Second, // Default timeout
}
}
// GetDiscoveryProfiles returns available discovery profiles
func GetDiscoveryProfiles() map[string]DiscoveryProfile {
return map[string]DiscoveryProfile{
"minimal": {
Name: "minimal",
Description: "Minimal collection: cluster info, basic logs",
IncludeImages: false,
RBACCheck: true,
MaxDepth: 1,
Timeout: 15 * time.Second,
},
"standard": {
Name: "standard",
Description: "Standard collection: logs, configs, secrets, events",
IncludeImages: false,
RBACCheck: true,
MaxDepth: 2,
Timeout: 30 * time.Second,
},
"comprehensive": {
Name: "comprehensive",
Description: "Comprehensive collection: everything + image metadata",
IncludeImages: true,
RBACCheck: true,
MaxDepth: 3,
Timeout: 60 * time.Second,
},
"paranoid": {
Name: "paranoid",
Description: "Paranoid collection: maximum data with extended timeouts",
IncludeImages: true,
RBACCheck: true,
MaxDepth: 5,
Timeout: 120 * time.Second,
},
}
}
// ApplyAutoDiscovery applies auto-discovery to the support bundle spec
func ApplyAutoDiscovery(ctx context.Context, client kubernetes.Interface, restConfig *rest.Config,
mainBundle *troubleshootv1beta2.SupportBundle, config AutoDiscoveryConfig, namespace string) error {
if !config.Enabled {
return nil // Auto-discovery not enabled
}
klog.V(2).Infof("Applying auto-discovery with profile: %s", config.Profile)
// Get discovery profile
profiles := GetDiscoveryProfiles()
profile, exists := profiles[config.Profile]
if !exists {
klog.Warningf("Unknown discovery profile '%s', using 'standard'", config.Profile)
profile = profiles["standard"]
}
// Override profile settings with explicit flags
if config.IncludeImages {
profile.IncludeImages = true
}
if config.Timeout > 0 {
profile.Timeout = config.Timeout
}
// Create auto-discovery options
discoveryOpts := autodiscovery.DiscoveryOptions{
IncludeImages: profile.IncludeImages,
RBACCheck: config.RBACCheck,
MaxDepth: profile.MaxDepth,
Timeout: profile.Timeout,
}
// Handle namespace filtering
if namespace != "" {
discoveryOpts.Namespaces = []string{namespace}
} else {
// Use include/exclude patterns if specified
if len(config.IncludeNamespaces) > 0 || len(config.ExcludeNamespaces) > 0 {
// Create namespace scanner to resolve include/exclude patterns
nsScanner := autodiscovery.NewNamespaceScanner(client)
scanOpts := autodiscovery.ScanOptions{
IncludePatterns: config.IncludeNamespaces,
ExcludePatterns: config.ExcludeNamespaces,
IncludeSystemNamespaces: config.IncludeSystemNamespaces,
}
targetNamespaces, err := nsScanner.GetTargetNamespaces(ctx, nil, scanOpts)
if err != nil {
klog.Warningf("Failed to resolve namespace patterns, using all accessible namespaces: %v", err)
// Continue with empty namespace list (all namespaces)
} else {
discoveryOpts.Namespaces = targetNamespaces
klog.V(2).Infof("Resolved namespace patterns to %d namespaces: %v", len(targetNamespaces), targetNamespaces)
}
}
}
// Create autodiscovery instance
discoverer, err := autodiscovery.NewDiscoverer(restConfig, client)
if err != nil {
return errors.Wrap(err, "failed to create auto-discoverer")
}
// Check if we have existing YAML collectors (Path 2) or just auto-discovery (Path 1)
hasYAMLCollectors := len(mainBundle.Spec.Collectors) > 0
var autoCollectors []autodiscovery.CollectorSpec
if hasYAMLCollectors {
// Path 2: Augment existing YAML collectors with foundational collectors
klog.V(2).Info("Auto-discovery: Augmenting YAML collectors with foundational collectors (Path 2)")
// Convert existing collectors to autodiscovery format
yamlCollectors, err := convertToCollectorSpecs(mainBundle.Spec.Collectors)
if err != nil {
return errors.Wrap(err, "failed to convert YAML collectors")
}
discoveryOpts.AugmentMode = true
autoCollectors, err = discoverer.AugmentWithFoundational(ctx, yamlCollectors, discoveryOpts)
if err != nil {
return errors.Wrap(err, "failed to augment with foundational collectors")
}
} else {
// Path 1: Pure foundational discovery
klog.V(2).Info("Auto-discovery: Collecting foundational data only (Path 1)")
discoveryOpts.FoundationalOnly = true
autoCollectors, err = discoverer.DiscoverFoundational(ctx, discoveryOpts)
if err != nil {
return errors.Wrap(err, "failed to discover foundational collectors")
}
}
// Convert auto-discovered collectors back to troubleshoot specs
troubleshootCollectors, err := convertToTroubleshootCollectors(autoCollectors)
if err != nil {
return errors.Wrap(err, "failed to convert auto-discovered collectors")
}
// Update the support bundle spec
if hasYAMLCollectors {
// Replace existing collectors with augmented set
mainBundle.Spec.Collectors = troubleshootCollectors
} else {
// Set foundational collectors
mainBundle.Spec.Collectors = troubleshootCollectors
}
klog.V(2).Infof("Auto-discovery complete: %d collectors configured", len(troubleshootCollectors))
return nil
}
// convertToCollectorSpecs converts troubleshootv1beta2.Collect to autodiscovery.CollectorSpec
func convertToCollectorSpecs(collectors []*troubleshootv1beta2.Collect) ([]autodiscovery.CollectorSpec, error) {
var specs []autodiscovery.CollectorSpec
for i, collect := range collectors {
// Determine collector type and extract relevant information
spec := autodiscovery.CollectorSpec{
Priority: 100, // High priority for YAML specs
Source: autodiscovery.SourceYAML,
}
// Map troubleshoot collectors to autodiscovery types
switch {
case collect.Logs != nil:
spec.Type = autodiscovery.CollectorTypeLogs
spec.Name = fmt.Sprintf("yaml-logs-%d", i)
spec.Namespace = collect.Logs.Namespace
spec.Spec = collect.Logs
case collect.ConfigMap != nil:
spec.Type = autodiscovery.CollectorTypeConfigMaps
spec.Name = fmt.Sprintf("yaml-configmap-%d", i)
spec.Namespace = collect.ConfigMap.Namespace
spec.Spec = collect.ConfigMap
case collect.Secret != nil:
spec.Type = autodiscovery.CollectorTypeSecrets
spec.Name = fmt.Sprintf("yaml-secret-%d", i)
spec.Namespace = collect.Secret.Namespace
spec.Spec = collect.Secret
case collect.ClusterInfo != nil:
spec.Type = autodiscovery.CollectorTypeClusterInfo
spec.Name = fmt.Sprintf("yaml-clusterinfo-%d", i)
spec.Spec = collect.ClusterInfo
case collect.ClusterResources != nil:
spec.Type = autodiscovery.CollectorTypeClusterResources
spec.Name = fmt.Sprintf("yaml-clusterresources-%d", i)
spec.Spec = collect.ClusterResources
default:
// For other collector types, create a generic spec
spec.Type = "other"
spec.Name = fmt.Sprintf("yaml-other-%d", i)
spec.Spec = collect
}
specs = append(specs, spec)
}
return specs, nil
}
// convertToTroubleshootCollectors converts autodiscovery.CollectorSpec to troubleshootv1beta2.Collect
func convertToTroubleshootCollectors(collectors []autodiscovery.CollectorSpec) ([]*troubleshootv1beta2.Collect, error) {
var troubleshootCollectors []*troubleshootv1beta2.Collect
for _, spec := range collectors {
collect, err := spec.ToTroubleshootCollect()
if err != nil {
klog.Warningf("Failed to convert collector spec %s: %v", spec.Name, err)
continue
}
troubleshootCollectors = append(troubleshootCollectors, collect)
}
return troubleshootCollectors, nil
}
// ValidateAutoDiscoveryFlags validates auto-discovery flag combinations
func ValidateAutoDiscoveryFlags(v *viper.Viper) error {
// If include-images is used without auto, it's an error
if v.GetBool("include-images") && !v.GetBool("auto") {
return errors.New("--include-images flag requires --auto flag to be enabled")
}
// Validate discovery profile
profile := v.GetString("discovery-profile")
profiles := GetDiscoveryProfiles()
if _, exists := profiles[profile]; !exists {
return fmt.Errorf("unknown discovery profile: %s. Available profiles: minimal, standard, comprehensive, paranoid", profile)
}
// Validate namespace patterns
includeNS := v.GetStringSlice("include-namespaces")
excludeNS := v.GetStringSlice("exclude-namespaces")
if len(includeNS) > 0 && len(excludeNS) > 0 {
klog.Warning("Both include-namespaces and exclude-namespaces specified. Include patterns take precedence")
}
return nil
}
// ShouldUseAutoDiscovery determines if auto-discovery should be used
func ShouldUseAutoDiscovery(v *viper.Viper, args []string) bool {
// Auto-discovery is enabled by the --auto flag
autoEnabled := v.GetBool("auto")
if !autoEnabled {
return false
}
// Auto-discovery can be used with or without YAML specs
return true
}
// GetAutoDiscoveryMode returns the auto-discovery mode based on arguments
func GetAutoDiscoveryMode(args []string, autoEnabled bool) string {
if !autoEnabled {
return "disabled"
}
if len(args) == 0 {
return "foundational-only" // Path 1
}
return "yaml-augmented" // Path 2
}
// CreateImageCollectionOptions creates image collection options from CLI config
func CreateImageCollectionOptions(config AutoDiscoveryConfig) images.CollectionOptions {
options := images.GetDefaultCollectionOptions()
// Configure based on profile and flags
profiles := GetDiscoveryProfiles()
if profile, exists := profiles[config.Profile]; exists {
options.Timeout = profile.Timeout
options.IncludeConfig = profile.Name == "comprehensive" || profile.Name == "paranoid"
options.IncludeLayers = profile.Name == "paranoid"
}
// Override based on explicit flags
if config.Timeout > 0 {
options.Timeout = config.Timeout
}
// For auto-discovery, always continue on error to maximize collection
options.ContinueOnError = true
options.EnableCache = true
return options
}
// PrintAutoDiscoveryInfo prints information about auto-discovery configuration
func PrintAutoDiscoveryInfo(config AutoDiscoveryConfig, mode string) {
if !config.Enabled {
return
}
fmt.Printf("Auto-discovery enabled (mode: %s, profile: %s)\n", mode, config.Profile)
if config.IncludeImages {
fmt.Println(" - Container image metadata collection enabled")
}
if len(config.IncludeNamespaces) > 0 {
fmt.Printf(" - Including namespaces: %v\n", config.IncludeNamespaces)
}
if len(config.ExcludeNamespaces) > 0 {
fmt.Printf(" - Excluding namespaces: %v\n", config.ExcludeNamespaces)
}
if config.IncludeSystemNamespaces {
fmt.Println(" - System namespaces included")
}
fmt.Printf(" - RBAC checking: %t\n", config.RBACCheck)
}
+389
View File
@@ -0,0 +1,389 @@
package cli
import (
"testing"
"time"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/collect/autodiscovery"
"github.com/replicatedhq/troubleshoot/pkg/collect/images"
"github.com/spf13/viper"
)
func TestGetAutoDiscoveryConfig(t *testing.T) {
tests := []struct {
name string
viperSetup func(*viper.Viper)
wantEnabled bool
wantImages bool
wantRBAC bool
wantProfile string
}{
{
name: "default config",
viperSetup: func(v *viper.Viper) {
// No flags set, should use defaults
},
wantEnabled: false,
wantImages: false,
wantRBAC: true, // Default is true
wantProfile: "standard",
},
{
name: "auto enabled",
viperSetup: func(v *viper.Viper) {
v.Set("auto", true)
},
wantEnabled: true,
wantImages: false,
wantRBAC: true,
wantProfile: "standard",
},
{
name: "auto with images",
viperSetup: func(v *viper.Viper) {
v.Set("auto", true)
v.Set("include-images", true)
},
wantEnabled: true,
wantImages: true,
wantRBAC: true,
wantProfile: "standard",
},
{
name: "comprehensive profile",
viperSetup: func(v *viper.Viper) {
v.Set("auto", true)
v.Set("discovery-profile", "comprehensive")
},
wantEnabled: true,
wantImages: false,
wantRBAC: true,
wantProfile: "comprehensive",
},
{
name: "rbac disabled",
viperSetup: func(v *viper.Viper) {
v.Set("auto", true)
v.Set("rbac-check", false)
},
wantEnabled: true,
wantImages: false,
wantRBAC: false,
wantProfile: "standard",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := viper.New()
// Set defaults
v.SetDefault("rbac-check", true)
v.SetDefault("discovery-profile", "standard")
// Apply test-specific setup
tt.viperSetup(v)
config := GetAutoDiscoveryConfig(v)
if config.Enabled != tt.wantEnabled {
t.Errorf("GetAutoDiscoveryConfig() enabled = %v, want %v", config.Enabled, tt.wantEnabled)
}
if config.IncludeImages != tt.wantImages {
t.Errorf("GetAutoDiscoveryConfig() includeImages = %v, want %v", config.IncludeImages, tt.wantImages)
}
if config.RBACCheck != tt.wantRBAC {
t.Errorf("GetAutoDiscoveryConfig() rbacCheck = %v, want %v", config.RBACCheck, tt.wantRBAC)
}
if config.Profile != tt.wantProfile {
t.Errorf("GetAutoDiscoveryConfig() profile = %v, want %v", config.Profile, tt.wantProfile)
}
})
}
}
func TestGetDiscoveryProfiles(t *testing.T) {
profiles := GetDiscoveryProfiles()
requiredProfiles := []string{"minimal", "standard", "comprehensive", "paranoid"}
for _, profileName := range requiredProfiles {
if profile, exists := profiles[profileName]; !exists {
t.Errorf("Missing required discovery profile: %s", profileName)
} else {
if profile.Name != profileName {
t.Errorf("Profile %s has wrong name: %s", profileName, profile.Name)
}
if profile.Description == "" {
t.Errorf("Profile %s missing description", profileName)
}
if profile.Timeout <= 0 {
t.Errorf("Profile %s has invalid timeout: %v", profileName, profile.Timeout)
}
}
}
// Check profile progression (more features as we go up)
if profiles["comprehensive"].IncludeImages && !profiles["paranoid"].IncludeImages {
t.Error("Paranoid profile should include at least everything comprehensive does")
}
}
func TestValidateAutoDiscoveryFlags(t *testing.T) {
tests := []struct {
name string
viperSetup func(*viper.Viper)
wantErr bool
}{
{
name: "valid auto discovery",
viperSetup: func(v *viper.Viper) {
v.Set("auto", true)
v.Set("include-images", true)
v.Set("discovery-profile", "standard")
},
wantErr: false,
},
{
name: "include-images without auto",
viperSetup: func(v *viper.Viper) {
v.Set("auto", false)
v.Set("include-images", true)
},
wantErr: true,
},
{
name: "invalid discovery profile",
viperSetup: func(v *viper.Viper) {
v.Set("auto", true)
v.Set("discovery-profile", "invalid-profile")
},
wantErr: true,
},
{
name: "no auto discovery",
viperSetup: func(v *viper.Viper) {
v.Set("auto", false)
},
wantErr: false,
},
{
name: "both include and exclude namespaces",
viperSetup: func(v *viper.Viper) {
v.Set("auto", true)
v.Set("include-namespaces", []string{"app1"})
v.Set("exclude-namespaces", []string{"system"})
},
wantErr: false, // Should warn but not error
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := viper.New()
// Set defaults
v.SetDefault("rbac-check", true)
v.SetDefault("discovery-profile", "standard")
// Apply test setup
tt.viperSetup(v)
err := ValidateAutoDiscoveryFlags(v)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateAutoDiscoveryFlags() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestShouldUseAutoDiscovery(t *testing.T) {
tests := []struct {
name string
viperSetup func(*viper.Viper)
args []string
want bool
}{
{
name: "auto flag enabled",
viperSetup: func(v *viper.Viper) {
v.Set("auto", true)
},
args: []string{},
want: true,
},
{
name: "auto flag disabled",
viperSetup: func(v *viper.Viper) {
v.Set("auto", false)
},
args: []string{},
want: false,
},
{
name: "auto with yaml args",
viperSetup: func(v *viper.Viper) {
v.Set("auto", true)
},
args: []string{"spec.yaml"},
want: true,
},
{
name: "no auto flag",
viperSetup: func(v *viper.Viper) {
// No auto flag set
},
args: []string{"spec.yaml"},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := viper.New()
tt.viperSetup(v)
got := ShouldUseAutoDiscovery(v, tt.args)
if got != tt.want {
t.Errorf("ShouldUseAutoDiscovery() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetAutoDiscoveryMode(t *testing.T) {
tests := []struct {
name string
args []string
autoEnabled bool
want string
}{
{
name: "foundational only",
args: []string{},
autoEnabled: true,
want: "foundational-only",
},
{
name: "yaml augmented",
args: []string{"spec.yaml"},
autoEnabled: true,
want: "yaml-augmented",
},
{
name: "disabled",
args: []string{},
autoEnabled: false,
want: "disabled",
},
{
name: "disabled with args",
args: []string{"spec.yaml"},
autoEnabled: false,
want: "disabled",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetAutoDiscoveryMode(tt.args, tt.autoEnabled)
if got != tt.want {
t.Errorf("GetAutoDiscoveryMode() = %v, want %v", got, tt.want)
}
})
}
}
func TestCreateImageCollectionOptions(t *testing.T) {
tests := []struct {
name string
config AutoDiscoveryConfig
checkFunc func(t *testing.T, opts images.CollectionOptions)
}{
{
name: "standard profile",
config: AutoDiscoveryConfig{
Profile: "standard",
},
checkFunc: func(t *testing.T, opts images.CollectionOptions) {
if opts.Timeout != 30*time.Second {
t.Errorf("Expected standard profile timeout 30s, got %v", opts.Timeout)
}
if !opts.ContinueOnError {
t.Error("Should continue on error for auto-discovery")
}
},
},
{
name: "comprehensive profile",
config: AutoDiscoveryConfig{
Profile: "comprehensive",
},
checkFunc: func(t *testing.T, opts images.CollectionOptions) {
if opts.Timeout != 60*time.Second {
t.Errorf("Expected comprehensive profile timeout 60s, got %v", opts.Timeout)
}
if !opts.IncludeConfig {
t.Error("Comprehensive profile should include config")
}
},
},
{
name: "paranoid profile",
config: AutoDiscoveryConfig{
Profile: "paranoid",
},
checkFunc: func(t *testing.T, opts images.CollectionOptions) {
if opts.Timeout != 120*time.Second {
t.Errorf("Expected paranoid profile timeout 120s, got %v", opts.Timeout)
}
if !opts.IncludeLayers {
t.Error("Paranoid profile should include layers")
}
},
},
{
name: "custom timeout",
config: AutoDiscoveryConfig{
Profile: "standard",
Timeout: 45 * time.Second,
},
checkFunc: func(t *testing.T, opts images.CollectionOptions) {
if opts.Timeout != 45*time.Second {
t.Errorf("Expected custom timeout 45s, got %v", opts.Timeout)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := CreateImageCollectionOptions(tt.config)
tt.checkFunc(t, opts)
})
}
}
func TestConvertToCollectorSpecs(t *testing.T) {
// This test would need actual troubleshootv1beta2.Collect instances
// For now, test with nil input
specs, err := convertToCollectorSpecs([]*troubleshootv1beta2.Collect{})
if err != nil {
t.Errorf("convertToCollectorSpecs() with empty input should not error: %v", err)
}
if len(specs) != 0 {
t.Errorf("convertToCollectorSpecs() with empty input should return empty slice, got %d items", len(specs))
}
}
func TestConvertToTroubleshootCollectors(t *testing.T) {
// This test would need actual autodiscovery.CollectorSpec instances
// For now, test with nil input
collectors, err := convertToTroubleshootCollectors([]autodiscovery.CollectorSpec{})
if err != nil {
t.Errorf("convertToTroubleshootCollectors() with empty input should not error: %v", err)
}
if len(collectors) != 0 {
t.Errorf("convertToTroubleshootCollectors() with empty input should return empty slice, got %d items", len(collectors))
}
}
+848
View File
@@ -0,0 +1,848 @@
package cli
import (
"archive/tar"
"bufio"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"
"time"
"github.com/pkg/errors"
"github.com/pmezard/go-difflib/difflib"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"k8s.io/klog/v2"
)
const maxInlineDiffBytes = 256 * 1024
// DiffResult represents the result of comparing two support bundles
type DiffResult struct {
Summary DiffSummary `json:"summary"`
Changes []Change `json:"changes"`
Metadata DiffMetadata `json:"metadata"`
Significance string `json:"significance"`
}
// DiffSummary provides high-level statistics about the diff
type DiffSummary struct {
TotalChanges int `json:"totalChanges"`
FilesAdded int `json:"filesAdded"`
FilesRemoved int `json:"filesRemoved"`
FilesModified int `json:"filesModified"`
HighImpactChanges int `json:"highImpactChanges"`
}
// Change represents a single difference between bundles
type Change struct {
Type string `json:"type"` // added, removed, modified
Category string `json:"category"` // resource, log, config, etc.
Path string `json:"path"` // file path or resource path
Impact string `json:"impact"` // high, medium, low, none
Details map[string]interface{} `json:"details"` // change-specific details
Remediation *RemediationStep `json:"remediation,omitempty"`
}
// RemediationStep represents a suggested remediation action
type RemediationStep struct {
Title string `json:"title"`
Description string `json:"description"`
Command string `json:"command,omitempty"`
URL string `json:"url,omitempty"`
}
// DiffMetadata contains metadata about the diff operation
type DiffMetadata struct {
OldBundle BundleMetadata `json:"oldBundle"`
NewBundle BundleMetadata `json:"newBundle"`
GeneratedAt string `json:"generatedAt"`
Version string `json:"version"`
}
// BundleMetadata contains metadata about a support bundle
type BundleMetadata struct {
Path string `json:"path"`
Size int64 `json:"size"`
CreatedAt string `json:"createdAt,omitempty"`
NumFiles int `json:"numFiles"`
}
func Diff() *cobra.Command {
cmd := &cobra.Command{
Use: "diff <old-bundle.(tar.gz|tgz)> <new-bundle.(tar.gz|tgz)>",
Args: cobra.ExactArgs(2),
Short: "Compare two support bundles and identify changes",
Long: `Compare two support bundles to identify changes over time.
This command analyzes differences between two support bundle archives and generates
a human-readable report showing what has changed, including:
- Added, removed, or modified files
- Configuration changes
- Log differences
- Resource status changes
- Performance metric changes
Use -o to write the report to a file; otherwise it prints to stdout.`,
PreRun: func(cmd *cobra.Command, args []string) {
viper.BindPFlags(cmd.Flags())
},
RunE: func(cmd *cobra.Command, args []string) error {
v := viper.GetViper()
return runBundleDiff(v, args[0], args[1])
},
}
cmd.Flags().StringP("output", "o", "", "file path of where to save the diff report (default prints to stdout)")
cmd.Flags().Int("max-diff-lines", 200, "maximum total lines to include in an inline diff for a single file")
cmd.Flags().Int("max-diff-files", 50, "maximum number of files to include inline diffs for; additional modified files will omit inline diffs")
cmd.Flags().Bool("include-log-diffs", false, "include inline diffs for log files as well")
cmd.Flags().Int("diff-context", 3, "number of context lines to include around changes in unified diffs")
cmd.Flags().Bool("hide-inline-diffs", false, "hide inline unified diffs in the report")
cmd.Flags().String("format", "", "output format; set to 'json' to emit machine-readable JSON to stdout or -o")
return cmd
}
func runBundleDiff(v *viper.Viper, oldBundle, newBundle string) error {
klog.V(2).Infof("Comparing support bundles: %s -> %s", oldBundle, newBundle)
// Validate input files
if err := validateBundleFile(oldBundle); err != nil {
return errors.Wrap(err, "invalid old bundle")
}
if err := validateBundleFile(newBundle); err != nil {
return errors.Wrap(err, "invalid new bundle")
}
// Perform the diff (placeholder implementation)
diffResult, err := performBundleDiff(oldBundle, newBundle, v)
if err != nil {
return errors.Wrap(err, "failed to compare bundles")
}
// Output the results
if err := outputDiffResult(diffResult, v); err != nil {
return errors.Wrap(err, "failed to output diff results")
}
return nil
}
func validateBundleFile(bundlePath string) error {
if bundlePath == "" {
return errors.New("bundle path cannot be empty")
}
// Check if file exists
if _, err := os.Stat(bundlePath); os.IsNotExist(err) {
return fmt.Errorf("bundle file not found: %s", bundlePath)
}
// Support .tar.gz and .tgz bundles
lower := strings.ToLower(bundlePath)
if !(strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz")) {
return fmt.Errorf("unsupported bundle format. Expected path to end with .tar.gz or .tgz")
}
return nil
}
func performBundleDiff(oldBundle, newBundle string, v *viper.Viper) (*DiffResult, error) {
klog.V(2).Info("Performing bundle diff analysis (streaming)...")
// Stream inventories
oldInv, err := buildInventoryFromTarGz(oldBundle)
if err != nil {
return nil, errors.Wrap(err, "failed to inventory old bundle")
}
newInv, err := buildInventoryFromTarGz(newBundle)
if err != nil {
return nil, errors.Wrap(err, "failed to inventory new bundle")
}
var changes []Change
inlineDiffsIncluded := 0
maxDiffLines := v.GetInt("max-diff-lines")
if maxDiffLines <= 0 {
maxDiffLines = 200
}
maxDiffFiles := v.GetInt("max-diff-files")
if maxDiffFiles <= 0 {
maxDiffFiles = 50
}
includeLogDiffs := v.GetBool("include-log-diffs")
diffContext := v.GetInt("diff-context")
if diffContext <= 0 {
diffContext = 3
}
// Added files
for p, nf := range newInv {
if _, ok := oldInv[p]; !ok {
ch := Change{
Type: "added",
Category: categorizePath(p),
Path: "/" + p,
Impact: estimateImpact("added", p),
Details: map[string]interface{}{
"size": nf.Size,
},
}
if rem := suggestRemediation(ch.Type, p); rem != nil {
ch.Remediation = rem
}
changes = append(changes, ch)
}
}
// Removed files
for p, of := range oldInv {
if _, ok := newInv[p]; !ok {
ch := Change{
Type: "removed",
Category: categorizePath(p),
Path: "/" + p,
Impact: estimateImpact("removed", p),
Details: map[string]interface{}{
"size": of.Size,
},
}
if rem := suggestRemediation(ch.Type, p); rem != nil {
ch.Remediation = rem
}
changes = append(changes, ch)
}
}
// Modified files
for p, of := range oldInv {
if nf, ok := newInv[p]; ok {
if of.Digest != nf.Digest {
ch := Change{
Type: "modified",
Category: categorizePath(p),
Path: "/" + p,
Impact: estimateImpact("modified", p),
Details: map[string]interface{}{},
}
if rem := suggestRemediation(ch.Type, p); rem != nil {
ch.Remediation = rem
}
changes = append(changes, ch)
}
}
}
// Sort changes deterministically: type, then path
sort.Slice(changes, func(i, j int) bool {
if changes[i].Type == changes[j].Type {
return changes[i].Path < changes[j].Path
}
return changes[i].Type < changes[j].Type
})
// Populate inline diffs lazily for the first N eligible modified files using streaming approach
for i := range changes {
if inlineDiffsIncluded >= maxDiffFiles {
break
}
ch := &changes[i]
if ch.Type != "modified" {
continue
}
allowLogs := includeLogDiffs || ch.Category != "logs"
if !allowLogs {
continue
}
// Use streaming diff generation to avoid loading large files into memory
if d := generateStreamingUnifiedDiff(oldBundle, newBundle, ch.Path, diffContext, maxDiffLines); d != "" {
if ch.Details == nil {
ch.Details = map[string]interface{}{}
}
ch.Details["diff"] = d
inlineDiffsIncluded++
}
}
// Summaries
summary := DiffSummary{}
for _, c := range changes {
switch c.Type {
case "added":
summary.FilesAdded++
case "removed":
summary.FilesRemoved++
case "modified":
summary.FilesModified++
}
if c.Impact == "high" {
summary.HighImpactChanges++
}
}
summary.TotalChanges = summary.FilesAdded + summary.FilesRemoved + summary.FilesModified
oldMeta := getBundleMetadataWithCount(oldBundle, len(oldInv))
newMeta := getBundleMetadataWithCount(newBundle, len(newInv))
result := &DiffResult{
Summary: summary,
Changes: changes,
Metadata: DiffMetadata{OldBundle: oldMeta, NewBundle: newMeta, GeneratedAt: time.Now().Format(time.RFC3339), Version: "v1"},
Significance: computeOverallSignificance(changes),
}
return result, nil
}
type inventoryFile struct {
Size int64
Digest string
}
func buildInventoryFromTarGz(bundlePath string) (map[string]inventoryFile, error) {
f, err := os.Open(bundlePath)
if err != nil {
return nil, errors.Wrap(err, "failed to open bundle")
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return nil, errors.Wrap(err, "failed to create gzip reader")
}
defer gz.Close()
tr := tar.NewReader(gz)
inv := make(map[string]inventoryFile)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, errors.Wrap(err, "failed to read tar entry")
}
if !hdr.FileInfo().Mode().IsRegular() {
continue
}
norm := normalizePath(hdr.Name)
if norm == "" {
continue
}
h := sha256.New()
var copied int64
buf := make([]byte, 32*1024)
for copied < hdr.Size {
toRead := int64(len(buf))
if remain := hdr.Size - copied; remain < toRead {
toRead = remain
}
n, rerr := io.ReadFull(tr, buf[:toRead])
if n > 0 {
_, _ = h.Write(buf[:n])
copied += int64(n)
}
if rerr == io.EOF || rerr == io.ErrUnexpectedEOF {
break
}
if rerr != nil {
return nil, errors.Wrap(rerr, "failed to read file content")
}
}
digest := hex.EncodeToString(h.Sum(nil))
inv[norm] = inventoryFile{Size: hdr.Size, Digest: digest}
}
return inv, nil
}
func normalizePath(name string) string {
name = strings.TrimPrefix(name, "./")
if name == "" {
return name
}
i := strings.IndexByte(name, '/')
if i < 0 {
return name
}
first := name[:i]
rest := name[i+1:]
// Known domain roots we do not strip
domainRoots := map[string]bool{
"cluster-resources": true,
"all-logs": true,
"cluster-info": true,
"execution-data": true,
}
if domainRoots[first] {
return name
}
// Strip only known container prefixes
if first == "root" || strings.HasPrefix(strings.ToLower(first), "support-bundle") {
return rest
}
return name
}
func isProbablyText(sample []byte) bool {
if len(sample) == 0 {
return false
}
for _, b := range sample {
if b == 0x00 {
return false
}
if b < 0x09 {
return false
}
}
return true
}
func normalizeNewlines(s string) string {
return strings.ReplaceAll(s, "\r\n", "\n")
}
// generateStreamingUnifiedDiff creates a unified diff by streaming files line-by-line to avoid loading large files into memory
func generateStreamingUnifiedDiff(oldBundle, newBundle, path string, context, maxTotalLines int) string {
oldReader, err := createTarFileReader(oldBundle, strings.TrimPrefix(path, "/"))
if err != nil {
return ""
}
defer oldReader.Close()
newReader, err := createTarFileReader(newBundle, strings.TrimPrefix(path, "/"))
if err != nil {
return ""
}
defer newReader.Close()
// Read files line by line
oldLines, err := readLinesFromReader(oldReader, maxInlineDiffBytes)
if err != nil {
return ""
}
newLines, err := readLinesFromReader(newReader, maxInlineDiffBytes)
if err != nil {
return ""
}
// Generate diff using the existing difflib
ud := difflib.UnifiedDiff{
A: oldLines,
B: newLines,
FromFile: "old:" + path,
ToFile: "new:" + path,
Context: context,
}
s, err := difflib.GetUnifiedDiffString(ud)
if err != nil || s == "" {
return ""
}
lines := strings.Split(s, "\n")
if maxTotalLines > 0 && len(lines) > maxTotalLines {
lines = append(lines[:maxTotalLines], "... (diff truncated)")
}
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return strings.Join(lines, "\n")
}
// readLinesFromReader reads lines from a reader up to maxBytes total, returning normalized lines
func readLinesFromReader(reader io.Reader, maxBytes int) ([]string, error) {
var lines []string
var totalBytes int
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := normalizeNewlines(scanner.Text())
lineWithNL := line + "\n"
lineBytes := len(lineWithNL)
if totalBytes+lineBytes > maxBytes {
lines = append(lines, "... (content truncated due to size)\n")
break
}
lines = append(lines, lineWithNL)
totalBytes += lineBytes
}
if err := scanner.Err(); err != nil {
return nil, err
}
return lines, nil
}
// generateUnifiedDiff builds a unified diff with headers and context, then truncates to maxTotalLines
func generateUnifiedDiff(a, b string, path string, context, maxTotalLines int) string {
ud := difflib.UnifiedDiff{
A: difflib.SplitLines(a),
B: difflib.SplitLines(b),
FromFile: "old:" + path,
ToFile: "new:" + path,
Context: context,
}
s, err := difflib.GetUnifiedDiffString(ud)
if err != nil || s == "" {
return ""
}
lines := strings.Split(s, "\n")
if maxTotalLines > 0 && len(lines) > maxTotalLines {
lines = append(lines[:maxTotalLines], "... (diff truncated)")
}
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return strings.Join(lines, "\n")
}
func categorizePath(p string) string {
if strings.HasPrefix(p, "cluster-resources/pods/logs/") || strings.Contains(p, "/logs/") || strings.HasPrefix(p, "all-logs/") || strings.HasPrefix(p, "logs/") {
return "logs"
}
if strings.HasPrefix(p, "cluster-resources/") {
rest := strings.TrimPrefix(p, "cluster-resources/")
seg := rest
if i := strings.IndexByte(rest, '/'); i >= 0 {
seg = rest[:i]
}
if seg == "" {
return "resources"
}
return "resources:" + seg
}
if strings.HasPrefix(p, "config/") || strings.HasSuffix(p, ".yaml") || strings.HasSuffix(p, ".yml") || strings.HasSuffix(p, ".json") {
return "config"
}
return "files"
}
// estimateImpact determines impact based on change type and path patterns
func estimateImpact(changeType, p string) string {
// High impact cases
if strings.HasPrefix(p, "cluster-resources/custom-resource-definitions") {
if changeType == "removed" || changeType == "modified" {
return "high"
}
}
if strings.HasPrefix(p, "cluster-resources/clusterrole") || strings.HasPrefix(p, "cluster-resources/clusterrolebindings") || strings.Contains(p, "/rolebindings/") {
if changeType != "added" { // reductions or changes can break access
return "high"
}
}
if strings.Contains(p, "/secrets/") || strings.HasSuffix(p, "-secrets.json") {
if changeType != "added" {
return "high"
}
}
if strings.HasPrefix(p, "cluster-resources/nodes") {
if changeType != "added" { // node status changes can be severe
return "high"
}
}
if strings.Contains(p, "/network-policy/") || strings.HasSuffix(p, "/networkpolicies.json") {
if changeType != "added" {
return "high"
}
}
if strings.HasPrefix(p, "cluster-resources/") && strings.Contains(p, "/kube-system") {
if changeType != "added" {
return "high"
}
}
// Medium default for cluster resources
if strings.HasPrefix(p, "cluster-resources/") {
return "medium"
}
// Logs and others default low
if strings.Contains(p, "/logs/") || strings.HasPrefix(p, "all-logs/") {
return "low"
}
return "low"
}
// suggestRemediation returns a basic remediation suggestion based on category and change
func suggestRemediation(changeType, p string) *RemediationStep {
// RBAC
if strings.HasPrefix(p, "cluster-resources/clusterrole") || strings.HasPrefix(p, "cluster-resources/clusterrolebindings") || strings.Contains(p, "/rolebindings/") {
return &RemediationStep{Description: "Validate RBAC permissions and recent changes", Command: "kubectl auth can-i --list"}
}
// CRDs
if strings.HasPrefix(p, "cluster-resources/custom-resource-definitions") {
return &RemediationStep{Description: "Check CRD presence and reconcile operator status", Command: "kubectl get crds"}
}
// Nodes
if strings.HasPrefix(p, "cluster-resources/nodes") {
return &RemediationStep{Description: "Inspect node conditions and recent events", Command: "kubectl describe nodes"}
}
// Network policy
if strings.Contains(p, "/network-policy/") || strings.HasSuffix(p, "/networkpolicies.json") {
return &RemediationStep{Description: "Validate connectivity and recent NetworkPolicy changes", Command: "kubectl get networkpolicy -A"}
}
// Secrets/Config
if strings.Contains(p, "/secrets/") || strings.HasPrefix(p, "config/") {
return &RemediationStep{Description: "Review configuration or secret changes for correctness"}
}
// Workloads
if strings.Contains(p, "/deployments/") || strings.Contains(p, "/statefulsets/") || strings.Contains(p, "/daemonsets/") {
return &RemediationStep{Description: "Check rollout and pod status", Command: "kubectl rollout status -n <ns> <kind>/<name>"}
}
return nil
}
func computeOverallSignificance(changes []Change) string {
high, medium := 0, 0
for _, c := range changes {
switch c.Impact {
case "high":
high++
case "medium":
medium++
}
}
if high > 0 {
return "high"
}
if medium > 0 {
return "medium"
}
if len(changes) > 0 {
return "low"
}
return "none"
}
func getBundleMetadata(bundlePath string) BundleMetadata {
metadata := BundleMetadata{
Path: bundlePath,
}
if stat, err := os.Stat(bundlePath); err == nil {
metadata.Size = stat.Size()
metadata.CreatedAt = stat.ModTime().Format(time.RFC3339)
}
return metadata
}
// getBundleMetadataWithCount sets NumFiles directly to avoid re-reading the archive
func getBundleMetadataWithCount(bundlePath string, numFiles int) BundleMetadata {
md := getBundleMetadata(bundlePath)
md.NumFiles = numFiles
return md
}
func outputDiffResult(result *DiffResult, v *viper.Viper) error {
outputPath := v.GetString("output")
showInlineDiffs := !v.GetBool("hide-inline-diffs")
formatMode := strings.ToLower(v.GetString("format"))
var output []byte
if formatMode == "json" {
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
return errors.Wrap(err, "failed to marshal diff result to JSON")
}
output = data
} else {
output = []byte(generateTextDiffReport(result, showInlineDiffs))
}
if outputPath != "" {
// Write to file
if err := os.WriteFile(outputPath, output, 0644); err != nil {
return errors.Wrap(err, "failed to write diff output to file")
}
fmt.Printf("Diff report written to: %s\n", outputPath)
} else {
// Write to stdout
fmt.Print(string(output))
}
return nil
}
func generateTextDiffReport(result *DiffResult, showInlineDiffs bool) string {
var report strings.Builder
report.WriteString("Support Bundle Diff Report\n")
report.WriteString("==========================\n\n")
report.WriteString(fmt.Sprintf("Generated: %s\n", result.Metadata.GeneratedAt))
report.WriteString(fmt.Sprintf("Old Bundle: %s (%s)\n", result.Metadata.OldBundle.Path, formatSize(result.Metadata.OldBundle.Size)))
report.WriteString(fmt.Sprintf("New Bundle: %s (%s)\n\n", result.Metadata.NewBundle.Path, formatSize(result.Metadata.NewBundle.Size)))
// Summary
report.WriteString("Summary:\n")
report.WriteString(fmt.Sprintf(" Total Changes: %d\n", result.Summary.TotalChanges))
report.WriteString(fmt.Sprintf(" Files Added: %d\n", result.Summary.FilesAdded))
report.WriteString(fmt.Sprintf(" Files Removed: %d\n", result.Summary.FilesRemoved))
report.WriteString(fmt.Sprintf(" Files Modified: %d\n", result.Summary.FilesModified))
report.WriteString(fmt.Sprintf(" High Impact Changes: %d\n", result.Summary.HighImpactChanges))
report.WriteString(fmt.Sprintf(" Significance: %s\n\n", result.Significance))
if len(result.Changes) == 0 {
report.WriteString("No changes detected between bundles.\n")
} else {
report.WriteString("Changes:\n")
for i, change := range result.Changes {
report.WriteString(fmt.Sprintf(" %d. [%s] %s (%s impact)\n",
i+1, strings.ToUpper(change.Type), change.Path, change.Impact))
if change.Remediation != nil {
report.WriteString(fmt.Sprintf(" Remediation: %s\n", change.Remediation.Description))
}
if showInlineDiffs {
if diffStr, ok := change.Details["diff"].(string); ok && diffStr != "" {
report.WriteString(" Diff:\n")
for _, line := range strings.Split(diffStr, "\n") {
report.WriteString(" " + line + "\n")
}
}
}
}
}
return report.String()
}
func formatSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// tarFileReader provides a streaming interface to read a specific file from a tar.gz archive
type tarFileReader struct {
file *os.File
gz *gzip.Reader
tr *tar.Reader
found bool
header *tar.Header
}
// createTarFileReader creates a streaming reader for a specific file in a tar.gz archive
func createTarFileReader(bundlePath, normalizedPath string) (*tarFileReader, error) {
f, err := os.Open(bundlePath)
if err != nil {
return nil, err
}
gz, err := gzip.NewReader(f)
if err != nil {
f.Close()
return nil, err
}
tr := tar.NewReader(gz)
// Find the target file
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
gz.Close()
f.Close()
return nil, err
}
if !hdr.FileInfo().Mode().IsRegular() {
continue
}
if normalizePath(hdr.Name) == normalizedPath {
// Check if file is probably text
sample := make([]byte, 512)
n, _ := io.ReadFull(tr, sample[:])
if n > 0 && !isProbablyText(sample[:n]) {
gz.Close()
f.Close()
return nil, fmt.Errorf("file is not text")
}
// Reopen to start from beginning of file
gz.Close()
f.Close()
f, err = os.Open(bundlePath)
if err != nil {
return nil, err
}
gz, err = gzip.NewReader(f)
if err != nil {
f.Close()
return nil, err
}
tr = tar.NewReader(gz)
// Find the file again
for {
hdr, err = tr.Next()
if err == io.EOF {
gz.Close()
f.Close()
return nil, fmt.Errorf("file not found on second pass")
}
if err != nil {
gz.Close()
f.Close()
return nil, err
}
if normalizePath(hdr.Name) == normalizedPath {
return &tarFileReader{
file: f,
gz: gz,
tr: tr,
found: true,
header: hdr,
}, nil
}
}
}
}
gz.Close()
f.Close()
return nil, fmt.Errorf("file not found: %s", normalizedPath)
}
// Read implements io.Reader interface
func (r *tarFileReader) Read(p []byte) (n int, err error) {
if !r.found {
return 0, io.EOF
}
return r.tr.Read(p)
}
// Close closes the underlying file handles
func (r *tarFileReader) Close() error {
if r.gz != nil {
r.gz.Close()
}
if r.file != nil {
return r.file.Close()
}
return nil
}
+552
View File
@@ -0,0 +1,552 @@
package cli
import (
"archive/tar"
"bytes"
"compress/gzip"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/viper"
)
func TestValidateBundleFile(t *testing.T) {
// Create temporary test files
tempDir := t.TempDir()
// Create bundle files
validBundle := filepath.Join(tempDir, "test-bundle.tar.gz")
if err := os.WriteFile(validBundle, []byte("dummy content"), 0644); err != nil {
t.Fatalf("Failed to create test bundle: %v", err)
}
validTgz := filepath.Join(tempDir, "test-bundle.tgz")
if err := os.WriteFile(validTgz, []byte("dummy content"), 0644); err != nil {
t.Fatalf("Failed to create test tgz bundle: %v", err)
}
tests := []struct {
name string
bundlePath string
wantErr bool
}{
{
name: "valid tar.gz bundle",
bundlePath: validBundle,
wantErr: false,
},
{
name: "valid tgz bundle",
bundlePath: validTgz,
wantErr: false,
},
{
name: "empty path",
bundlePath: "",
wantErr: true,
},
{
name: "non-existent file",
bundlePath: "/path/to/nonexistent.tar.gz",
wantErr: true,
},
{
name: "invalid extension",
bundlePath: filepath.Join(tempDir, "invalid.txt"),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// For invalid extension test, create the file
if strings.HasSuffix(tt.bundlePath, "invalid.txt") {
os.WriteFile(tt.bundlePath, []byte("content"), 0644)
}
err := validateBundleFile(tt.bundlePath)
if (err != nil) != tt.wantErr {
t.Errorf("validateBundleFile() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestGetBundleMetadata(t *testing.T) {
// Create a temporary test file
tempDir := t.TempDir()
testBundle := filepath.Join(tempDir, "test-bundle.tar.gz")
testContent := []byte("test bundle content")
if err := os.WriteFile(testBundle, testContent, 0644); err != nil {
t.Fatalf("Failed to create test bundle: %v", err)
}
metadata := getBundleMetadata(testBundle)
if metadata.Path != testBundle {
t.Errorf("getBundleMetadata() path = %v, want %v", metadata.Path, testBundle)
}
if metadata.Size != int64(len(testContent)) {
t.Errorf("getBundleMetadata() size = %v, want %v", metadata.Size, len(testContent))
}
if metadata.CreatedAt == "" {
t.Error("getBundleMetadata() createdAt should not be empty")
}
// Validate timestamp format
if _, err := time.Parse(time.RFC3339, metadata.CreatedAt); err != nil {
t.Errorf("getBundleMetadata() createdAt has invalid format: %v", err)
}
}
func TestPerformBundleDiff(t *testing.T) {
// Create temporary test bundles
tempDir := t.TempDir()
oldBundle := filepath.Join(tempDir, "old-bundle.tar.gz")
newBundle := filepath.Join(tempDir, "new-bundle.tar.gz")
if err := writeTarGz(oldBundle, map[string]string{
"root/cluster-resources/version.txt": "v1\n",
"root/logs/app.log": "line1\n",
}); err != nil {
t.Fatalf("Failed to create old bundle: %v", err)
}
if err := writeTarGz(newBundle, map[string]string{
"root/cluster-resources/version.txt": "v2\n",
"root/logs/app.log": "line1\nline2\n",
"root/added.txt": "new\n",
}); err != nil {
t.Fatalf("Failed to create new bundle: %v", err)
}
v := viper.New()
result, err := performBundleDiff(oldBundle, newBundle, v)
if err != nil {
t.Fatalf("performBundleDiff() error = %v", err)
}
if result == nil {
t.Fatal("performBundleDiff() returned nil result")
}
// Verify result structure
if result.Metadata.Version != "v1" {
t.Errorf("performBundleDiff() version = %v, want v1", result.Metadata.Version)
}
if result.Metadata.OldBundle.Path != oldBundle {
t.Errorf("performBundleDiff() old bundle path = %v, want %v", result.Metadata.OldBundle.Path, oldBundle)
}
if result.Metadata.NewBundle.Path != newBundle {
t.Errorf("performBundleDiff() new bundle path = %v, want %v", result.Metadata.NewBundle.Path, newBundle)
}
if result.Metadata.GeneratedAt == "" {
t.Error("performBundleDiff() generatedAt should not be empty")
}
}
// writeTarGz creates a gzipped tar file at tarPath with the provided files map.
// Keys are entry names inside the archive, values are file contents.
func writeTarGz(tarPath string, files map[string]string) error {
f, err := os.Create(tarPath)
if err != nil {
return err
}
defer f.Close()
gz := gzip.NewWriter(f)
defer gz.Close()
tw := tar.NewWriter(gz)
defer tw.Close()
for name, content := range files {
data := []byte(content)
hdr := &tar.Header{
Name: name,
Mode: 0644,
Size: int64(len(data)),
Typeflag: tar.TypeReg,
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if _, err := bytes.NewReader(data).WriteTo(tw); err != nil {
return err
}
}
return nil
}
func TestGenerateTextDiffReport(t *testing.T) {
result := &DiffResult{
Summary: DiffSummary{
TotalChanges: 3,
FilesAdded: 1,
FilesRemoved: 1,
FilesModified: 1,
HighImpactChanges: 1,
},
Changes: []Change{
{
Type: "added",
Category: "config",
Path: "/new-config.yaml",
Impact: "medium",
Remediation: &RemediationStep{
Description: "Review new configuration",
},
},
{
Type: "removed",
Category: "resource",
Path: "/old-deployment.yaml",
Impact: "high",
},
},
Metadata: DiffMetadata{
OldBundle: BundleMetadata{
Path: "/old/bundle.tar.gz",
Size: 1024,
},
NewBundle: BundleMetadata{
Path: "/new/bundle.tar.gz",
Size: 2048,
},
GeneratedAt: "2023-01-01T00:00:00Z",
},
Significance: "high",
}
report := generateTextDiffReport(result, true)
// Check that report contains expected elements
expectedStrings := []string{
"Support Bundle Diff Report",
"Total Changes: 3",
"Files Added: 1",
"High Impact Changes: 1",
"Significance: high",
"/new-config.yaml",
"/old-deployment.yaml",
"Remediation: Review new configuration",
}
for _, expected := range expectedStrings {
if !strings.Contains(report, expected) {
t.Errorf("generateTextDiffReport() missing expected string: %s", expected)
}
}
}
func TestOutputDiffResult_JSON(t *testing.T) {
// Minimal result
result := &DiffResult{
Summary: DiffSummary{},
Metadata: DiffMetadata{
OldBundle: BundleMetadata{Path: "/old.tar.gz"},
NewBundle: BundleMetadata{Path: "/new.tar.gz"},
GeneratedAt: time.Now().Format(time.RFC3339),
Version: "v1",
},
Changes: []Change{{Type: "modified", Category: "files", Path: "/a", Impact: "low"}},
Significance: "low",
}
v := viper.New()
v.Set("format", "json")
// Write to a temp file via -o to exercise file write path
tempDir := t.TempDir()
outPath := filepath.Join(tempDir, "diff.json")
v.Set("output", outPath)
if err := outputDiffResult(result, v); err != nil {
t.Fatalf("outputDiffResult(json) error = %v", err)
}
data, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("failed to read output: %v", err)
}
// Basic JSON sanity checks
s := string(data)
if !strings.Contains(s, "\"summary\"") || !strings.Contains(s, "\"changes\"") {
t.Fatalf("json output missing keys: %s", s)
}
if !strings.Contains(s, "\"path\": \"/a\"") {
t.Fatalf("json output missing change path: %s", s)
}
}
func TestGenerateTextDiffReport_DiffVisibility(t *testing.T) {
result := &DiffResult{
Summary: DiffSummary{TotalChanges: 1, FilesModified: 1},
Changes: []Change{
{
Type: "modified",
Category: "files",
Path: "/path.txt",
Impact: "low",
Details: map[string]interface{}{"diff": "--- old:/path.txt\n+++ new:/path.txt\n@@\n-a\n+b"},
},
},
Metadata: DiffMetadata{GeneratedAt: time.Now().Format(time.RFC3339)},
}
reportShown := generateTextDiffReport(result, true)
if !strings.Contains(reportShown, "Diff:") || !strings.Contains(reportShown, "+ new:/path.txt") {
t.Fatalf("expected diff to be shown when enabled; got: %s", reportShown)
}
reportHidden := generateTextDiffReport(result, false)
if strings.Contains(reportHidden, "Diff:") {
t.Fatalf("expected diff to be hidden when disabled; got: %s", reportHidden)
}
}
func TestCategorizePath(t *testing.T) {
cases := []struct {
in string
out string
}{
{"cluster-resources/pods/logs/ns/pod/container.log", "logs"},
{"some/ns/logs/thing.log", "logs"},
{"all-logs/ns/pod/container.log", "logs"},
{"logs/app.log", "logs"},
{"cluster-resources/configmaps/ns.json", "resources:configmaps"},
{"cluster-resources/", "resources"},
{"config/settings.yaml", "config"},
{"random/file.json", "config"},
{"random/file.txt", "files"},
}
for _, c := range cases {
if got := categorizePath(c.in); got != c.out {
t.Errorf("categorizePath(%q)=%q want %q", c.in, got, c.out)
}
}
}
func TestNormalizePath(t *testing.T) {
cases := []struct {
in string
out string
}{
{"root/foo.txt", "foo.txt"},
{"support-bundle-123/foo.txt", "foo.txt"},
{"Support-Bundle-ABC/bar/baz.txt", "bar/baz.txt"},
{"cluster-resources/pods/logs/whatever.log", "cluster-resources/pods/logs/whatever.log"},
{"all-logs/whatever.log", "all-logs/whatever.log"},
}
for _, c := range cases {
if got := normalizePath(c.in); got != c.out {
t.Errorf("normalizePath(%q)=%q want %q", c.in, got, c.out)
}
}
}
func TestGenerateUnifiedDiff_TruncationAndContext(t *testing.T) {
old := "line1\nline2\nline3\nline4\nline5\n"
newv := "line1\nline2-mod\nline3\nline4\nline5\n"
// context=1 should include headers and minimal context; max lines small to force truncation
diff := generateUnifiedDiff(old, newv, "/file.txt", 1, 5)
if diff == "" {
t.Fatal("expected non-empty diff")
}
if !strings.Contains(diff, "old:/file.txt") || !strings.Contains(diff, "new:/file.txt") {
t.Errorf("diff missing headers: %s", diff)
}
if !strings.Contains(diff, "... (diff truncated)") {
t.Errorf("expected truncated marker in diff: %s", diff)
}
}
func TestFormatSize(t *testing.T) {
tests := []struct {
name string
bytes int64
want string
}{
{
name: "bytes",
bytes: 512,
want: "512 B",
},
{
name: "kilobytes",
bytes: 1536, // 1.5 KB
want: "1.5 KiB",
},
{
name: "megabytes",
bytes: 1572864, // 1.5 MB
want: "1.5 MiB",
},
{
name: "zero",
bytes: 0,
want: "0 B",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatSize(tt.bytes)
if got != tt.want {
t.Errorf("formatSize() = %v, want %v", got, tt.want)
}
})
}
}
func TestCreateTarFileReader(t *testing.T) {
tempDir := t.TempDir()
bundlePath := filepath.Join(tempDir, "test-bundle.tar.gz")
// Create test bundle with text and binary files
testFiles := map[string]string{
"root/text-file.txt": "line1\nline2\nline3\n",
"root/config.yaml": "key: value\nother: data\n",
"root/binary-file.bin": string([]byte{0x00, 0x01, 0x02, 0xFF}), // Binary content
}
if err := writeTarGz(bundlePath, testFiles); err != nil {
t.Fatalf("Failed to create test bundle: %v", err)
}
// Test reading existing text file
reader, err := createTarFileReader(bundlePath, "text-file.txt")
if err != nil {
t.Fatalf("createTarFileReader() error = %v", err)
}
defer reader.Close()
content := make([]byte, 100)
n, err := reader.Read(content)
if err != nil && err != io.EOF {
t.Errorf("Read() error = %v", err)
}
contentStr := string(content[:n])
if !strings.Contains(contentStr, "line1") {
t.Errorf("Expected file content not found, got: %s", contentStr)
}
// Test reading non-existent file
_, err = createTarFileReader(bundlePath, "non-existent.txt")
if err == nil {
t.Error("Expected error for non-existent file")
}
// Test reading binary file (should fail)
_, err = createTarFileReader(bundlePath, "binary-file.bin")
if err == nil {
t.Error("Expected error for binary file")
}
}
func TestGenerateStreamingUnifiedDiff(t *testing.T) {
tempDir := t.TempDir()
oldBundle := filepath.Join(tempDir, "old-bundle.tar.gz")
newBundle := filepath.Join(tempDir, "new-bundle.tar.gz")
// Create bundles with different versions of the same file
if err := writeTarGz(oldBundle, map[string]string{
"root/config.yaml": "version: 1.0\napp: test\nfeature: disabled\n",
}); err != nil {
t.Fatalf("Failed to create old bundle: %v", err)
}
if err := writeTarGz(newBundle, map[string]string{
"root/config.yaml": "version: 2.0\napp: test\nfeature: enabled\n",
}); err != nil {
t.Fatalf("Failed to create new bundle: %v", err)
}
// Generate streaming diff
diff := generateStreamingUnifiedDiff(oldBundle, newBundle, "/config.yaml", 3, 100)
// Verify diff content
if diff == "" {
t.Error("Expected non-empty diff")
}
expectedStrings := []string{
"old:/config.yaml",
"new:/config.yaml",
"-version: 1.0",
"+version: 2.0",
"-feature: disabled",
"+feature: enabled",
}
for _, expected := range expectedStrings {
if !strings.Contains(diff, expected) {
t.Errorf("Diff missing expected string '%s'. Got: %s", expected, diff)
}
}
}
func TestReadLinesFromReader(t *testing.T) {
tests := []struct {
name string
content string
maxBytes int
wantLen int
wantLast string
}{
{
name: "small content",
content: "line1\nline2\nline3\n",
maxBytes: 1000,
wantLen: 3,
wantLast: "line3\n",
},
{
name: "content exceeds limit",
content: "line1\nline2\nline3\nline4\nline5\n",
maxBytes: 15, // Only allows first 2 lines plus truncation marker
wantLen: 3,
wantLast: "... (content truncated due to size)\n",
},
{
name: "empty content",
content: "",
maxBytes: 1000,
wantLen: 0,
wantLast: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := strings.NewReader(tt.content)
lines, err := readLinesFromReader(reader, tt.maxBytes)
if err != nil {
t.Errorf("readLinesFromReader() error = %v", err)
}
if len(lines) != tt.wantLen {
t.Errorf("readLinesFromReader() got %d lines, want %d", len(lines), tt.wantLen)
}
if tt.wantLen > 0 && lines[len(lines)-1] != tt.wantLast {
t.Errorf("readLinesFromReader() last line = %s, want %s", lines[len(lines)-1], tt.wantLast)
}
})
}
}
+239
View File
@@ -0,0 +1,239 @@
package cli
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/pkg/errors"
"k8s.io/klog/v2"
)
// DiscoveryConfig represents the configuration for auto-discovery
type DiscoveryConfig struct {
Version string `json:"version" yaml:"version"`
Profiles map[string]DiscoveryProfile `json:"profiles" yaml:"profiles"`
Patterns DiscoveryPatterns `json:"patterns" yaml:"patterns"`
}
// DiscoveryPatterns defines inclusion/exclusion patterns for discovery
type DiscoveryPatterns struct {
NamespacePatterns PatternConfig `json:"namespacePatterns" yaml:"namespacePatterns"`
ResourceTypePatterns PatternConfig `json:"resourceTypePatterns" yaml:"resourceTypePatterns"`
RegistryPatterns PatternConfig `json:"registryPatterns" yaml:"registryPatterns"`
}
// PatternConfig defines include/exclude patterns
type PatternConfig struct {
Include []string `json:"include" yaml:"include"`
Exclude []string `json:"exclude" yaml:"exclude"`
}
// LoadDiscoveryConfig loads discovery configuration from file or returns defaults
func LoadDiscoveryConfig(configPath string) (*DiscoveryConfig, error) {
// If no config path specified, use built-in defaults
if configPath == "" {
return getDefaultDiscoveryConfig(), nil
}
// Check if config file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
klog.V(2).Infof("Discovery config file not found: %s, using defaults", configPath)
return getDefaultDiscoveryConfig(), nil
}
// Read config file
data, err := ioutil.ReadFile(configPath)
if err != nil {
return nil, errors.Wrap(err, "failed to read discovery config file")
}
// Parse config file (support JSON for now)
var config DiscoveryConfig
if err := json.Unmarshal(data, &config); err != nil {
return nil, errors.Wrap(err, "failed to parse discovery config")
}
// Validate config
if err := validateDiscoveryConfig(&config); err != nil {
return nil, errors.Wrap(err, "invalid discovery config")
}
return &config, nil
}
// getDefaultDiscoveryConfig returns the built-in default configuration
func getDefaultDiscoveryConfig() *DiscoveryConfig {
return &DiscoveryConfig{
Version: "v1",
Profiles: GetDiscoveryProfiles(),
Patterns: DiscoveryPatterns{
NamespacePatterns: PatternConfig{
Include: []string{"*"}, // Include all by default
Exclude: []string{
"kube-system",
"kube-public",
"kube-node-lease",
"kubernetes-dashboard",
"cattle-*",
"rancher-*",
},
},
ResourceTypePatterns: PatternConfig{
Include: []string{
"pods",
"deployments",
"services",
"configmaps",
"secrets",
"events",
},
Exclude: []string{
"*.tmp",
"*.log", // Exclude raw log files in favor of structured logs
},
},
RegistryPatterns: PatternConfig{
Include: []string{"*"}, // Include all registries
Exclude: []string{}, // No exclusions by default
},
},
}
}
// validateDiscoveryConfig validates a discovery configuration
func validateDiscoveryConfig(config *DiscoveryConfig) error {
if config.Version == "" {
config.Version = "v1" // Default version
}
if config.Profiles == nil {
return errors.New("profiles section is required")
}
// Validate each profile
requiredProfiles := []string{"minimal", "standard", "comprehensive"}
for _, profileName := range requiredProfiles {
if _, exists := config.Profiles[profileName]; !exists {
return fmt.Errorf("required profile missing: %s", profileName)
}
}
return nil
}
// ApplyDiscoveryPatterns applies include/exclude patterns to a list
func ApplyDiscoveryPatterns(items []string, patterns PatternConfig) ([]string, error) {
if len(patterns.Include) == 0 && len(patterns.Exclude) == 0 {
return items, nil // No patterns to apply
}
var result []string
for _, item := range items {
include := true
// Check exclude patterns first
for _, excludePattern := range patterns.Exclude {
if matched, err := matchPattern(item, excludePattern); err != nil {
return nil, errors.Wrapf(err, "invalid exclude pattern: %s", excludePattern)
} else if matched {
include = false
break
}
}
// If not excluded, check include patterns
if include && len(patterns.Include) > 0 {
include = false // Default to exclude if include patterns exist
for _, includePattern := range patterns.Include {
if matched, err := matchPattern(item, includePattern); err != nil {
return nil, errors.Wrapf(err, "invalid include pattern: %s", includePattern)
} else if matched {
include = true
break
}
}
}
if include {
result = append(result, item)
}
}
return result, nil
}
// matchPattern checks if an item matches a glob pattern
func matchPattern(item, pattern string) (bool, error) {
// Simple glob pattern matching
if pattern == "*" {
return true, nil
}
if pattern == item {
return true, nil
}
// Handle basic wildcard patterns
if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") {
// Pattern is "*substring*"
substring := pattern[1 : len(pattern)-1]
return strings.Contains(item, substring), nil
}
if strings.HasPrefix(pattern, "*") {
// Pattern is "*suffix"
suffix := pattern[1:]
return strings.HasSuffix(item, suffix), nil
}
if strings.HasSuffix(pattern, "*") {
// Pattern is "prefix*"
prefix := pattern[:len(pattern)-1]
return strings.HasPrefix(item, prefix), nil
}
return false, nil
}
// SaveDiscoveryConfig saves discovery configuration to a file
func SaveDiscoveryConfig(config *DiscoveryConfig, configPath string) error {
// Create directory if it doesn't exist
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return errors.Wrap(err, "failed to create config directory")
}
// Marshal to JSON
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return errors.Wrap(err, "failed to marshal config")
}
// Write to file
if err := ioutil.WriteFile(configPath, data, 0644); err != nil {
return errors.Wrap(err, "failed to write config file")
}
return nil
}
// GetDiscoveryConfigPath returns the default path for discovery configuration
func GetDiscoveryConfigPath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return "./troubleshoot-discovery.json"
}
return filepath.Join(homeDir, ".troubleshoot", "discovery.json")
}
// CreateDefaultDiscoveryConfigFile creates a default discovery config file
func CreateDefaultDiscoveryConfigFile(configPath string) error {
config := getDefaultDiscoveryConfig()
return SaveDiscoveryConfig(config, configPath)
}
@@ -0,0 +1,422 @@
package cli
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadDiscoveryConfig(t *testing.T) {
tests := []struct {
name string
setupConfig func(string) error
configPath string
wantErr bool
checkFunc func(*testing.T, *DiscoveryConfig)
}{
{
name: "no config path - use defaults",
configPath: "",
wantErr: false,
checkFunc: func(t *testing.T, config *DiscoveryConfig) {
if config.Version != "v1" {
t.Errorf("Expected version v1, got %s", config.Version)
}
if len(config.Profiles) == 0 {
t.Error("Default config should have profiles")
}
},
},
{
name: "non-existent config file - use defaults",
configPath: "/non/existent/path.json",
wantErr: false,
checkFunc: func(t *testing.T, config *DiscoveryConfig) {
if config.Version != "v1" {
t.Errorf("Expected version v1, got %s", config.Version)
}
},
},
{
name: "valid config file",
setupConfig: func(path string) error {
configContent := `{
"version": "v1",
"profiles": {
"minimal": {
"name": "minimal",
"description": "Minimal collection",
"includeImages": false,
"rbacCheck": true,
"maxDepth": 1,
"timeout": 15000000000
},
"standard": {
"name": "standard",
"description": "Standard collection",
"includeImages": false,
"rbacCheck": true,
"maxDepth": 2,
"timeout": 30000000000
},
"comprehensive": {
"name": "comprehensive",
"description": "Comprehensive collection",
"includeImages": true,
"rbacCheck": true,
"maxDepth": 3,
"timeout": 60000000000
}
},
"patterns": {
"namespacePatterns": {
"include": ["app-*"],
"exclude": ["kube-*"]
}
}
}`
return os.WriteFile(path, []byte(configContent), 0644)
},
wantErr: false,
checkFunc: func(t *testing.T, config *DiscoveryConfig) {
if config.Version != "v1" {
t.Errorf("Expected version v1, got %s", config.Version)
}
if len(config.Profiles) == 0 {
t.Error("Config should have profiles")
}
},
},
{
name: "invalid json config",
setupConfig: func(path string) error {
return os.WriteFile(path, []byte(`{invalid json`), 0644)
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var configPath string
if tt.configPath != "" {
configPath = tt.configPath
}
// Setup config file if needed
if tt.setupConfig != nil {
tempDir := t.TempDir()
configPath = filepath.Join(tempDir, "config.json")
if err := tt.setupConfig(configPath); err != nil {
t.Fatalf("Failed to setup config: %v", err)
}
}
config, err := LoadDiscoveryConfig(configPath)
if (err != nil) != tt.wantErr {
t.Errorf("LoadDiscoveryConfig() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && tt.checkFunc != nil {
tt.checkFunc(t, config)
}
})
}
}
func TestApplyDiscoveryPatterns(t *testing.T) {
tests := []struct {
name string
items []string
patterns PatternConfig
want []string
}{
{
name: "no patterns",
items: []string{"app1", "app2", "kube-system"},
patterns: PatternConfig{
Include: []string{},
Exclude: []string{},
},
want: []string{"app1", "app2", "kube-system"},
},
{
name: "exclude patterns only",
items: []string{"app1", "app2", "kube-system", "kube-public"},
patterns: PatternConfig{
Include: []string{},
Exclude: []string{"kube-*"},
},
want: []string{"app1", "app2"},
},
{
name: "include patterns only",
items: []string{"app1", "app2", "kube-system"},
patterns: PatternConfig{
Include: []string{"app*"},
Exclude: []string{},
},
want: []string{"app1", "app2"},
},
{
name: "include and exclude patterns",
items: []string{"app1", "app2", "app-system", "kube-system"},
patterns: PatternConfig{
Include: []string{"app*"},
Exclude: []string{"*system"},
},
want: []string{"app1", "app2"}, // app-system excluded, kube-system not included
},
{
name: "exact match patterns",
items: []string{"app1", "app2", "special"},
patterns: PatternConfig{
Include: []string{"special", "app1"},
Exclude: []string{},
},
want: []string{"app1", "special"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ApplyDiscoveryPatterns(tt.items, tt.patterns)
if err != nil {
t.Errorf("ApplyDiscoveryPatterns() error = %v", err)
return
}
if len(got) != len(tt.want) {
t.Errorf("ApplyDiscoveryPatterns() length = %v, want %v", len(got), len(tt.want))
return
}
// Check that all expected items are present
gotMap := make(map[string]bool)
for _, item := range got {
gotMap[item] = true
}
for _, wantItem := range tt.want {
if !gotMap[wantItem] {
t.Errorf("ApplyDiscoveryPatterns() missing expected item: %s", wantItem)
}
}
})
}
}
func TestMatchPattern(t *testing.T) {
tests := []struct {
name string
item string
pattern string
want bool
wantErr bool
}{
{
name: "exact match",
item: "app1",
pattern: "app1",
want: true,
},
{
name: "wildcard all",
item: "anything",
pattern: "*",
want: true,
},
{
name: "prefix wildcard",
item: "app-namespace",
pattern: "app*",
want: true,
},
{
name: "suffix wildcard",
item: "kube-system",
pattern: "*system",
want: true,
},
{
name: "substring wildcard",
item: "my-app-namespace",
pattern: "*app*",
want: true,
},
{
name: "no match",
item: "different",
pattern: "app*",
want: false,
},
{
name: "prefix no match",
item: "other-app",
pattern: "app*",
want: false,
},
{
name: "suffix no match",
item: "system-app",
pattern: "*system",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := matchPattern(tt.item, tt.pattern)
if (err != nil) != tt.wantErr {
t.Errorf("matchPattern() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("matchPattern() = %v, want %v", got, tt.want)
}
})
}
}
func TestSaveDiscoveryConfig(t *testing.T) {
config := getDefaultDiscoveryConfig()
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "test-config.json")
err := SaveDiscoveryConfig(config, configPath)
if err != nil {
t.Fatalf("SaveDiscoveryConfig() error = %v", err)
}
// Verify file was created
if _, err := os.Stat(configPath); os.IsNotExist(err) {
t.Error("SaveDiscoveryConfig() did not create config file")
}
// Verify we can load it back
loadedConfig, err := LoadDiscoveryConfig(configPath)
if err != nil {
t.Fatalf("Failed to reload saved config: %v", err)
}
if loadedConfig.Version != config.Version {
t.Errorf("Reloaded config version = %v, want %v", loadedConfig.Version, config.Version)
}
}
func TestGetDiscoveryConfigPath(t *testing.T) {
path := GetDiscoveryConfigPath()
if path == "" {
t.Error("GetDiscoveryConfigPath() should not return empty string")
}
// Should end with expected filename
expectedSuffix := "discovery.json"
if !strings.HasSuffix(path, expectedSuffix) {
t.Errorf("GetDiscoveryConfigPath() should end with %s, got %s", expectedSuffix, path)
}
}
func TestValidateDiscoveryConfig(t *testing.T) {
tests := []struct {
name string
config *DiscoveryConfig
wantErr bool
}{
{
name: "valid config",
config: &DiscoveryConfig{
Version: "v1",
Profiles: GetDiscoveryProfiles(),
},
wantErr: false,
},
{
name: "missing version gets default",
config: &DiscoveryConfig{
Profiles: GetDiscoveryProfiles(),
},
wantErr: false,
},
{
name: "nil profiles",
config: &DiscoveryConfig{
Version: "v1",
Profiles: nil,
},
wantErr: true,
},
{
name: "missing required profile",
config: &DiscoveryConfig{
Version: "v1",
Profiles: map[string]DiscoveryProfile{
"custom": {Name: "custom"},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateDiscoveryConfig(tt.config)
if (err != nil) != tt.wantErr {
t.Errorf("validateDiscoveryConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func BenchmarkMatchPattern(b *testing.B) {
testCases := []struct {
item string
pattern string
}{
{"app-namespace", "app*"},
{"kube-system", "*system"},
{"my-app-test", "*app*"},
{"exact-match", "exact-match"},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, tc := range testCases {
_, err := matchPattern(tc.item, tc.pattern)
if err != nil {
b.Fatalf("matchPattern failed: %v", err)
}
}
}
}
func BenchmarkApplyDiscoveryPatterns(b *testing.B) {
items := make([]string, 100)
for i := 0; i < 100; i++ {
if i%3 == 0 {
items[i] = fmt.Sprintf("app-%d", i)
} else if i%3 == 1 {
items[i] = fmt.Sprintf("kube-system-%d", i)
} else {
items[i] = fmt.Sprintf("other-%d", i)
}
}
patterns := PatternConfig{
Include: []string{"app*", "other*"},
Exclude: []string{"*system*"},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := ApplyDiscoveryPatterns(items, patterns)
if err != nil {
b.Fatalf("ApplyDiscoveryPatterns failed: %v", err)
}
}
}
+37
View File
@@ -9,6 +9,7 @@ import (
"github.com/replicatedhq/troubleshoot/internal/traces"
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
"github.com/replicatedhq/troubleshoot/pkg/logger"
"github.com/replicatedhq/troubleshoot/pkg/updater"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"k8s.io/klog/v2"
@@ -40,6 +41,25 @@ If no arguments are provided, specs are automatically loaded from the cluster by
if err := util.StartProfiling(); err != nil {
klog.Errorf("Failed to start profiling: %v", err)
}
// Auto-update support-bundle unless disabled by flag or env
envAuto := os.Getenv("TROUBLESHOOT_AUTO_UPDATE")
autoFromEnv := true
if envAuto != "" {
if strings.EqualFold(envAuto, "0") || strings.EqualFold(envAuto, "false") {
autoFromEnv = false
}
}
if v.GetBool("auto-update") && autoFromEnv {
exe, err := os.Executable()
if err == nil {
_ = updater.CheckAndUpdate(cmd.Context(), updater.Options{
BinaryName: "support-bundle",
CurrentPath: exe,
Printf: func(f string, a ...interface{}) { fmt.Fprintf(os.Stderr, f, a...) },
})
}
}
},
RunE: func(cmd *cobra.Command, args []string) error {
v := viper.GetViper()
@@ -82,6 +102,8 @@ If no arguments are provided, specs are automatically loaded from the cluster by
cmd.AddCommand(Analyze())
cmd.AddCommand(Redact())
cmd.AddCommand(Diff())
cmd.AddCommand(UploadCmd())
cmd.AddCommand(util.VersionCmd())
cmd.Flags().StringSlice("redactors", []string{}, "names of the additional redactors to use")
@@ -95,6 +117,21 @@ If no arguments are provided, specs are automatically loaded from the cluster by
cmd.Flags().StringP("output", "o", "", "specify the output file path for the support bundle")
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")
// Auto-upload flags
cmd.Flags().Bool("auto-upload", false, "automatically upload bundle after generation (auto-detects license and app from bundle)")
cmd.Flags().String("license-id", "", "license ID for upload (auto-detected from bundle if not provided)")
cmd.Flags().String("app-slug", "", "application slug for upload (auto-detected from bundle if not provided)")
// Auto-discovery flags
cmd.Flags().Bool("auto", false, "enable auto-discovery of foundational collectors. When used with YAML specs, adds foundational collectors to YAML collectors. When used alone, collects only foundational data")
cmd.Flags().Bool("include-images", false, "include container image metadata collection when using auto-discovery")
cmd.Flags().Bool("rbac-check", true, "enable RBAC permission checking for auto-discovered collectors")
cmd.Flags().String("discovery-profile", "standard", "auto-discovery profile: minimal, standard, comprehensive, or paranoid")
cmd.Flags().StringSlice("exclude-namespaces", []string{}, "namespaces to exclude from auto-discovery (supports glob patterns)")
cmd.Flags().StringSlice("include-namespaces", []string{}, "namespaces to include in auto-discovery (supports glob patterns). If specified, only these namespaces will be included")
cmd.Flags().Bool("include-system-namespaces", false, "include system namespaces (kube-system, etc.) in auto-discovery")
// hidden in favor of the `insecure-skip-tls-verify` flag
cmd.Flags().Bool("allow-insecure-connections", false, "when set, do not verify TLS certs when retrieving spec and reporting results")
+61 -7
View File
@@ -55,6 +55,26 @@ func runTroubleshoot(v *viper.Viper, args []string) error {
return err
}
// Validate auto-discovery flags
if err := ValidateAutoDiscoveryFlags(v); err != nil {
return errors.Wrap(err, "invalid auto-discovery configuration")
}
// Apply auto-discovery if enabled
autoConfig := GetAutoDiscoveryConfig(v)
if autoConfig.Enabled {
mode := GetAutoDiscoveryMode(args, autoConfig.Enabled)
if !v.GetBool("quiet") {
PrintAutoDiscoveryInfo(autoConfig, mode)
}
// Apply auto-discovery to the main bundle
namespace := v.GetString("namespace")
if err := ApplyAutoDiscovery(ctx, client, restConfig, mainBundle, autoConfig, namespace); err != nil {
return errors.Wrap(err, "auto-discovery failed")
}
}
// For --dry-run, we want to print the yaml and exit
if v.GetBool("dry-run") {
k := loader.TroubleshootKinds{
@@ -165,9 +185,9 @@ func runTroubleshoot(v *viper.Viper, args []string) error {
}
case <-time.After(time.Millisecond * 100):
if currentDir == "" {
fmt.Printf("\r%s \033[36mCollecting support bundle\033[m %s", cursor.ClearEntireLine(), s.Next())
fmt.Printf("\r%s \u001b[36mCollecting support bundle\u001b[m %s", cursor.ClearEntireLine(), s.Next())
} else {
fmt.Printf("\r%s \033[36mCollecting support bundle\033[m %s %s", cursor.ClearEntireLine(), s.Next(), currentDir)
fmt.Printf("\r%s \u001b[36mCollecting support bundle\u001b[m %s %s", cursor.ClearEntireLine(), s.Next(), currentDir)
}
}
}
@@ -197,6 +217,18 @@ func runTroubleshoot(v *viper.Viper, args []string) error {
close(progressChan) // this removes the spinner in interactive mode
isProgressChanClosed = true
// Auto-upload if requested
if v.GetBool("auto-upload") {
licenseID := v.GetString("license-id")
appSlug := v.GetString("app-slug")
fmt.Fprintf(os.Stderr, "Auto-uploading bundle to replicated.app...\n")
if err := supportbundle.UploadBundleAutoDetect(response.ArchivePath, licenseID, appSlug); err != nil {
fmt.Fprintf(os.Stderr, "Auto-upload failed: %v\n", err)
fmt.Fprintf(os.Stderr, "You can manually upload the bundle using: support-bundle upload %s\n", response.ArchivePath)
}
}
if len(response.AnalyzerResults) > 0 {
if interactive {
if err := showInteractiveResults(mainBundle.Name, response.AnalyzerResults, response.ArchivePath); err != nil {
@@ -314,10 +346,12 @@ func loadSpecs(ctx context.Context, args []string, client kubernetes.Interface)
}
// Check if we have any collectors to run in the troubleshoot specs
// TODO: Do we use the RemoteCollectors anymore?
// Skip this check if auto-discovery is enabled, as collectors will be added later
// Note: RemoteCollectors are still actively used in preflights and host preflights
if len(kinds.CollectorsV1Beta2) == 0 &&
len(kinds.HostCollectorsV1Beta2) == 0 &&
len(kinds.SupportBundlesV1Beta2) == 0 {
len(kinds.SupportBundlesV1Beta2) == 0 &&
!vp.GetBool("auto") {
return nil, nil, types.NewExitCodeError(
constants.EXIT_CODE_CATCH_ALL,
errors.New("no collectors specified to run. Use --debug and/or -v=2 to see more information"),
@@ -337,6 +371,25 @@ func loadSpecs(ctx context.Context, args []string, client kubernetes.Interface)
},
}
// If auto-discovery is enabled and no support bundle specs were loaded,
// create a minimal default support bundle spec for auto-discovery to work with
if vp.GetBool("auto") && len(kinds.SupportBundlesV1Beta2) == 0 {
defaultSupportBundle := troubleshootv1beta2.SupportBundle{
TypeMeta: metav1.TypeMeta{
APIVersion: "troubleshoot.replicated.com/v1beta2",
Kind: "SupportBundle",
},
ObjectMeta: metav1.ObjectMeta{
Name: "auto-discovery-default",
},
Spec: troubleshootv1beta2.SupportBundleSpec{
Collectors: []*troubleshootv1beta2.Collect{}, // Empty collectors - will be populated by auto-discovery
},
}
kinds.SupportBundlesV1Beta2 = append(kinds.SupportBundlesV1Beta2, defaultSupportBundle)
klog.V(2).Info("Created default support bundle spec for auto-discovery")
}
var enableRunHostCollectorsInPod bool
for _, sb := range kinds.SupportBundlesV1Beta2 {
@@ -357,8 +410,9 @@ func loadSpecs(ctx context.Context, args []string, client kubernetes.Interface)
mainBundle.Spec.HostCollectors = util.Append(mainBundle.Spec.HostCollectors, hc.Spec.Collectors)
}
if !(len(mainBundle.Spec.HostCollectors) > 0 && len(mainBundle.Spec.Collectors) == 0) {
// Always add default collectors unless we only have host collectors
// Don't add default collectors if auto-discovery is enabled, as auto-discovery will add them
if !(len(mainBundle.Spec.HostCollectors) > 0 && len(mainBundle.Spec.Collectors) == 0) && !vp.GetBool("auto") {
// Always add default collectors unless we only have host collectors or auto-discovery is enabled
// We need to add them here so when we --dry-run, these collectors
// are included. supportbundle.runCollectors duplicates this bit.
// We'll need to refactor it out later when its clearer what other
@@ -375,7 +429,7 @@ func loadSpecs(ctx context.Context, args []string, client kubernetes.Interface)
additionalRedactors := &troubleshootv1beta2.Redactor{
TypeMeta: metav1.TypeMeta{
APIVersion: "troubleshoot.sh/v1beta2",
APIVersion: "troubleshoot.replicated.com/v1beta2",
Kind: "Redactor",
},
ObjectMeta: metav1.ObjectMeta{
+3 -2
View File
@@ -140,10 +140,11 @@ func Test_loadSupportBundleSpecsFromURIs_TimeoutError(t *testing.T) {
})
require.NoError(t, err)
// Set the timeout on the http client to 10ms
// Set the timeout on the http client to 500ms
// The server sleeps for 2 seconds, so this should still timeout
// supportbundle.LoadSupportBundleSpec does not yet use the context
before := httputil.GetHttpClient().Timeout
httputil.GetHttpClient().Timeout = 10 * time.Millisecond
httputil.GetHttpClient().Timeout = 500 * time.Millisecond
defer func() {
// Reinstate the original timeout. Its a global var so we need to reset it
httputil.GetHttpClient().Timeout = before
+56
View File
@@ -0,0 +1,56 @@
package cli
import (
"os"
"github.com/pkg/errors"
"github.com/replicatedhq/troubleshoot/pkg/supportbundle"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func UploadCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "upload [bundle-file]",
Args: cobra.ExactArgs(1),
Short: "Upload a support bundle to replicated.app",
Long: `Upload a support bundle to replicated.app for analysis and troubleshooting.
This command automatically extracts the license ID and app slug from the bundle if not provided.
Examples:
# Auto-detect license and app from bundle
support-bundle upload bundle.tar.gz
# Specify license ID explicitly
support-bundle upload bundle.tar.gz --license-id YOUR_LICENSE_ID
# Specify both license and app
support-bundle upload bundle.tar.gz --license-id YOUR_LICENSE_ID --app-slug my-app`,
RunE: func(cmd *cobra.Command, args []string) error {
v := viper.GetViper()
bundlePath := args[0]
// Check if bundle file exists
if _, err := os.Stat(bundlePath); os.IsNotExist(err) {
return errors.Errorf("bundle file does not exist: %s", bundlePath)
}
// Get upload parameters
licenseID := v.GetString("license-id")
appSlug := v.GetString("app-slug")
// Use auto-detection for uploads
if err := supportbundle.UploadBundleAutoDetect(bundlePath, licenseID, appSlug); err != nil {
return errors.Wrap(err, "upload failed")
}
return nil
},
}
cmd.Flags().String("license-id", "", "license ID for authentication (auto-detected from bundle if not provided)")
cmd.Flags().String("app-slug", "", "application slug (auto-detected from bundle if not provided)")
return cmd
}
+113 -87
View File
@@ -1,85 +1,64 @@
version: 2
project_name: troubleshoot
release:
prerelease: auto
builds:
- id: preflight
# NOTE: if you add any additional goos/goarch values, ensure you update ../.github/workflows/build-test-deploy.yaml
# specifically the matrix values for goreleaser-test
goos:
- linux
- darwin
goarch:
- amd64
- arm
- arm64
- riscv64
main: ./cmd/preflight/main.go
env: [CGO_ENABLED=0]
goos: [linux, darwin]
goarch: [amd64, arm, arm64, riscv64]
ignore:
- goos: windows
goarch: arm
env:
- CGO_ENABLED=0
main: cmd/preflight/main.go
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
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: preflight
hooks: {}
- id: support-bundle
goos:
- linux
- darwin
goarch:
- amd64
- arm
- arm64
- riscv64
main: ./cmd/troubleshoot/main.go
env: [CGO_ENABLED=0]
goos: [linux, darwin]
goarch: [amd64, arm, arm64, riscv64]
ignore:
- goos: windows
goarch: arm
env:
- CGO_ENABLED=0
main: cmd/troubleshoot/main.go
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
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: support-bundle
hooks: {}
- id: collect
goos:
- linux
- darwin
goarch:
- amd64
- arm
- arm64
- riscv64
ignore:
- goos: windows
goarch: arm
env:
- CGO_ENABLED=0
main: cmd/collect/main.go
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
hooks: {}
archives:
- id: preflight
builds:
- preflight
format: tar.gz
ids: [preflight]
formats: [tar.gz]
format_overrides:
- goos: windows
format: zip
name_template: 'preflight_{{ .Os }}_{{ .Arch }}'
formats: [zip]
name_template: "preflight_{{ .Os }}_{{ .Arch }}"
files:
- licence*
- LICENCE*
@@ -89,17 +68,16 @@ archives:
- README*
- changelog*
- CHANGELOG*
- src: 'sbom/assets/*'
- src: "sbom/assets/*"
dst: .
strip_parent: true # this is needed to make up for the way unzips work in krew v0.4.1
strip_parent: true
- id: support-bundle
builds:
- support-bundle
format: tar.gz
ids: [support-bundle]
formats: [tar.gz]
format_overrides:
- goos: windows
format: zip
name_template: 'support-bundle_{{ .Os }}_{{ .Arch }}'
formats: [zip]
name_template: "support-bundle_{{ .Os }}_{{ .Arch }}"
files:
- licence*
- LICENCE*
@@ -109,17 +87,14 @@ archives:
- README*
- changelog*
- CHANGELOG*
- src: 'sbom/assets/*'
- src: "sbom/assets/*"
dst: .
strip_parent: true # this is needed to make up for the way unzips work in krew v0.4.1
- id: collect
builds:
- collect
format: tar.gz
format_overrides:
- goos: windows
format: zip
name_template: 'collect_{{ .Os }}_{{ .Arch }}'
strip_parent: true
- id: preflight-universal
ids: [preflight-universal]
formats: [tar.gz]
name_template: "preflight_{{ .Os }}_{{ .Arch }}"
files:
- licence*
- LICENCE*
@@ -129,9 +104,27 @@ archives:
- README*
- changelog*
- CHANGELOG*
- src: 'sbom/assets/*'
- src: "sbom/assets/*"
dst: .
strip_parent: true # this is needed to make up for the way unzips work in krew v0.4.1
strip_parent: true
- id: support-bundle-universal
ids: [support-bundle-universal]
formats: [tar.gz]
name_template: "support-bundle_{{ .Os }}_{{ .Arch }}"
files:
- licence*
- LICENCE*
- license*
- LICENSE*
- readme*
- README*
- changelog*
- CHANGELOG*
- src: "sbom/assets/*"
dst: .
strip_parent: true
dockers:
- dockerfile: ./deploy/Dockerfile.troubleshoot
image_templates:
@@ -142,7 +135,7 @@ dockers:
ids:
- support-bundle
- preflight
- collect
skip_push: true
- dockerfile: ./deploy/Dockerfile.troubleshoot
image_templates:
- "replicated/preflight:latest"
@@ -152,4 +145,37 @@ dockers:
ids:
- support-bundle
- preflight
- collect
skip_push: true
universal_binaries:
- id: preflight-universal
ids: [preflight] # refers to the build id above
replace: true
name_template: preflight
- id: support-bundle-universal
ids: [support-bundle] # refers to the build id above
replace: true
name_template: support-bundle
brews:
- name: preflight
ids: [preflight, preflight-universal]
homepage: https://docs.replicated.com/reference/preflight-overview/
description: "A preflight checker and conformance test for Kubernetes clusters."
repository:
owner: replicatedhq
name: homebrew-replicated
branch: main
directory: HomebrewFormula
install: bin.install "preflight"
- name: support-bundle
ids: [support-bundle, support-bundle-universal]
homepage: https://docs.replicated.com/reference/support-bundle-overview/
description: "Collect and redact support bundles for Kubernetes clusters."
repository:
owner: replicatedhq
name: homebrew-replicated
branch: main
directory: HomebrewFormula
install: bin.install "support-bundle"
-1
View File
@@ -7,7 +7,6 @@ RUN apt-get -qq update \
COPY support-bundle /troubleshoot/support-bundle
COPY preflight /troubleshoot/preflight
COPY collect /troubleshoot/collect
ENV PATH="/troubleshoot:${PATH}"
+1427
View File
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -1,4 +1,4 @@
## preflight
## preflight
Run and retrieve preflight checks in a cluster
@@ -17,7 +17,8 @@ preflight [url] [flags]
--as string Username to impersonate for the operation. User could be a regular user or a service account in a namespace.
--as-group stringArray Group to impersonate for the operation, this flag can be repeated to specify multiple groups.
--as-uid string UID to impersonate for the operation.
--cache-dir string Default cache directory (default "$HOME/.kube/cache")
--auto-update enable automatic binary self-update check and install (default true)
--cache-dir string Default cache directory (default "/Users/marccampbell/.kube/cache")
--certificate-authority string Path to a cert file for the certificate authority
--client-certificate string Path to a client certificate file for TLS
--client-key string Path to a client key file for TLS
@@ -52,7 +53,9 @@ preflight [url] [flags]
### SEE ALSO
* [preflight oci-fetch](preflight_oci-fetch.md) - Fetch a preflight from an OCI registry and print it to standard out
* [preflight version](preflight_version.md) - Print the current version and exit
* [preflight oci-fetch](preflight_oci-fetch.md) - Fetch a preflight from an OCI registry and print it to standard out
* [preflight template](preflight_template.md) - Render a templated preflight spec with values
* [preflight docs](preflight_docs.md) - Extract and display documentation from a preflight spec
* [preflight version](preflight_version.md) - Print the current version and exit
###### Auto generated by spf13/cobra on 23-Aug-2024
###### Auto generated by spf13/cobra on 15-Sep-2025
+60
View File
@@ -0,0 +1,60 @@
## preflight docs
Extract and display documentation from a preflight spec
### Synopsis
Extract all `docString` fields from enabled analyzers in one or more preflight YAML files. Templating is evaluated first using the provided values, so only documentation for analyzers that are enabled is emitted. The output is Markdown.
```
preflight docs [preflight-file...] [flags]
```
### Examples
```
# Extract docs with defaults
preflight docs ml-platform-preflight.yaml
# Multiple specs with values files (later values override earlier ones)
preflight docs spec1.yaml spec2.yaml \
--values values-base.yaml --values values-prod.yaml
# Inline overrides (Helm-style --set)
preflight docs ml-platform-preflight.yaml \
--set monitoring.enabled=true --set ingress.enabled=false
# Save to file
preflight docs ml-platform-preflight.yaml -o requirements.md
```
### Options
```
--values stringArray Path to YAML files containing template values (can be used multiple times)
--set stringArray Set template values on the command line (can be used multiple times)
-o, --output string Output file (default: stdout)
```
### Behavior
- Accepts one or more preflight specs; all are rendered, and their docStrings are concatenated in input order.
- Values merge: deep-merged left-to-right across `--values` files. `--set` overrides win last.
- Rendering engine:
- If a spec references `.Values`, it is rendered with the Helm engine; otherwise Go text/template is used. A fallback to the legacy engine is applied for mixed templates.
- Map normalization: values maps are normalized to `map[string]interface{}` before applying `--set` to avoid type errors.
- Markdown formatting:
- The first line starting with `Title:` in a `docString` becomes a Markdown heading.
- If no `Title:` is present, the analyzer (or requirement) name is used.
- Sections are separated by blank lines.
### v1beta3 docString extraction
- v1beta3 layout uses `spec.analyzers: [...]`.
- Each analyzer may include a sibling `docString` string.
- The docs command extracts `spec.analyzers[*].docString` after rendering.
- Backward compatibility: legacy `requirements` blocks are still supported and extracted when present.
### SEE ALSO
* [preflight](preflight.md) - Run and retrieve preflight checks in a cluster
+1 -1
View File
@@ -34,4 +34,4 @@ preflight oci-fetch [URI] [flags]
* [preflight](preflight.md) - Run and retrieve preflight checks in a cluster
###### Auto generated by spf13/cobra on 23-Aug-2024
###### Auto generated by spf13/cobra on 15-Sep-2025
+56
View File
@@ -0,0 +1,56 @@
## preflight template
Render a templated preflight spec with values
### Synopsis
Process a templated preflight YAML file, substituting variables and removing conditional sections based on provided values. Supports multiple values files and inline overrides. Outputs the fully-resolved YAML (no conditional logic remains).
```
preflight template [template-file] [flags]
```
### Examples
```
# Render with defaults only
preflight template sample-preflight-templated.yaml
# Render with multiple values files (later files override earlier ones)
preflight template sample-preflight-templated.yaml \
--values values-base.yaml --values values-prod.yaml
# Inline overrides (Helm-style --set)
preflight template sample-preflight-templated.yaml \
--set kubernetes.minVersion=v1.24.0 --set storage.enabled=true
# Save to file
preflight template sample-preflight-templated.yaml -o rendered.yaml
```
### Options
```
--values stringArray Path to YAML files containing template values (can be used multiple times)
--set stringArray Set template values on the command line (can be used multiple times)
-o, --output string Output file (default: stdout)
```
### Behavior
- Values merge: deep-merged left-to-right across multiple `--values` files. `--set` overrides win last.
- Rendering engine:
- v1beta3 specs (Helm-style templates using `.Values.*`) are rendered with the Helm engine.
- Legacy templates are rendered with Go text/template; mixed templates are supported.
- Map normalization: values files are normalized to `map[string]interface{}` before applying `--set` (avoids type errors when merging Helm `strvals`).
### v1beta3 spec decisions
- Layout aligns with v1beta2: `spec.analyzers: [...]`.
- Each analyzer accepts an optional `docString` used by `preflight docs`.
- Templating style is Helm-oriented (`.Values.*`).
- Modularity via conditional analyzers is supported, e.g. `{{- if .Values.ingress.enabled }}`.
### SEE ALSO
* [preflight](preflight.md) - Run and retrieve preflight checks in a cluster
+1 -1
View File
@@ -37,4 +37,4 @@ preflight version [flags]
* [preflight](preflight.md) - Run and retrieve preflight checks in a cluster
###### Auto generated by spf13/cobra on 23-Aug-2024
###### Auto generated by spf13/cobra on 15-Sep-2025
+13 -3
View File
@@ -25,7 +25,9 @@ support-bundle [urls...] [flags]
--as string Username to impersonate for the operation. User could be a regular user or a service account in a namespace.
--as-group stringArray Group to impersonate for the operation, this flag can be repeated to specify multiple groups.
--as-uid string UID to impersonate for the operation.
--cache-dir string Default cache directory (default "$HOME/.kube/cache")
--auto enable auto-discovery of foundational collectors. When used with YAML specs, adds foundational collectors to YAML collectors. When used alone, collects only foundational data
--auto-update enable automatic binary self-update check and install (default true)
--cache-dir string Default cache directory (default "/Users/marccampbell/.kube/cache")
--certificate-authority string Path to a cert file for the certificate authority
--client-certificate string Path to a client certificate file for TLS
--client-key string Path to a client key file for TLS
@@ -35,16 +37,22 @@ support-bundle [urls...] [flags]
--cpuprofile string File path to write cpu profiling data
--debug enable debug logging. This is equivalent to --v=0
--disable-compression If true, opt-out of response compression for all requests to the server
--discovery-profile string auto-discovery profile: minimal, standard, comprehensive, or paranoid (default "standard")
--dry-run print support bundle spec without collecting anything
--exclude-namespaces strings namespaces to exclude from auto-discovery (supports glob patterns)
-h, --help help for support-bundle
--include-images include container image metadata collection when using auto-discovery
--include-namespaces strings namespaces to include in auto-discovery (supports glob patterns). If specified, only these namespaces will be included
--include-system-namespaces include system namespaces (kube-system, etc.) in auto-discovery
--insecure-skip-tls-verify If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure
--interactive enable/disable interactive mode (default true)
--kubeconfig string Path to the kubeconfig file to use for CLI requests.
--load-cluster-specs enable/disable loading additional troubleshoot specs found within the cluster. This is the default behavior if no spec is provided as an argument
--load-cluster-specs enable/disable loading additional troubleshoot specs found within the cluster. Do not load by default unless no specs are provided in the cli args
--memprofile string File path to write memory profiling data
-n, --namespace string If present, the namespace scope for this CLI request
--no-uri When this flag is used, Troubleshoot does not attempt to retrieve the spec referenced by the uri: field`
-o, --output string specify the output file path for the support bundle
--rbac-check enable RBAC permission checking for auto-discovered collectors (default true)
--redact enable/disable default redactions (default true)
--redactors strings names of the additional redactors to use
--request-timeout string The length of time to wait before giving up on a single server request. Non-zero values should contain a corresponding time unit (e.g. 1s, 2m, 3h). A value of zero means don't timeout requests. (default "0")
@@ -61,7 +69,9 @@ support-bundle [urls...] [flags]
### SEE ALSO
* [support-bundle analyze](support-bundle_analyze.md) - analyze a support bundle
* [support-bundle diff](support-bundle_diff.md) - Compare two support bundles and identify changes
* [support-bundle redact](support-bundle_redact.md) - Redact information from a generated support bundle archive
* [support-bundle diff](support-bundle_diff.md) - Compare two support bundles and identify changes
* [support-bundle version](support-bundle_version.md) - Print the current version and exit
###### Auto generated by spf13/cobra on 23-Aug-2024
###### Auto generated by spf13/cobra on 15-Sep-2025
+1 -1
View File
@@ -30,4 +30,4 @@ support-bundle analyze [url] [flags]
* [support-bundle](support-bundle.md) - Generate a support bundle from a Kubernetes cluster or specified sources
###### Auto generated by spf13/cobra on 23-Aug-2024
###### Auto generated by spf13/cobra on 15-Sep-2025
+54
View File
@@ -0,0 +1,54 @@
## support-bundle diff
Compare two support bundles and identify changes
### Synopsis
Compare two support bundle archives to identify changes over time. The command outputs a human-readable report by default and can also emit machine-readable JSON.
```
support-bundle diff <old-bundle.(tar.gz|tgz)> <new-bundle.(tar.gz|tgz)> [flags]
```
### Options
```
--diff-context int number of context lines to include around changes in unified diffs (default 3)
-h, --help help for diff
--hide-inline-diffs hide inline unified diffs in the report
--include-log-diffs include inline diffs for log files as well
--max-diff-files int maximum number of files to include inline diffs for; additional modified files will omit inline diffs (default 50)
--max-diff-lines int maximum total lines to include in an inline diff for a single file (default 200)
-o, --output string file path of where to save the diff report (default prints to stdout)
--format string output format; set to 'json' to emit machine-readable JSON to stdout or -o
```
### Notes
- Only `.tar.gz` bundles are supported.
- Inline diffs are generated for text files up to an internal size cap and for a limited number of files (configurable with `--max-diff-files`).
### Examples
```
# Human-readable diff to stdout
support-bundle diff old.tgz new.tgz
# JSON output to a file
support-bundle diff old.tgz new.tgz --format=json -o diff.json
# Human-readable report with more context lines, written to a file
support-bundle diff old.tgz new.tgz --diff-context=5 -o report.txt
```
### Options inherited from parent commands
```
--cpuprofile string File path to write cpu profiling data
--memprofile string File path to write memory profiling data
```
### SEE ALSO
* [support-bundle](support-bundle.md) - Generate a support bundle from a Kubernetes cluster or specified sources
###### Auto generated by spf13/cobra on 15-Sep-2025
+1 -1
View File
@@ -39,4 +39,4 @@ support-bundle redact [urls...] [flags]
* [support-bundle](support-bundle.md) - Generate a support bundle from a Kubernetes cluster or specified sources
###### Auto generated by spf13/cobra on 23-Aug-2024
###### Auto generated by spf13/cobra on 15-Sep-2025
+1 -1
View File
@@ -27,4 +27,4 @@ support-bundle version [flags]
* [support-bundle](support-bundle.md) - Generate a support bundle from a Kubernetes cluster or specified sources
###### Auto generated by spf13/cobra on 23-Aug-2024
###### Auto generated by spf13/cobra on 15-Sep-2025
+474
View File
@@ -0,0 +1,474 @@
## Writing modular, templated Preflight specs (v1beta3 style)
This guide shows how to author preflight YAML specs in a modular, values-driven style like `v1beta3.yaml`. The goal is to keep checks self-documenting, easy to toggle on/off, and customizable via values files or inline `--set` flags.
### Core structure
- **Header**
- `apiVersion`: `troubleshoot.sh/v1beta3`
- `kind`: `Preflight`
- `metadata.name`: a short, stable identifier
- **Spec**
- `spec.analyzers`: list of checks (analyzers)
- Each analyzer is optionally guarded by templating conditionals (e.g., `{{- if .Values.kubernetes.enabled }}`)
- A `docString` accompanies each analyzer, describing the requirement, why it matters, and any links
### Use templating and values
The examples use Go templates with the standard Sprig function set. Values can be supplied by files (`--values`) and/or inline overrides (`--set`), and accessed in templates via `.Values`.
- **Toggling sections**: wrap analyzer blocks in conditionals tied to values.
```yaml
{{- if .Values.storageClass.enabled }}
- docString: |
Title: Default StorageClass Requirements
Requirement:
- A StorageClass named "{{ .Values.storageClass.className }}" must exist
Default StorageClass enables dynamic PVC provisioning without manual intervention.
storageClass:
checkName: Default StorageClass
storageClassName: '{{ .Values.storageClass.className }}'
outcomes:
- fail:
message: Default StorageClass not found
- pass:
message: Default StorageClass present
{{- end }}
```
- **Values**: template expressions directly use values from your values files.
```yaml
{{ .Values.clusterVersion.minVersion }}
```
- **Nested conditionals**: further constrain checks (e.g., only when a specific CRD is required).
```yaml
{{- if .Values.crd.enabled }}
- docString: |
Title: Required CRD Presence
Requirement:
- CRD must exist: {{ .Values.crd.name }}
The application depends on this CRD for controllers to reconcile desired state.
customResourceDefinition:
checkName: Required CRD
customResourceDefinitionName: '{{ .Values.crd.name }}'
outcomes:
- fail:
message: Required CRD not found
- pass:
message: Required CRD present
{{- end }}
```
### Author high-quality docString blocks
Every analyzer should start with a `docString` so you can extract documentation automatically:
- **Title**: a concise name for the requirement
- **Requirement**: bullet list of specific, testable criteria (e.g., versions, counts, names)
- **Rationale**: 13 sentences explaining why the requirement exists and the impact if unmet
- **Links**: include authoritative docs with stable URLs
Example:
```yaml
docString: |
Title: Required CRDs and Ingress Capabilities
Requirement:
- Ingress Controller: Contour
- CRD must be present:
- Group: heptio.com
- Kind: IngressRoute
- Version: v1beta1 or later served version
The ingress layer terminates TLS and routes external traffic to Services.
Contour relies on the IngressRoute CRD to express host/path routing, TLS
configuration, and policy. If the CRD is not installed and served by the
API server, Contour cannot reconcile desired state, leaving routes
unconfigured and traffic unreachable.
```
### Choose the right analyzer type and outcomes
Use the analyzer that matches the requirement, and enumerate `outcomes` with clear messages. Common analyzers in this style:
- **clusterVersion**: compare to min and recommended versions
```yaml
clusterVersion:
checkName: Kubernetes version
outcomes:
- fail:
when: '< {{ .Values.clusterVersion.minVersion }}'
message: Requires at least Kubernetes {{ .Values.clusterVersion.minVersion }}.
- warn:
when: '< {{ .Values.clusterVersion.recommendedVersion }}'
message: Recommended to use Kubernetes {{ .Values.clusterVersion.recommendedVersion }} or later.
- pass:
when: '>= {{ .Values.clusterVersion.recommendedVersion }}'
message: Meets recommended and required Kubernetes versions.
```
- **customResourceDefinition**: ensure a CRD exists
```yaml
customResourceDefinition:
checkName: Required CRD
customResourceDefinitionName: '{{ .Values.crd.name }}'
outcomes:
- fail:
message: Required CRD not found
- pass:
message: Required CRD present
```
- **containerRuntime**: verify container runtime
```yaml
containerRuntime:
outcomes:
- pass:
when: '== containerd'
message: containerd runtime detected
- fail:
message: Unsupported container runtime; containerd required
```
- **storageClass**: check for a named StorageClass (often the default)
```yaml
storageClass:
checkName: Default StorageClass
storageClassName: '{{ .Values.analyzers.storageClass.className }}'
outcomes:
- fail:
message: Default StorageClass not found
- pass:
message: Default StorageClass present
```
- **distribution**: whitelist/blacklist distributions
```yaml
distribution:
checkName: Supported distribution
outcomes:
{{- range $d := .Values.distribution.unsupported }}
- fail:
when: '== {{ $d }}'
message: '{{ $d }} is not supported'
{{- end }}
{{- range $d := .Values.distribution.supported }}
- pass:
when: '== {{ $d }}'
message: '{{ $d }} is a supported distribution'
{{- end }}
- warn:
message: Unable to determine the distribution
```
- **nodeResources**: aggregate across nodes; common patterns include count, CPU, memory, and ephemeral storage
```yaml
# Node count requirement
nodeResources:
checkName: Node count
outcomes:
- fail:
when: 'count() < {{ .Values.nodeResources.count.min }}'
message: Requires at least {{ .Values.nodeResources.count.min }} nodes
- warn:
when: 'count() < {{ .Values.nodeResources.count.recommended }}'
message: Recommended at least {{ .Values.nodeResources.count.recommended }} nodes
- pass:
message: Cluster has sufficient nodes
# Cluster CPU total
nodeResources:
checkName: Cluster CPU total
outcomes:
- fail:
when: 'sum(cpuCapacity) < {{ .Values.nodeResources.cpu.min }}'
message: Requires at least {{ .Values.nodeResources.cpu.min }} cores
- pass:
message: Cluster CPU capacity meets requirement
# Per-node memory (Gi)
nodeResources:
checkName: Per-node memory
outcomes:
- fail:
when: 'min(memoryCapacity) < {{ .Values.nodeResources.memory.minGi }}Gi'
message: All nodes must have at least {{ .Values.nodeResources.memory.minGi }} GiB
- warn:
when: 'min(memoryCapacity) < {{ .Values.nodeResources.memory.recommendedGi }}Gi'
message: Recommended {{ .Values.nodeResources.memory.recommendedGi }} GiB per node
- pass:
message: All nodes meet recommended memory
# Per-node ephemeral storage (Gi)
nodeResources:
checkName: Per-node ephemeral storage
outcomes:
- fail:
when: 'min(ephemeralStorageCapacity) < {{ .Values.nodeResources.ephemeral.minGi }}Gi'
message: All nodes must have at least {{ .Values.nodeResources.ephemeral.minGi }} GiB
- warn:
when: 'min(ephemeralStorageCapacity) < {{ .Values.nodeResources.ephemeral.recommendedGi }}Gi'
message: Recommended {{ .Values.nodeResources.ephemeral.recommendedGi }} GiB per node
- pass:
message: All nodes meet recommended ephemeral storage
```
- **deploymentStatus**: verify workload deployment status
```yaml
deploymentStatus:
checkName: Deployment ready
namespace: '{{ .Values.workloads.deployments.namespace }}'
name: '{{ .Values.workloads.deployments.name }}'
outcomes:
- fail:
when: absent
message: Deployment not found
- fail:
when: '< {{ .Values.workloads.deployments.minReady }}'
message: Deployment has insufficient ready replicas
- pass:
when: '>= {{ .Values.workloads.deployments.minReady }}'
message: Deployment has sufficient ready replicas
```
- **postgres/mysql/redis**: database connectivity (requires collectors)
```yaml
# Collector section
- postgres:
collectorName: '{{ .Values.databases.postgres.collectorName }}'
uri: '{{ .Values.databases.postgres.uri }}'
# Analyzer section
postgres:
checkName: Postgres checks
collectorName: '{{ .Values.databases.postgres.collectorName }}'
outcomes:
- fail:
message: Postgres checks failed
- pass:
message: Postgres checks passed
```
- **textAnalyze/yamlCompare/jsonCompare**: analyze collected data
```yaml
textAnalyze:
checkName: Text analyze
collectorName: 'cluster-resources'
fileName: '{{ .Values.textAnalyze.fileName }}'
regex: '{{ .Values.textAnalyze.regex }}'
outcomes:
- fail:
message: Pattern matched in files
- pass:
message: Pattern not found
```
### Design conventions for maintainability
- **Guard every optional analyzer** with a values toggle, so consumers can enable only what they need.
- **Always include collectors section** when analyzers require them (databases, http, registryImages, etc.).
- **Use `checkName`** to provide a stable, user-facing label for each check.
- **Prefer `fail` for unmet hard requirements**, `warn` for soft requirements, and `pass` with a direct, affirmative message.
- **Attach `uri`** to outcomes when helpful for remediation.
- **Keep docString in sync** with the actual checks; avoid drift by templating values into both the docs and the analyzer.
- **Ensure values files contain all required fields** since templates now directly use values without fallback defaults.
### Values files: shape and examples
Provide a values schema that mirrors your toggles and thresholds. Example full and minimal values are included in this repository:
- `values-v1beta3-full.yaml` (all features enabled, opinionated defaults)
- `values-v1beta3-minimal.yaml` (most features disabled, conservative thresholds)
Typical structure:
```yaml
clusterVersion:
enabled: true
minVersion: "1.24.0"
recommendedVersion: "1.28.0"
storageClass:
enabled: true
className: "standard"
crd:
enabled: true
name: "samples.mycompany.com"
containerRuntime:
enabled: true
distribution:
enabled: true
supported: ["eks", "gke", "aks", "kubeadm"]
unsupported: []
nodeResources:
count:
enabled: true
min: 1
recommended: 3
cpu:
enabled: true
min: "4"
memory:
enabled: true
minGi: 8
recommendedGi: 16
ephemeral:
enabled: true
minGi: 20
recommendedGi: 50
workloads:
deployments:
enabled: true
namespace: "default"
name: "example-deploy"
minReady: 1
databases:
postgres:
enabled: true
collectorName: "postgres"
uri: "postgres://user:pass@postgres:5432/db?sslmode=disable"
mysql:
enabled: true
collectorName: "mysql"
uri: "mysql://user:pass@tcp(mysql:3306)/db"
```
### Render, run, and extract docs
You can render templates, run preflights with values, and extract requirement docs without running checks.
- **Render a templated preflight spec** to stdout or a file:
```bash
preflight template v1beta3.yaml \
--values values-base.yaml \
--values values-prod.yaml \
--set storage.className=fast-local \
-o rendered-preflight.yaml
```
- **Run preflights with values** (values and sets also work with `preflight` root command):
```bash
preflight run rendered-preflight.yaml
# or run directly against the template with values
preflight run v1beta3.yaml --values values-prod.yaml --set cluster.minNodes=5
```
- **Extract only documentation** from enabled analyzers in one or more templates:
```bash
preflight docs v1beta3.yaml other-spec.yaml \
--values values-prod.yaml \
--set kubernetes.enabled=true \
-o REQUIREMENTS.md
```
Notes:
- Multiple `--values` files are merged in order; later files win.
- `--set` uses Helm-style semantics for nested keys and types, applied after files.
### Authoring checklist
- Add `docString` with Title, Requirement bullets, rationale, and links.
- Gate optional analyzers with `{{- if .Values.analyzers.<feature>.enabled }}`.
- Parameterize thresholds and names with `.Values` expressions.
- Ensure all required values are present in your values files since there are no fallback defaults.
- Use precise, user-actionable `message` text for each outcome; add `uri` where helpful.
- Prefer a minimal values file with everything disabled, and a full values file enabling most checks.
- Test with `preflight template` (no values, minimal, full) and verify `preflight docs` output reads well.
### Example skeleton to start a new spec
```yaml
apiVersion: troubleshoot.sh/v1beta3
kind: Preflight
metadata:
name: your-product-preflight
spec:
{{- /* Determine if we need explicit collectors beyond always-on clusterResources */}}
{{- $needExtraCollectors := or .Values.databases.postgres.enabled .Values.http.enabled }}
collectors:
# Always collect cluster resources to support core analyzers
- clusterResources: {}
{{- if .Values.databases.postgres.enabled }}
- postgres:
collectorName: '{{ .Values.databases.postgres.collectorName }}'
uri: '{{ .Values.databases.postgres.uri }}'
{{- end }}
analyzers:
{{- if .Values.clusterVersion.enabled }}
- docString: |
Title: Kubernetes Control Plane Requirements
Requirement:
- Version:
- Minimum: {{ .Values.clusterVersion.minVersion }}
- Recommended: {{ .Values.clusterVersion.recommendedVersion }}
- Docs: https://kubernetes.io
These version targets ensure required APIs and defaults are available and patched.
clusterVersion:
checkName: Kubernetes version
outcomes:
- fail:
when: '< {{ .Values.clusterVersion.minVersion }}'
message: Requires at least Kubernetes {{ .Values.clusterVersion.minVersion }}.
- warn:
when: '< {{ .Values.clusterVersion.recommendedVersion }}'
message: Recommended to use Kubernetes {{ .Values.clusterVersion.recommendedVersion }} or later.
- pass:
when: '>= {{ .Values.clusterVersion.recommendedVersion }}'
message: Meets recommended and required Kubernetes versions.
{{- end }}
{{- if .Values.storageClass.enabled }}
- docString: |
Title: Default StorageClass Requirements
Requirement:
- A StorageClass named "{{ .Values.storageClass.className }}" must exist
A default StorageClass enables dynamic PVC provisioning without manual intervention.
storageClass:
checkName: Default StorageClass
storageClassName: '{{ .Values.storageClass.className }}'
outcomes:
- fail:
message: Default StorageClass not found
- pass:
message: Default StorageClass present
{{- end }}
{{- if .Values.databases.postgres.enabled }}
- docString: |
Title: Postgres Connectivity
Requirement:
- Postgres checks collected by '{{ .Values.databases.postgres.collectorName }}' must pass
postgres:
checkName: Postgres checks
collectorName: '{{ .Values.databases.postgres.collectorName }}'
outcomes:
- fail:
message: Postgres checks failed
- pass:
message: Postgres checks passed
{{- end }}
```
### References
- Example template in this repo: `v1beta3-all-analyzers.yaml`
- Values example: `values-v1beta3-all-analyzers.yaml`
@@ -0,0 +1,483 @@
apiVersion: troubleshoot.sh/v1beta2
kind: Preflight
metadata:
name: all-analyzers-v1beta2
spec:
collectors:
# Generic cluster resources (used by several analyzers like events)
- clusterResources:
collectorName: cluster-resources
# Text/YAML/JSON inputs for textAnalyze/yamlCompare/jsonCompare
- data:
name: config/replicas.txt
data: "5"
- data:
name: files/example.yaml
data: |
apiVersion: v1
kind: ConfigMap
metadata:
name: sample
data:
key: value
- data:
name: files/example.json
data: '{"foo": {"bar": "baz"}}'
# Database connection collectors (postgres, mssql, mysql, redis)
- postgres:
collectorName: pg
uri: postgresql://user:password@hostname:5432/defaultdb?sslmode=disable
- mssql:
collectorName: mssql
uri: sqlserver://user:password@hostname:1433/master
- mysql:
collectorName: mysql
uri: mysql://user:password@hostname:3306/defaultdb
- redis:
collectorName: redis
uri: redis://:password@hostname:6379
# Registry images (used by registryImages analyzer)
- registryImages:
collectorName: registry-images
namespace: default
images:
- nginx:1.25
- alpine:3.19
# HTTP checks (used by http analyzer)
- http:
collectorName: http-check
get:
url: https://example.com/healthz
timeout: 5s
# Node metrics (used by nodeMetrics analyzer)
- nodeMetrics:
collectorName: node-metrics
# Sysctl (used by sysctl analyzer)
- sysctl:
collectorName: sysctl
namespace: default
image: busybox
# Certificates (used by certificates analyzer)
- certificates:
collectorName: certs
secrets:
- namespaces: ["default"]
configMaps:
- namespaces: ["default"]
# Goldpinger (used by goldpinger analyzer)
- goldpinger:
collectorName: goldpinger
namespace: default
collectDelay: 10s
analyzers:
# Kubernetes version
- clusterVersion:
checkName: Kubernetes version
outcomes:
- fail:
when: "< 1.20.0"
message: Requires at least Kubernetes 1.20.0
- warn:
when: "< 1.22.0"
message: Recommended to use Kubernetes 1.22.0 or later
- pass:
when: ">= 1.22.0"
message: Meets recommended and required versions
# StorageClass
- storageClass:
checkName: Default StorageClass
storageClassName: "default"
outcomes:
- fail:
message: Default StorageClass not found
- pass:
message: Default StorageClass present
# CustomResourceDefinition
- customResourceDefinition:
checkName: Required CRD
customResourceDefinitionName: widgets.example.com
outcomes:
- fail:
message: Required CRD not found
- pass:
message: Required CRD present
# Ingress
- ingress:
checkName: Ingress exists
namespace: default
ingressName: my-app-ingress
outcomes:
- fail:
message: Expected ingress not found
- pass:
message: Expected ingress present
# Secret
- secret:
checkName: Required secret
namespace: default
secretName: my-secret
outcomes:
- fail:
message: Required secret not found
- pass:
message: Required secret present
# ConfigMap
- configMap:
checkName: Required ConfigMap
namespace: default
configMapName: my-config
outcomes:
- fail:
message: Required ConfigMap not found
- pass:
message: Required ConfigMap present
# ImagePullSecret presence
- imagePullSecret:
checkName: Registry credentials
registryName: quay.io
outcomes:
- fail:
message: Cannot pull from registry; credentials missing
- pass:
message: Found credentials for registry
# Deployment status
- deploymentStatus:
checkName: Deployment ready
namespace: default
name: my-deployment
outcomes:
- fail:
when: absent
message: Deployment not found
- fail:
when: "< 1"
message: Deployment has insufficient ready replicas
- pass:
when: ">= 1"
message: Deployment has sufficient ready replicas
# StatefulSet status
- statefulsetStatus:
checkName: StatefulSet ready
namespace: default
name: my-statefulset
outcomes:
- fail:
when: absent
message: StatefulSet not found
- fail:
when: "< 1"
message: StatefulSet has insufficient ready replicas
- pass:
when: ">= 1"
message: StatefulSet has sufficient ready replicas
# Job status
- jobStatus:
checkName: Job completed
namespace: default
name: my-job
outcomes:
- fail:
when: absent
message: Job not found
- fail:
when: "= 0"
message: Job has no successful completions
- pass:
when: "> 0"
message: Job completed successfully
# ReplicaSet status
- replicasetStatus:
checkName: ReplicaSet ready
namespace: default
name: my-replicaset
outcomes:
- fail:
message: ReplicaSet is not ready
- pass:
when: ">= 1"
message: ReplicaSet has sufficient ready replicas
# Cluster pod statuses
- clusterPodStatuses:
checkName: Pod statuses
namespaces:
- kube-system
outcomes:
- warn:
message: Some pods are not ready
- pass:
message: All pods are ready
# Cluster container statuses (restarts)
- clusterContainerStatuses:
checkName: Container restarts
namespaces:
- kube-system
restartCount: 3
outcomes:
- warn:
message: One or more containers exceed restart threshold
- pass:
message: Container restarts are within thresholds
# Container runtime
- containerRuntime:
checkName: Runtime must be containerd
outcomes:
- pass:
when: "== containerd"
message: containerd runtime detected
- fail:
message: Unsupported container runtime; containerd required
# Distribution
- distribution:
checkName: Supported distribution
outcomes:
- fail:
when: "== docker-desktop"
message: Docker Desktop is not supported
- pass:
when: "== eks"
message: EKS is supported
- warn:
message: Unable to determine the distribution
# Node resources - cluster size
- nodeResources:
checkName: Node count
outcomes:
- fail:
when: "count() < 3"
message: Requires at least 3 nodes
- warn:
when: "count() < 5"
message: Recommended at least 5 nodes
- pass:
message: Cluster has sufficient nodes
# Node resources - per-node memory
- nodeResources:
checkName: Per-node memory
outcomes:
- fail:
when: "min(memoryCapacity) < 8Gi"
message: All nodes must have at least 8 GiB
- pass:
message: All nodes meet recommended memory
# Text analyze (regex on collected file)
- textAnalyze:
checkName: Text analyze
fileName: config/replicas.txt
regexGroups: '(?P<Replicas>\d+)'
outcomes:
- fail:
when: "Replicas < 5"
message: Not enough replicas
- pass:
message: Replica count is sufficient
# YAML compare
- yamlCompare:
checkName: YAML compare
fileName: files/example.yaml
path: data.key
value: value
outcomes:
- fail:
message: YAML value does not match expected
- pass:
message: YAML value matches expected
# JSON compare
- jsonCompare:
checkName: JSON compare
fileName: files/example.json
jsonPath: $.foo.bar
value: baz
outcomes:
- fail:
message: JSON value does not match expected
- pass:
message: JSON value matches expected
# Postgres
- postgres:
checkName: Postgres checks
collectorName: pg
outcomes:
- fail:
when: "connected == false"
message: Cannot connect to postgres server
- pass:
message: Postgres connection checks out
# MSSQL
- mssql:
checkName: MSSQL checks
collectorName: mssql
outcomes:
- fail:
when: "connected == false"
message: Cannot connect to SQL Server
- pass:
message: MSSQL connection checks out
# MySQL
- mysql:
checkName: MySQL checks
collectorName: mysql
outcomes:
- fail:
when: "connected == false"
message: Cannot connect to MySQL server
- pass:
message: MySQL connection checks out
# Redis
- redis:
checkName: Redis checks
collectorName: redis
outcomes:
- fail:
when: "connected == false"
message: Cannot connect to Redis server
- pass:
message: Redis connection checks out
# Ceph status
- cephStatus:
checkName: Ceph cluster health
namespace: rook-ceph
outcomes:
- fail:
message: Ceph is not healthy
- pass:
message: Ceph is healthy
# Velero
- velero:
checkName: Velero installed
# Longhorn
- longhorn:
checkName: Longhorn health
namespace: longhorn-system
outcomes:
- fail:
message: Longhorn is not healthy
- pass:
message: Longhorn is healthy
# Registry images availability
- registryImages:
checkName: Registry image availability
collectorName: registry-images
outcomes:
- fail:
message: One or more images are not available
- pass:
message: All images are available
# Weave report (expects weave report files to be present if collected)
- weaveReport:
checkName: Weave report
reportFileGlob: kots/kurl/weave/kube-system/*/weave-report-stdout.txt
# Sysctl (cluster-level)
- sysctl:
checkName: Sysctl settings
outcomes:
- warn:
message: One or more sysctl values do not meet recommendations
- pass:
message: Sysctl values meet recommendations
# Cluster resource YAML field compare
- clusterResource:
checkName: Cluster resource value
kind: Namespace
clusterScoped: true
name: kube-system
yamlPath: metadata.name
expectedValue: kube-system
outcomes:
- fail:
message: Cluster resource field does not match expected value
- pass:
message: Cluster resource field matches expected value
# Certificates analyzer
- certificates:
checkName: Certificates validity
outcomes:
- warn:
message: One or more certificates may be invalid or expiring soon
- pass:
message: Certificates are valid
# Goldpinger analyzer
- goldpinger:
checkName: Goldpinger report
collectorName: goldpinger
filePath: goldpinger/report.json
outcomes:
- fail:
message: Goldpinger indicates network issues
- pass:
message: Goldpinger indicates healthy networking
# Event analyzer (requires events in clusterResources)
- event:
checkName: Events
collectorName: cluster-resources
namespace: default
reason: Failed
regex: ".*"
outcomes:
- fail:
message: Critical events detected
- pass:
message: No critical events detected
# Node metrics analyzer
- nodeMetrics:
checkName: Node metrics thresholds
collectorName: node-metrics
outcomes:
- warn:
message: Node metrics exceed warning thresholds
- pass:
message: Node metrics within thresholds
# HTTP analyzer (cluster)
- http:
checkName: HTTP checks
collectorName: http-check
outcomes:
- fail:
message: One or more HTTP checks failed
- pass:
message: All HTTP checks passed
+905
View File
@@ -0,0 +1,905 @@
apiVersion: troubleshoot.sh/v1beta3
kind: Preflight
metadata:
name: all-analyzers
spec:
{{- /* Determine if we need explicit collectors beyond always-on clusterResources */}}
{{- $needExtraCollectors := or (or (or .Values.databases.postgres.enabled .Values.databases.mssql.enabled) (or .Values.databases.mysql.enabled .Values.databases.redis.enabled)) (or (or (or .Values.registryImages.enabled .Values.http.enabled) (or .Values.nodeMetrics.enabled (or .Values.sysctl.enabled .Values.certificates.enabled))) (or (or .Values.goldpinger.enabled .Values.cephStatus.enabled) .Values.longhorn.enabled)) }}
collectors:
# Always collect cluster resources to support core analyzers (deployments, secrets, pods, events, etc.)
- clusterResources: {}
{{- if .Values.databases.postgres.enabled }}
- postgres:
collectorName: '{{ .Values.databases.postgres.collectorName }}'
uri: '{{ .Values.databases.postgres.uri }}'
{{- if .Values.databases.postgres.tls }}
tls:
skipVerify: {{ .Values.databases.postgres.tls.skipVerify | default false }}
{{- if .Values.databases.postgres.tls.secret }}
secret:
name: '{{ .Values.databases.postgres.tls.secret.name }}'
namespace: '{{ .Values.databases.postgres.tls.secret.namespace }}'
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.databases.mssql.enabled }}
- mssql:
collectorName: '{{ .Values.databases.mssql.collectorName }}'
uri: '{{ .Values.databases.mssql.uri }}'
{{- end }}
{{- if .Values.databases.mysql.enabled }}
- mysql:
collectorName: '{{ .Values.databases.mysql.collectorName }}'
uri: '{{ .Values.databases.mysql.uri }}'
{{- end }}
{{- if .Values.databases.redis.enabled }}
- redis:
collectorName: '{{ .Values.databases.redis.collectorName }}'
uri: '{{ .Values.databases.redis.uri }}'
{{- end }}
{{- if .Values.registryImages.enabled }}
- registryImages:
collectorName: '{{ .Values.registryImages.collectorName }}'
namespace: '{{ .Values.registryImages.namespace }}'
{{- if .Values.registryImages.imagePullSecret }}
imagePullSecret:
name: '{{ .Values.registryImages.imagePullSecret.name }}'
{{- if .Values.registryImages.imagePullSecret.data }}
data:
{{- range $k, $v := .Values.registryImages.imagePullSecret.data }}
{{ $k }}: '{{ $v }}'
{{- end }}
{{- end }}
{{- end }}
images:
{{- range .Values.registryImages.images }}
- '{{ . }}'
{{- end }}
{{- end }}
{{- if .Values.http.enabled }}
- http:
collectorName: '{{ .Values.http.collectorName }}'
{{- if .Values.http.get }}
get:
url: '{{ .Values.http.get.url }}'
{{- if .Values.http.get.timeout }}
timeout: '{{ .Values.http.get.timeout }}'
{{- end }}
{{- if .Values.http.get.insecureSkipVerify }}
insecureSkipVerify: {{ .Values.http.get.insecureSkipVerify }}
{{- end }}
{{- if .Values.http.get.headers }}
headers:
{{- range $k, $v := .Values.http.get.headers }}
{{ $k }}: '{{ $v }}'
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.http.post }}
post:
url: '{{ .Values.http.post.url }}'
{{- if .Values.http.post.timeout }}
timeout: '{{ .Values.http.post.timeout }}'
{{- end }}
{{- if .Values.http.post.insecureSkipVerify }}
insecureSkipVerify: {{ .Values.http.post.insecureSkipVerify }}
{{- end }}
{{- if .Values.http.post.headers }}
headers:
{{- range $k, $v := .Values.http.post.headers }}
{{ $k }}: '{{ $v }}'
{{- end }}
{{- end }}
{{- if .Values.http.post.body }}
body: '{{ .Values.http.post.body }}'
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.nodeMetrics.enabled }}
- nodeMetrics:
collectorName: '{{ .Values.nodeMetrics.collectorName }}'
{{- if .Values.nodeMetrics.nodeNames }}
nodeNames:
{{- range .Values.nodeMetrics.nodeNames }}
- '{{ . }}'
{{- end }}
{{- end }}
{{- if .Values.nodeMetrics.selector }}
selector:
{{- range .Values.nodeMetrics.selector }}
- '{{ . }}'
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.sysctl.enabled }}
- sysctl:
collectorName: 'sysctl'
namespace: '{{ .Values.sysctl.namespace }}'
image: '{{ .Values.sysctl.image }}'
{{- if .Values.sysctl.imagePullPolicy }}
imagePullPolicy: '{{ .Values.sysctl.imagePullPolicy }}'
{{- end }}
{{- end }}
{{- if .Values.certificates.enabled }}
- certificates:
collectorName: 'certs'
{{- if .Values.certificates.secrets }}
secrets:
{{- range .Values.certificates.secrets }}
- name: '{{ .name }}'
namespaces:
{{- range .namespaces }}
- '{{ . }}'
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.certificates.configMaps }}
configMaps:
{{- range .Values.certificates.configMaps }}
- name: '{{ .name }}'
namespaces:
{{- range .namespaces }}
- '{{ . }}'
{{- end }}
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.longhorn.enabled }}
- longhorn:
collectorName: 'longhorn'
namespace: '{{ .Values.longhorn.namespace }}'
{{- if .Values.longhorn.timeout }}
timeout: '{{ .Values.longhorn.timeout }}'
{{- end }}
{{- end }}
{{- if .Values.cephStatus.enabled }}
- ceph:
collectorName: 'ceph'
namespace: '{{ .Values.cephStatus.namespace }}'
{{- if .Values.cephStatus.timeout }}
timeout: '{{ .Values.cephStatus.timeout }}'
{{- end }}
{{- end }}
{{- if .Values.goldpinger.enabled }}
- goldpinger:
collectorName: '{{ .Values.goldpinger.collectorName }}'
namespace: '{{ .Values.goldpinger.namespace }}'
{{- if .Values.goldpinger.collectDelay }}
collectDelay: '{{ .Values.goldpinger.collectDelay }}'
{{- end }}
{{- if .Values.goldpinger.podLaunch }}
podLaunchOptions:
{{- if .Values.goldpinger.podLaunch.namespace }}
namespace: '{{ .Values.goldpinger.podLaunch.namespace }}'
{{- end }}
{{- if .Values.goldpinger.podLaunch.image }}
image: '{{ .Values.goldpinger.podLaunch.image }}'
{{- end }}
{{- if .Values.goldpinger.podLaunch.imagePullSecret }}
imagePullSecret:
name: '{{ .Values.goldpinger.podLaunch.imagePullSecret.name }}'
{{- end }}
{{- if .Values.goldpinger.podLaunch.serviceAccountName }}
serviceAccountName: '{{ .Values.goldpinger.podLaunch.serviceAccountName }}'
{{- end }}
{{- end }}
{{- end }}
analyzers:
{{- if .Values.clusterVersion.enabled }}
- docString: |
Title: Kubernetes Control Plane Requirements
Requirement:
- Version:
- Minimum: {{ .Values.clusterVersion.minVersion }}
- Recommended: {{ .Values.clusterVersion.recommendedVersion }}
Running below the minimum can remove or alter required GA APIs and lacks critical CVE fixes. The recommended version aligns with CI coverage and provides safer upgrades and operational guidance.
clusterVersion:
checkName: Kubernetes version
outcomes:
- fail:
when: '< {{ .Values.clusterVersion.minVersion }}'
message: Requires at least Kubernetes {{ .Values.clusterVersion.minVersion }}.
- warn:
when: '< {{ .Values.clusterVersion.recommendedVersion }}'
message: Recommended to use Kubernetes {{ .Values.clusterVersion.recommendedVersion }} or later.
- pass:
when: '>= {{ .Values.clusterVersion.recommendedVersion }}'
message: Meets recommended and required Kubernetes versions.
{{- end }}
{{- if .Values.storageClass.enabled }}
- docString: |
Title: Default StorageClass Requirements
Requirement:
- A StorageClass named "{{ .Values.storageClass.className }}" must exist
A default StorageClass enables dynamic PVC provisioning without manual intervention. Missing or misnamed defaults cause PVCs to remain Pending and block workloads.
storageClass:
checkName: Default StorageClass
storageClassName: '{{ .Values.storageClass.className }}'
outcomes:
- fail:
message: Default StorageClass not found
- pass:
message: Default StorageClass present
{{- end }}
{{- if .Values.crd.enabled }}
- docString: |
Title: Required CRD Presence
Requirement:
- CRD must exist: {{ .Values.crd.name }}
Controllers depending on this CRD cannot reconcile without it, leading to missing resources and degraded functionality.
customResourceDefinition:
checkName: Required CRD
customResourceDefinitionName: '{{ .Values.crd.name }}'
outcomes:
- fail:
message: Required CRD not found
- pass:
message: Required CRD present
{{- end }}
{{- if .Values.ingress.enabled }}
- docString: |
Title: Ingress Object Presence
Requirement:
- Ingress exists: {{ .Values.ingress.namespace }}/{{ .Values.ingress.name }}
Ensures external routing is configured to reach the application. Missing ingress prevents user traffic from reaching services.
ingress:
checkName: Ingress exists
namespace: '{{ .Values.ingress.namespace }}'
ingressName: '{{ .Values.ingress.name }}'
outcomes:
- fail:
message: Expected ingress not found
- pass:
message: Expected ingress present
{{- end }}
{{- if .Values.secret.enabled }}
- docString: |
Title: Required Secret Presence
Requirement:
- Secret exists: {{ .Values.secret.namespace }}/{{ .Values.secret.name }}{{ if .Values.secret.key }} (key: {{ .Values.secret.key }}){{ end }}
Secrets commonly provide credentials or TLS material. Absence blocks components from authenticating or decrypting traffic.
secret:
checkName: Required secret
namespace: '{{ .Values.secret.namespace }}'
secretName: '{{ .Values.secret.name }}'
{{- if .Values.secret.key }}
key: '{{ .Values.secret.key }}'
{{- end }}
outcomes:
- fail:
message: Required secret not found
- pass:
message: Required secret present
{{- end }}
{{- if .Values.configMap.enabled }}
- docString: |
Title: Required ConfigMap Presence
Requirement:
- ConfigMap exists: {{ .Values.configMap.namespace }}/{{ .Values.configMap.name }}{{ if .Values.configMap.key }} (key: {{ .Values.configMap.key }}){{ end }}
Required for bootstrapping configuration. Missing keys lead to defaulting or startup failure.
configMap:
checkName: Required ConfigMap
namespace: '{{ .Values.configMap.namespace }}'
configMapName: '{{ .Values.configMap.name }}'
{{- if .Values.configMap.key }}
key: '{{ .Values.configMap.key }}'
{{- end }}
outcomes:
- fail:
message: Required ConfigMap not found
- pass:
message: Required ConfigMap present
{{- end }}
{{- if .Values.imagePullSecret.enabled }}
- docString: |
Title: Container Registry Credentials
Requirement:
- Credentials present for registry: {{ .Values.imagePullSecret.registry }}
Ensures images can be pulled from private registries. Missing secrets cause ImagePullBackOff and prevent workloads from starting.
imagePullSecret:
checkName: Registry credentials
registryName: '{{ .Values.imagePullSecret.registry }}'
outcomes:
- fail:
message: Cannot pull from registry; credentials missing
- pass:
message: Found credentials for registry
{{- end }}
{{- if .Values.workloads.deployments.enabled }}
- docString: |
Title: Deployment Ready
Requirement:
- Deployment ready: {{ .Values.workloads.deployments.namespace }}/{{ .Values.workloads.deployments.name }} (minReady: {{ .Values.workloads.deployments.minReady }})
Validates rollout completed and enough replicas are Ready to serve traffic.
deploymentStatus:
checkName: Deployment ready
namespace: '{{ .Values.workloads.deployments.namespace }}'
name: '{{ .Values.workloads.deployments.name }}'
outcomes:
- fail:
when: absent
message: Deployment not found
- fail:
when: '< {{ .Values.workloads.deployments.minReady }}'
message: Deployment has insufficient ready replicas
- pass:
when: '>= {{ .Values.workloads.deployments.minReady }}'
message: Deployment has sufficient ready replicas
{{- end }}
{{- if .Values.workloads.statefulsets.enabled }}
- docString: |
Title: StatefulSet Ready
Requirement:
- StatefulSet ready: {{ .Values.workloads.statefulsets.namespace }}/{{ .Values.workloads.statefulsets.name }} (minReady: {{ .Values.workloads.statefulsets.minReady }})
Confirms ordered, persistent workloads have reached readiness before proceeding.
statefulsetStatus:
checkName: StatefulSet ready
namespace: '{{ .Values.workloads.statefulsets.namespace }}'
name: '{{ .Values.workloads.statefulsets.name }}'
outcomes:
- fail:
when: absent
message: StatefulSet not found
- fail:
when: '< {{ .Values.workloads.statefulsets.minReady }}'
message: StatefulSet has insufficient ready replicas
- pass:
when: '>= {{ .Values.workloads.statefulsets.minReady }}'
message: StatefulSet has sufficient ready replicas
{{- end }}
{{- if .Values.workloads.jobs.enabled }}
- docString: |
Title: Job Completion
Requirement:
- Job completed: {{ .Values.workloads.jobs.namespace }}/{{ .Values.workloads.jobs.name }}
Verifies one-off tasks have succeeded; failures indicate setup or migration problems.
jobStatus:
checkName: Job completed
namespace: '{{ .Values.workloads.jobs.namespace }}'
name: '{{ .Values.workloads.jobs.name }}'
outcomes:
- fail:
when: absent
message: Job not found
- fail:
when: '= 0'
message: Job has no successful completions
- pass:
when: '> 0'
message: Job completed successfully
{{- end }}
{{- if .Values.workloads.replicasets.enabled }}
- docString: |
Title: ReplicaSet Ready
Requirement:
- ReplicaSet ready: {{ .Values.workloads.replicasets.namespace }}/{{ .Values.workloads.replicasets.name }} (minReady: {{ .Values.workloads.replicasets.minReady }})
Ensures underlying ReplicaSet has produced the required number of Ready pods for upstream controllers.
replicasetStatus:
checkName: ReplicaSet ready
namespace: '{{ .Values.workloads.replicasets.namespace }}'
name: '{{ .Values.workloads.replicasets.name }}'
outcomes:
- fail:
message: ReplicaSet is not ready
- pass:
when: '>= {{ .Values.workloads.replicasets.minReady }}'
message: ReplicaSet has sufficient ready replicas
{{- end }}
{{- if .Values.clusterPodStatuses.enabled }}
- docString: |
Title: Cluster Pod Readiness by Namespace
Requirement:
- Namespaces checked: {{ toYaml .Values.clusterPodStatuses.namespaces | nindent 10 }}
Highlights unhealthy pods across critical namespaces to surface rollout or configuration issues.
clusterPodStatuses:
checkName: Pod statuses
namespaces: {{ toYaml .Values.clusterPodStatuses.namespaces | nindent 8 }}
outcomes:
- warn:
message: Some pods are not ready
- pass:
message: All pods are ready
{{- end }}
{{- if .Values.clusterContainerStatuses.enabled }}
- docString: |
Title: Container Restart Thresholds
Requirement:
- Namespaces checked: {{ toYaml .Values.clusterContainerStatuses.namespaces | nindent 10 }}
- Restart threshold: {{ .Values.clusterContainerStatuses.restartCount }}
Elevated restart counts often indicate crash loops, resource pressure, or image/runtime issues.
clusterContainerStatuses:
checkName: Container restarts
namespaces: {{ toYaml .Values.clusterContainerStatuses.namespaces | nindent 8 }}
restartCount: {{ .Values.clusterContainerStatuses.restartCount }}
outcomes:
- warn:
message: One or more containers exceed restart threshold
- pass:
message: Container restarts are within thresholds
{{- end }}
{{- if .Values.containerRuntime.enabled }}
- docString: |
Title: Container Runtime Compatibility
Requirement:
- Runtime must be: containerd
containerd with CRI provides stable semantics; other runtimes are unsupported and may break image, cgroup, and networking expectations.
containerRuntime:
checkName: Runtime must be containerd
outcomes:
- pass:
when: '== containerd'
message: containerd runtime detected
- fail:
message: Unsupported container runtime; containerd required
{{- end }}
{{- if .Values.distribution.enabled }}
- docString: |
Title: Supported Kubernetes Distributions
Requirement:
- Unsupported: {{ toYaml .Values.distribution.unsupported | nindent 12 }}
- Supported: {{ toYaml .Values.distribution.supported | nindent 12 }}
Production-tier assumptions (RBAC, admission, networking, storage) are validated on supported distros. Unsupported environments commonly diverge and reduce reliability.
distribution:
checkName: Supported distribution
outcomes:
{{- range $d := .Values.distribution.unsupported }}
- fail:
when: '== {{ $d }}'
message: '{{ $d }} is not supported'
{{- end }}
{{- range $d := .Values.distribution.supported }}
- pass:
when: '== {{ $d }}'
message: '{{ $d }} is a supported distribution'
{{- end }}
- warn:
message: Unable to determine the distribution
{{- end }}
{{- if .Values.nodeResources.count.enabled }}
- docString: |
Title: Node Count Requirement
Requirement:
- Minimum nodes: {{ .Values.nodeResources.count.min }}
- Recommended nodes: {{ .Values.nodeResources.count.recommended }}
Ensures capacity and disruption tolerance for upgrades and failures; too few nodes yields scheduling pressure and risk during maintenance.
nodeResources:
checkName: Node count
outcomes:
- fail:
when: 'count() < {{ .Values.nodeResources.count.min }}'
message: Requires at least {{ .Values.nodeResources.count.min }} nodes
- warn:
when: 'count() < {{ .Values.nodeResources.count.recommended }}'
message: Recommended at least {{ .Values.nodeResources.count.recommended }} nodes
- pass:
message: Cluster has sufficient nodes
{{- end }}
{{- if .Values.nodeResources.cpu.enabled }}
- docString: |
Title: Cluster CPU Capacity
Requirement:
- Total vCPU minimum: {{ .Values.nodeResources.cpu.min }}
Aggregate CPU must cover control plane, system daemons, and application workloads; insufficient CPU causes scheduling delays and degraded throughput.
nodeResources:
checkName: Cluster CPU total
outcomes:
- fail:
when: 'sum(cpuCapacity) < {{ .Values.nodeResources.cpu.min }}'
message: Requires at least {{ .Values.nodeResources.cpu.min }} cores
- pass:
message: Cluster CPU capacity meets requirement
{{- end }}
{{- if .Values.nodeResources.memory.enabled }}
- docString: |
Title: Per-node Memory Requirement
Requirement:
- Minimum per-node: {{ .Values.nodeResources.memory.minGi }} GiB
- Recommended per-node: {{ .Values.nodeResources.memory.recommendedGi }} GiB
Memory headroom avoids OOMKills and evictions during spikes and upgrades; recommended capacity supports stable operations.
nodeResources:
checkName: Per-node memory
outcomes:
- fail:
when: 'min(memoryCapacity) < {{ .Values.nodeResources.memory.minGi }}Gi'
message: All nodes must have at least {{ .Values.nodeResources.memory.minGi }} GiB
- warn:
when: 'min(memoryCapacity) < {{ .Values.nodeResources.memory.recommendedGi }}Gi'
message: Recommended {{ .Values.nodeResources.memory.recommendedGi }} GiB per node
- pass:
message: All nodes meet recommended memory
{{- end }}
{{- if .Values.nodeResources.ephemeral.enabled }}
- docString: |
Title: Per-node Ephemeral Storage Requirement
Requirement:
- Minimum per-node: {{ .Values.nodeResources.ephemeral.minGi }} GiB
- Recommended per-node: {{ .Values.nodeResources.ephemeral.recommendedGi }} GiB
Ephemeral storage backs images, container filesystems, and logs; insufficient capacity triggers disk pressure and failed pulls.
nodeResources:
checkName: Per-node ephemeral storage
outcomes:
- fail:
when: 'min(ephemeralStorageCapacity) < {{ .Values.nodeResources.ephemeral.minGi }}Gi'
message: All nodes must have at least {{ .Values.nodeResources.ephemeral.minGi }} GiB
- warn:
when: 'min(ephemeralStorageCapacity) < {{ .Values.nodeResources.ephemeral.recommendedGi }}Gi'
message: Recommended {{ .Values.nodeResources.ephemeral.recommendedGi }} GiB per node
- pass:
message: All nodes meet recommended ephemeral storage
{{- end }}
{{- if .Values.textAnalyze.enabled }}
- docString: |
Title: Text Analyze Pattern Check
Requirement:
- File(s): {{ .Values.textAnalyze.fileName }}
- Regex: {{ .Values.textAnalyze.regex }}
Surfaces error patterns in collected logs or text files that indicate configuration or runtime issues.
textAnalyze:
checkName: Text analyze
collectorName: 'cluster-resources'
fileName: '{{ .Values.textAnalyze.fileName }}'
regex: '{{ .Values.textAnalyze.regex }}'
ignoreIfNoFiles: true
outcomes:
- fail:
message: Pattern matched in files
- pass:
message: Pattern not found
{{- end }}
{{- if .Values.yamlCompare.enabled }}
- docString: |
Title: YAML Field Comparison
Requirement:
- File: {{ .Values.yamlCompare.fileName }}
- Path: {{ .Values.yamlCompare.path }}
- Expected: {{ .Values.yamlCompare.value }}
Validates rendered object fields match required configuration to ensure correct behavior.
yamlCompare:
checkName: YAML compare
collectorName: 'cluster-resources'
fileName: '{{ .Values.yamlCompare.fileName }}'
path: '{{ .Values.yamlCompare.path }}'
value: '{{ .Values.yamlCompare.value }}'
outcomes:
- fail:
message: YAML value does not match expected
- pass:
message: YAML value matches expected
{{- end }}
{{- if .Values.jsonCompare.enabled }}
- docString: |
Title: JSON Field Comparison
Requirement:
- File: {{ .Values.jsonCompare.fileName }}
- JSONPath: {{ .Values.jsonCompare.jsonPath }}
- Expected: {{ .Values.jsonCompare.value }}
Ensures collected JSON metrics or resources match required values.
jsonCompare:
checkName: JSON compare
collectorName: 'cluster-resources'
fileName: '{{ .Values.jsonCompare.fileName }}'
jsonPath: '{{ .Values.jsonCompare.jsonPath }}'
value: '{{ .Values.jsonCompare.value }}'
outcomes:
- fail:
message: JSON value does not match expected
- pass:
message: JSON value matches expected
{{- end }}
{{- if .Values.databases.postgres.enabled }}
- docString: |
Title: Postgres Connectivity and Health
Requirement:
- Collector: {{ .Values.databases.postgres.collectorName }}
Validates database availability and credentials to avoid boot failures or runtime errors.
postgres:
checkName: Postgres checks
collectorName: '{{ .Values.databases.postgres.collectorName }}'
outcomes:
- fail:
message: Postgres checks failed
- pass:
message: Postgres checks passed
{{- end }}
{{- if .Values.databases.mssql.enabled }}
- docString: |
Title: MSSQL Connectivity and Health
Requirement:
- Collector: {{ .Values.databases.mssql.collectorName }}
Ensures connectivity and credentials to Microsoft SQL Server are valid prior to workload startup.
mssql:
checkName: MSSQL checks
collectorName: '{{ .Values.databases.mssql.collectorName }}'
outcomes:
- fail:
message: MSSQL checks failed
- pass:
message: MSSQL checks passed
{{- end }}
{{- if .Values.databases.mysql.enabled }}
- docString: |
Title: MySQL Connectivity and Health
Requirement:
- Collector: {{ .Values.databases.mysql.collectorName }}
Verifies MySQL reachability and credentials to prevent configuration-time failures.
mysql:
checkName: MySQL checks
collectorName: '{{ .Values.databases.mysql.collectorName }}'
outcomes:
- fail:
message: MySQL checks failed
- pass:
message: MySQL checks passed
{{- end }}
{{- if .Values.databases.redis.enabled }}
- docString: |
Title: Redis Connectivity and Health
Requirement:
- Collector: {{ .Values.databases.redis.collectorName }}
Validates cache availability; failures cause timeouts, degraded performance, or startup errors.
redis:
checkName: Redis checks
collectorName: '{{ .Values.databases.redis.collectorName }}'
outcomes:
- fail:
message: Redis checks failed
- pass:
message: Redis checks passed
{{- end }}
{{- if .Values.cephStatus.enabled }}
- docString: |
Title: Ceph Cluster Health
Requirement:
- Namespace: {{ .Values.cephStatus.namespace }}
Ensures Ceph reports healthy status before depending on it for storage operations.
cephStatus:
checkName: Ceph cluster health
namespace: '{{ .Values.cephStatus.namespace }}'
outcomes:
- fail:
message: Ceph is not healthy
- pass:
message: Ceph is healthy
{{- end }}
{{- if .Values.velero.enabled }}
- docString: |
Title: Velero Installed
Requirement:
- Velero controllers installed and discoverable
Backup/restore operations require Velero components to be present.
velero:
checkName: Velero installed
{{- end }}
{{- if .Values.longhorn.enabled }}
- docString: |
Title: Longhorn Health
Requirement:
- Namespace: {{ .Values.longhorn.namespace }}
Verifies Longhorn is healthy to ensure persistent volumes remain available and replicas are in sync.
longhorn:
checkName: Longhorn health
namespace: '{{ .Values.longhorn.namespace }}'
outcomes:
- fail:
message: Longhorn is not healthy
- pass:
message: Longhorn is healthy
{{- end }}
{{- if .Values.registryImages.enabled }}
- docString: |
Title: Registry Image Availability
Requirement:
- Collector: {{ .Values.registryImages.collectorName }}
- Images: {{ toYaml .Values.registryImages.images | nindent 12 }}
Ensures required images are available and pullable with provided credentials.
registryImages:
checkName: Registry image availability
collectorName: '{{ .Values.registryImages.collectorName }}'
outcomes:
- fail:
message: One or more images are not available
- pass:
message: All images are available
{{- end }}
{{- if .Values.weaveReport.enabled }}
- docString: |
Title: Weave Net Report Presence
Requirement:
- Report files: {{ .Values.weaveReport.reportFileGlob }}
Validates networking diagnostics are collected for analysis of connectivity issues.
weaveReport:
checkName: Weave report
reportFileGlob: '{{ .Values.weaveReport.reportFileGlob }}'
{{- end }}
{{- if .Values.sysctl.enabled }}
- docString: |
Title: Sysctl Settings Validation
Requirement:
- Namespace: {{ .Values.sysctl.namespace }}
- Image: {{ .Values.sysctl.image }}
Checks kernel parameter configuration that impacts networking, file descriptors, and memory behavior.
sysctl:
checkName: Sysctl settings
outcomes:
- warn:
message: One or more sysctl values do not meet recommendations
- pass:
message: Sysctl values meet recommendations
{{- end }}
{{- if .Values.clusterResource.enabled }}
- docString: |
Title: Cluster Resource Field Requirement
Requirement:
- Kind: {{ .Values.clusterResource.kind }}
- Name: {{ .Values.clusterResource.name }}{{ if not .Values.clusterResource.clusterScoped }} (ns: {{ .Values.clusterResource.namespace }}){{ end }}
- YAML path: {{ .Values.clusterResource.yamlPath }}{{ if .Values.clusterResource.expectedValue }} (expected: {{ .Values.clusterResource.expectedValue }}){{ end }}
Ensures critical configuration on a Kubernetes object matches expected value to guarantee correct behavior.
clusterResource:
checkName: Cluster resource value
kind: '{{ .Values.clusterResource.kind }}'
clusterScoped: {{ .Values.clusterResource.clusterScoped }}
{{- if not .Values.clusterResource.clusterScoped }}
namespace: '{{ .Values.clusterResource.namespace }}'
{{- end }}
name: '{{ .Values.clusterResource.name }}'
yamlPath: '{{ .Values.clusterResource.yamlPath }}'
{{- if .Values.clusterResource.expectedValue }}
expectedValue: '{{ .Values.clusterResource.expectedValue }}'
{{- end }}
{{- if .Values.clusterResource.regex }}
regex: '{{ .Values.clusterResource.regex }}'
{{- end }}
outcomes:
- fail:
message: Cluster resource field does not match expected value
- pass:
message: Cluster resource field matches expected value
{{- end }}
{{- if .Values.certificates.enabled }}
- docString: |
Title: Certificates Validity and Expiry
Requirement:
- Check certificate material in referenced secrets/configmaps
Identifies expired or soon-to-expire certificates that would break TLS handshakes.
certificates:
checkName: Certificates validity
outcomes:
- warn:
message: One or more certificates may be invalid or expiring soon
- pass:
message: Certificates are valid
{{- end }}
{{- if .Values.goldpinger.enabled }}
- docString: |
Title: Goldpinger Network Health
Requirement:
- Collector: {{ .Values.goldpinger.collectorName }}
- Report path: {{ .Values.goldpinger.filePath }}
Uses Goldpinger probes to detect DNS, network, and kube-proxy issues across the cluster.
goldpinger:
checkName: Goldpinger report
collectorName: '{{ .Values.goldpinger.collectorName }}'
filePath: '{{ .Values.goldpinger.filePath }}'
outcomes:
- fail:
message: Goldpinger indicates network issues
- pass:
message: Goldpinger indicates healthy networking
{{- end }}
{{- if .Values.event.enabled }}
- docString: |
Title: Kubernetes Events Scan
Requirement:
- Namespace: {{ .Values.event.namespace }}
- Reason: {{ .Values.event.reason }}{{ if .Values.event.kind }} (kind: {{ .Values.event.kind }}){{ end }}{{ if .Values.event.regex }} (regex: {{ .Values.event.regex }}){{ end }}
Surfaces critical events that often correlate with configuration issues, crash loops, or cluster instability.
event:
checkName: Events
collectorName: '{{ .Values.event.collectorName }}'
namespace: '{{ .Values.event.namespace }}'
{{- if .Values.event.kind }}
kind: '{{ .Values.event.kind }}'
{{- end }}
reason: '{{ .Values.event.reason }}'
{{- if .Values.event.regex }}
regex: '{{ .Values.event.regex }}'
{{- end }}
outcomes:
- fail:
when: 'true'
message: Critical events detected
- pass:
when: 'false'
message: No critical events detected
{{- end }}
{{- if .Values.nodeMetrics.enabled }}
- docString: |
Title: Node Metrics Thresholds
Requirement:
- Filters: PVC nameRegex={{ .Values.nodeMetrics.filters.pvc.nameRegex }}{{ if .Values.nodeMetrics.filters.pvc.namespace }}, namespace={{ .Values.nodeMetrics.filters.pvc.namespace }}{{ end }}
Evaluates node-level metrics to detect capacity pressure and performance bottlenecks.
nodeMetrics:
checkName: Node metrics thresholds
collectorName: '{{ .Values.nodeMetrics.collectorName }}'
{{- if .Values.nodeMetrics.filters.pvc.nameRegex }}
filters:
pvc:
nameRegex: '{{ .Values.nodeMetrics.filters.pvc.nameRegex }}'
{{- if .Values.nodeMetrics.filters.pvc.namespace }}
namespace: '{{ .Values.nodeMetrics.filters.pvc.namespace }}'
{{- end }}
{{- end }}
outcomes:
- warn:
message: Node metrics exceed warning thresholds
- pass:
message: Node metrics within thresholds
{{- end }}
{{- if .Values.http.enabled }}
- docString: |
Title: HTTP Endpoint Health Checks
Requirement:
- Collected results: {{ .Values.http.collectorName }}
Validates availability of service HTTP endpoints used by the application.
http:
checkName: HTTP checks
collectorName: '{{ .Values.http.collectorName }}'
outcomes:
- fail:
message: One or more HTTP checks failed
- pass:
message: All HTTP checks passed
{{- end }}
+244
View File
@@ -0,0 +1,244 @@
apiVersion: troubleshoot.sh/v1beta3
kind: Preflight
metadata:
name: templated-from-v1beta2
spec:
analyzers:
{{- if .Values.kubernetes.enabled }}
- docString: |
Title: Kubernetes Control Plane Requirements
Requirement:
- Version:
- Minimum: {{ .Values.kubernetes.minVersion }}
- Recommended: {{ .Values.kubernetes.recommendedVersion }}
- Docs: https://kubernetes.io
These version targets ensure that required APIs and default behaviors are
available and patched. Moving below the minimum commonly removes GA APIs
(e.g., apps/v1 workloads, storage and ingress v1 APIs), changes admission
defaults, and lacks critical CVE fixes. Running at or above the recommended
version matches what is exercised most extensively in CI and receives the
best operational guidance for upgrades and incident response.
clusterVersion:
checkName: Kubernetes version
outcomes:
- fail:
when: '< {{ .Values.kubernetes.minVersion }}'
message: This application requires at least Kubernetes {{ .Values.kubernetes.minVersion }}, and recommends {{ .Values.kubernetes.recommendedVersion }}.
uri: https://www.kubernetes.io
- warn:
when: '< {{ .Values.kubernetes.recommendedVersion }}'
message: Your cluster meets the minimum version of Kubernetes, but we recommend you update to {{ .Values.kubernetes.recommendedVersion }} or later.
uri: https://kubernetes.io
- pass:
when: '>= {{ .Values.kubernetes.recommendedVersion }}'
message: Your cluster meets the recommended and required versions of Kubernetes.
{{- end }}
{{- if .Values.ingress.enabled }}
- docString: |
Title: Required CRDs and Ingress Capabilities
Requirement:
- Ingress Controller: Contour
- CRD must be present:
- Group: heptio.com
- Kind: IngressRoute
- Version: v1beta1 or later served version
The ingress layer terminates TLS and routes external traffic to Services.
Contour relies on the IngressRoute CRD to express host/path routing, TLS
configuration, and policy. If the CRD is not installed and served by the
API server, Contour cannot reconcile desired state, leaving routes
unconfigured and traffic unreachable.
{{- if eq .Values.ingress.type "Contour" }}
customResourceDefinition:
checkName: Contour IngressRoute CRD
customResourceDefinitionName: ingressroutes.contour.heptio.com
outcomes:
- fail:
message: Contour IngressRoute CRD not found; required for ingress routing
- pass:
message: Contour IngressRoute CRD present
{{- end }}
{{- end }}
{{- if .Values.runtime.enabled }}
- docString: |
Title: Container Runtime Requirements
Requirement:
- Runtime: containerd (CRI)
- Kubelet cgroup driver: systemd
- CRI socket path: /run/containerd/containerd.sock
containerd (via the CRI) is the supported runtime for predictable container
lifecycle management. On modern distros (cgroup v2), kubelet and the OS must
both use the systemd cgroup driver to avoid resource accounting mismatches
that lead to unexpected OOMKills and throttling. The CRI socket path must
match kubelet configuration so the node can start and manage pods.
containerRuntime:
outcomes:
- pass:
when: '== containerd'
message: containerd runtime detected
- fail:
message: Unsupported container runtime; containerd required
{{- end }}
{{- if .Values.storage.enabled }}
- docString: |
Title: Default StorageClass Requirements
Requirement:
- A StorageClass named "{{ .Values.storage.className }}" must exist (cluster default preferred)
- AccessMode: ReadWriteOnce (RWO) required (RWX optional)
- VolumeBindingMode: WaitForFirstConsumer preferred
- allowVolumeExpansion: true recommended
A default StorageClass enables dynamic PVC provisioning without manual
intervention. RWO provides baseline persistence semantics for stateful pods.
WaitForFirstConsumer defers binding until a pod is scheduled, improving
topology-aware placement (zonal/az) and reducing unschedulable PVCs.
AllowVolumeExpansion permits online growth during capacity pressure
without disruptive migrations.
storageClass:
checkName: Default StorageClass
storageClassName: '{{ .Values.storage.className }}'
outcomes:
- fail:
message: Default StorageClass not found
- pass:
message: Default StorageClass present
{{- end }}
{{- if .Values.distribution.enabled }}
- docString: |
Title: Kubernetes Distribution Support
Requirement:
- Unsupported: docker-desktop, microk8s, minikube
- Supported: eks, gke, aks, kurl, digitalocean, rke2, k3s, oke, kind
Development or single-node environments are optimized for local testing and
omit HA control-plane patterns, cloud integration, and production defaults.
The supported distributions are validated for API compatibility, RBAC
expectations, admission behavior, and default storage/networking this
application depends on.
distribution:
outcomes:
- fail:
when: '== docker-desktop'
message: The application does not support Docker Desktop Clusters
- fail:
when: '== microk8s'
message: The application does not support Microk8s Clusters
- fail:
when: '== minikube'
message: The application does not support Minikube Clusters
- pass:
when: '== eks'
message: EKS is a supported distribution
- pass:
when: '== gke'
message: GKE is a supported distribution
- pass:
when: '== aks'
message: AKS is a supported distribution
- pass:
when: '== kurl'
message: KURL is a supported distribution
- pass:
when: '== digitalocean'
message: DigitalOcean is a supported distribution
- pass:
when: '== rke2'
message: RKE2 is a supported distribution
- pass:
when: '== k3s'
message: K3S is a supported distribution
- pass:
when: '== oke'
message: OKE is a supported distribution
- pass:
when: '== kind'
message: Kind is a supported distribution
- warn:
message: Unable to determine the distribution of Kubernetes
{{- end }}
{{- if .Values.nodeChecks.count.enabled }}
- docString: |
Title: Node count requirement
Requirement:
- Node count: Minimum {{ .Values.cluster.minNodes }} nodes, Recommended {{ .Values.cluster.recommendedNodes }} nodes
Multiple worker nodes provide scheduling capacity, tolerance to disruptions,
and safe rolling updates. Operating below the recommendation increases risk
of unschedulable pods during maintenance or failures and reduces headroom
for horizontal scaling.
nodeResources:
checkName: Node count
outcomes:
- fail:
when: 'count() < {{ .Values.cluster.minNodes }}'
message: This application requires at least {{ .Values.cluster.minNodes }} nodes.
uri: https://kurl.sh/docs/install-with-kurl/adding-nodes
- warn:
when: 'count() < {{ .Values.cluster.recommendedNodes }}'
message: This application recommends at least {{ .Values.cluster.recommendedNodes }} nodes.
uri: https://kurl.sh/docs/install-with-kurl/adding-nodes
- pass:
message: This cluster has enough nodes.
{{- end }}
{{- if .Values.nodeChecks.cpu.enabled }}
- docString: |
Title: Cluster CPU requirement
Requirement:
- Total CPU: Minimum {{ .Values.cluster.minCPU }} vCPU
Aggregate CPU must cover system daemons, controllers, and application pods.
Insufficient CPU causes prolonged scheduling latency, readiness probe
failures, and throughput collapse under load.
nodeResources:
checkName: Cluster CPU total
outcomes:
- fail:
when: 'sum(cpuCapacity) < {{ .Values.cluster.minCPU }}'
message: The cluster must contain at least {{ .Values.cluster.minCPU }} cores
uri: https://kurl.sh/docs/install-with-kurl/system-requirements
- pass:
message: There are at least {{ .Values.cluster.minCPU }} cores in the cluster
{{- end }}
{{- if .Values.nodeChecks.memory.enabled }}
- docString: |
Title: Per-node memory requirement
Requirement:
- Per-node memory: Minimum {{ .Values.node.minMemoryGi }} GiB; Recommended {{ .Values.node.recommendedMemoryGi }} GiB
Nodes must reserve memory for kubelet/system components and per-pod overhead.
Below the minimum, pods will frequently be OOMKilled or evicted. The
recommended capacity provides headroom for spikes, compactions, and
upgrades without destabilizing workloads.
nodeResources:
checkName: Per-node memory requirement
outcomes:
- fail:
when: 'min(memoryCapacity) < {{ .Values.node.minMemoryGi }}Gi'
message: All nodes must have at least {{ .Values.node.minMemoryGi }} GiB of memory.
uri: https://kurl.sh/docs/install-with-kurl/system-requirements
- warn:
when: 'min(memoryCapacity) < {{ .Values.node.recommendedMemoryGi }}Gi'
message: All nodes are recommended to have at least {{ .Values.node.recommendedMemoryGi }} GiB of memory.
uri: https://kurl.sh/docs/install-with-kurl/system-requirements
- pass:
message: All nodes have at least {{ .Values.node.recommendedMemoryGi }} GiB of memory.
{{- end }}
{{- if .Values.nodeChecks.ephemeral.enabled }}
- docString: |
Title: Per-node ephemeral storage requirement
Requirement:
- Per-node ephemeral storage: Minimum {{ .Values.node.minEphemeralGi }} GiB; Recommended {{ .Values.node.recommendedEphemeralGi }} GiB
Ephemeral storage backs image layers, writable container filesystems, logs,
and temporary data. When capacity is low, kubelet enters disk-pressure
eviction and image pulls fail, causing pod restarts and data loss for
transient files.
nodeResources:
checkName: Per-node ephemeral storage requirement
outcomes:
- fail:
when: 'min(ephemeralStorageCapacity) < {{ .Values.node.minEphemeralGi }}Gi'
message: All nodes must have at least {{ .Values.node.minEphemeralGi }} GiB of ephemeral storage.
uri: https://kurl.sh/docs/install-with-kurl/system-requirements
- warn:
when: 'min(ephemeralStorageCapacity) < {{ .Values.node.recommendedEphemeralGi }}Gi'
message: All nodes are recommended to have at least {{ .Values.node.recommendedEphemeralGi }} GiB of ephemeral storage.
uri: https://kurl.sh/docs/install-with-kurl/system-requirements
- pass:
message: All nodes have at least {{ .Values.node.recommendedEphemeralGi }} GiB of ephemeral storage.
{{- end }}
+229
View File
@@ -0,0 +1,229 @@
clusterVersion:
enabled: true
minVersion: "1.24.0"
recommendedVersion: "1.28.0"
crd:
enabled: true
name: "samples.mycompany.com"
ingress:
enabled: true
namespace: "default"
name: "example"
secret:
enabled: true
namespace: "default"
name: "my-secret"
key: ""
configMap:
enabled: true
namespace: "kube-public"
name: "cluster-info"
key: ""
imagePullSecret:
enabled: true
registry: "registry.example.com"
workloads:
deployments:
enabled: true
namespace: "default"
name: "example-deploy"
minReady: 1
statefulsets:
enabled: true
namespace: "default"
name: "example-sts"
minReady: 1
jobs:
enabled: true
namespace: "default"
name: "example-job"
replicasets:
enabled: true
namespace: "default"
name: "example-rs"
minReady: 1
clusterPodStatuses:
enabled: true
namespaces:
- "default"
- "kube-system"
clusterContainerStatuses:
enabled: true
namespaces:
- "default"
- "kube-system"
restartCount: 3
containerRuntime:
enabled: true
distribution:
enabled: true
supported: ["eks", "gke", "aks", "kubeadm"]
unsupported: []
nodeResources:
count:
enabled: true
min: 1
recommended: 3
cpu:
enabled: true
min: "4"
memory:
enabled: true
minGi: 8
recommendedGi: 16
ephemeral:
enabled: true
minGi: 20
recommendedGi: 50
textAnalyze:
enabled: true
fileName: "logs/*.log"
regex: "error"
yamlCompare:
enabled: true
fileName: "kube-system/sample.yaml"
path: "spec.replicas"
value: "3"
jsonCompare:
enabled: true
fileName: "custom/sample.json"
jsonPath: "$.items[0].status"
value: "Running"
databases:
postgres:
enabled: true
collectorName: "postgres"
uri: "postgres://user:pass@postgres:5432/db?sslmode=disable"
tls:
skipVerify: true
secret:
name: ""
namespace: ""
mssql:
enabled: true
collectorName: "mssql"
uri: "sqlserver://user:pass@mssql:1433?database=db"
mysql:
enabled: true
collectorName: "mysql"
uri: "mysql://user:pass@tcp(mysql:3306)/db"
redis:
enabled: true
collectorName: "redis"
uri: "redis://redis:6379"
cephStatus:
enabled: true
namespace: "rook-ceph"
timeout: "30s"
velero:
enabled: true
longhorn:
enabled: true
namespace: "longhorn-system"
timeout: "30s"
registryImages:
enabled: true
collectorName: "images"
namespace: "default"
imagePullSecret:
name: ""
data: {}
images:
- "alpine:3.19"
- "busybox:1.36"
http:
enabled: true
collectorName: "http"
get:
url: "https://example.com/healthz"
timeout: "10s"
insecureSkipVerify: true
headers: {}
post:
url: ""
timeout: ""
insecureSkipVerify: true
headers: {}
body: ""
weaveReport:
enabled: true
reportFileGlob: "weave/*.json"
sysctl:
enabled: true
namespace: "default"
image: "busybox:1.36"
imagePullPolicy: "IfNotPresent"
clusterResource:
enabled: true
kind: "Deployment"
clusterScoped: true
namespace: "default"
name: "example-deploy"
yamlPath: "spec.replicas"
expectedValue: "3"
regex: ""
certificates:
enabled: true
secrets:
- name: ""
namespaces: []
configMaps:
- name: ""
namespaces: []
goldpinger:
enabled: true
collectorName: "goldpinger"
filePath: "goldpinger/check-all.json"
namespace: "default"
collectDelay: "30s"
podLaunch:
namespace: ""
image: ""
imagePullSecret:
name: ""
serviceAccountName: ""
event:
enabled: true
collectorName: "events"
namespace: "default"
kind: "Pod"
reason: "Unhealthy"
regex: ""
nodeMetrics:
enabled: true
collectorName: "node-metrics"
filters:
pvc:
nameRegex: ""
namespace: ""
nodeNames: []
selector: []
@@ -0,0 +1,4 @@
clusterVersion:
enabled: true
minVersion: "1.24.0"
recommendedVersion: "1.28.0"
+66
View File
@@ -0,0 +1,66 @@
# Values for v1beta3-templated-from-v1beta2.yaml
kubernetes:
enabled: true
minVersion: "1.22.0"
recommendedVersion: "1.29.0"
storage:
enabled: true
className: "default"
cluster:
minNodes: 3
recommendedNodes: 5
minCPU: 4
node:
minMemoryGi: 8
recommendedMemoryGi: 32
minEphemeralGi: 40
recommendedEphemeralGi: 100
ingress:
enabled: true
type: "Contour"
contour:
crdName: "ingressroutes.contour.heptio.com"
crdGroup: "heptio.com"
crdKind: "IngressRoute"
crdVersion: "v1beta1 or later served version"
runtime:
enabled: true
name: "containerd"
cgroupDriver: "systemd"
criSocket: "/run/containerd/containerd.sock"
distribution:
enabled: true
unsupported:
- docker-desktop
- microk8s
- minikube
supported:
- eks
- gke
- aks
- kurl
- digitalocean
- rke2
- k3s
- oke
- kind
nodeChecks:
enabled: true
count:
enabled: true
cpu:
enabled: true
memory:
enabled: true
ephemeral:
enabled: true
+16
View File
@@ -0,0 +1,16 @@
# Minimal values for v1beta3-templated-from-v1beta2.yaml
kubernetes:
enabled: true
minVersion: "1.22.0"
recommendedVersion: "1.29.0"
storage:
enabled: true
className: "default"
nodeChecks:
cpu:
enabled: false
ephemeral:
enabled: false
+10
View File
@@ -0,0 +1,10 @@
cluster:
minNodes: 3
recommendedNodes: 3
minCPU: 4
node:
minMemoryGi: 8
recommendedMemoryGi: 16
minEphemeralGi: 40
recommendedEphemeralGi: 40
+26
View File
@@ -0,0 +1,26 @@
ingress:
enabled: true
type: "Contour"
runtime:
enabled: true
distribution:
enabled: true
nodeChecks:
enabled: true
count:
enabled: true
cpu:
enabled: true
memory:
enabled: true
ephemeral:
enabled: true
kubernetes:
enabled: false
minVersion: "1.22.0"
recommendedVersion: "1.29.0"
-52
View File
@@ -1,52 +0,0 @@
package main
import "C"
import (
"encoding/json"
"fmt"
analyzer "github.com/replicatedhq/troubleshoot/pkg/analyze"
"github.com/replicatedhq/troubleshoot/pkg/convert"
"github.com/replicatedhq/troubleshoot/pkg/logger"
"gopkg.in/yaml.v2"
)
//export Analyze
func Analyze(bundleURL string, analyzers string, outputFormat string, compatibility string) *C.char {
logger.SetQuiet(true)
result, err := analyzer.DownloadAndAnalyze(bundleURL, analyzers)
if err != nil {
fmt.Printf("error downloading and analyzing: %s\n", err.Error())
return C.CString("")
}
var data interface{}
switch compatibility {
case "support-bundle":
data = convert.FromAnalyzerResult(result)
default:
data = result
}
var formatted []byte
switch outputFormat {
case "json":
formatted, err = json.MarshalIndent(data, "", " ")
case "", "yaml":
formatted, err = yaml.Marshal(data)
default:
fmt.Printf("unknown output format: %s\n", outputFormat)
return C.CString("")
}
if err != nil {
fmt.Printf("error formatting output: %#v\n", err)
return C.CString("")
}
return C.CString(string(formatted))
}
func main() {}
+1 -2
View File
@@ -104,7 +104,6 @@ require (
github.com/containerd/platforms v0.2.1 // indirect
github.com/containerd/typeurl/v2 v2.2.3 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/ebitengine/purego v0.8.4 // indirect
@@ -250,7 +249,7 @@ require (
github.com/opencontainers/selinux v1.12.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.62.0 // indirect
-1
View File
@@ -178,7 +178,6 @@ github.com/containers/storage v1.59.1 h1:11Zu68MXsEQGBBd+GadPrHPpWeqjKS8hJDGiAHg
github.com/containers/storage v1.59.1/go.mod h1:KoAYHnAjP3/cTsRS+mmWZGkufSY2GACiKQ4V3ZLQnR0=
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
+1 -1
View File
@@ -848,7 +848,7 @@ func Test_nodeMatchesFilters(t *testing.T) {
Taints: []corev1.Taint{},
},
},
filters: &troubleshootv1beta2.NodeResourceFilters{},
filters: &troubleshootv1beta2.NodeResourceFilters{},
expectResult: true,
},
{
+29 -1
View File
@@ -37,9 +37,23 @@ func analyzeTextAnalyze(
analyzer *troubleshootv1beta2.TextAnalyze, getCollectedFileContents getChildCollectedFileContents, title string,
) ([]*AnalyzeResult, error) {
fullPath := filepath.Join(analyzer.CollectorName, analyzer.FileName)
// Auto-handle exec collector output files which are nested deeper than expected
// Exec collectors store files in: {collectorName}/{namespace}/{podName}/{fileName}
// But textAnalyze expects: {collectorName}/{fileName}
// If the fileName looks like exec output and doesn't already have wildcards, make it work automatically
if isLikelyExecOutput(analyzer.FileName) && !containsWildcards(analyzer.FileName) && !containsWildcards(fullPath) {
fullPath = filepath.Join(analyzer.CollectorName, "*", "*", analyzer.FileName)
}
excludeFiles := []string{}
for _, excludeFile := range analyzer.ExcludeFiles {
excludeFiles = append(excludeFiles, filepath.Join(analyzer.CollectorName, excludeFile))
excludePath := filepath.Join(analyzer.CollectorName, excludeFile)
// Apply same logic to exclude files
if isLikelyExecOutput(excludeFile) && !containsWildcards(excludeFile) && !containsWildcards(excludePath) {
excludePath = filepath.Join(analyzer.CollectorName, "*", "*", excludeFile)
}
excludeFiles = append(excludeFiles, excludePath)
}
collected, err := getCollectedFileContents(fullPath, excludeFiles)
@@ -108,6 +122,20 @@ func analyzeTextAnalyze(
}, nil
}
// isLikelyExecOutput checks if a filename looks like exec collector output
func isLikelyExecOutput(fileName string) bool {
return strings.HasSuffix(fileName, "-stdout.txt") ||
strings.HasSuffix(fileName, "-stderr.txt") ||
strings.HasSuffix(fileName, "-errors.json")
}
// containsWildcards checks if a path contains glob wildcards
func containsWildcards(path string) bool {
return strings.Contains(path, "*") ||
strings.Contains(path, "?") ||
strings.Contains(path, "[")
}
func analyzeRegexPattern(pattern string, collected []byte, outcomes []*troubleshootv1beta2.Outcome, checkName string) (*AnalyzeResult, error) {
re, err := regexp.Compile(pattern)
if err != nil {
+100
View File
@@ -776,6 +776,106 @@ func Test_textAnalyze(t *testing.T) {
"text-collector-1/cfile-2.txt": []byte("Yes it all succeeded"),
},
},
{
name: "exec collector auto-path matching for stdout",
analyzer: troubleshootv1beta2.TextAnalyze{
Outcomes: []*troubleshootv1beta2.Outcome{
{
Pass: &troubleshootv1beta2.SingleOutcome{
Message: "Command output found",
},
},
{
Fail: &troubleshootv1beta2.SingleOutcome{
Message: "Command output not found",
},
},
},
CollectorName: "netbox-branch-check",
FileName: "netbox-branch-check-stdout.txt", // Simple filename, but file is nested deeper
RegexPattern: "success",
},
expectResult: []AnalyzeResult{
{
IsPass: true,
IsWarn: false,
IsFail: false,
Title: "netbox-branch-check",
Message: "Command output found",
IconKey: "kubernetes_text_analyze",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
},
},
files: map[string][]byte{
// File is stored in exec-style nested path: {collector}/{namespace}/{pod}/{collector}-stdout.txt
"netbox-branch-check/netbox-enterprise/netbox-enterprise-858bcb8d4-cdgk7/netbox-branch-check-stdout.txt": []byte("operation success completed"),
},
},
{
name: "exec collector auto-path matching for stderr",
analyzer: troubleshootv1beta2.TextAnalyze{
Outcomes: []*troubleshootv1beta2.Outcome{
{
Pass: &troubleshootv1beta2.SingleOutcome{
Message: "No errors in stderr",
When: "false",
},
},
{
Fail: &troubleshootv1beta2.SingleOutcome{
Message: "Error found in stderr",
When: "true",
},
},
},
CollectorName: "my-exec-collector",
FileName: "my-exec-collector-stderr.txt",
RegexPattern: "error",
},
expectResult: []AnalyzeResult{
{
IsPass: false,
IsWarn: false,
IsFail: true,
Title: "my-exec-collector",
Message: "Error found in stderr",
IconKey: "kubernetes_text_analyze",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
},
},
files: map[string][]byte{
"my-exec-collector/default/my-pod-12345/my-exec-collector-stderr.txt": []byte("connection error occurred"),
},
},
{
name: "exec collector no auto-match when wildcards already present",
analyzer: troubleshootv1beta2.TextAnalyze{
Outcomes: []*troubleshootv1beta2.Outcome{
{
Pass: &troubleshootv1beta2.SingleOutcome{
Message: "Found with existing wildcard",
},
},
},
CollectorName: "test-collector",
FileName: "*/test-collector-stdout.txt", // Already has wildcard, should not be modified
RegexPattern: "output",
},
expectResult: []AnalyzeResult{
{
IsPass: true,
IsWarn: false,
IsFail: false,
Title: "test-collector",
Message: "Found with existing wildcard",
IconKey: "kubernetes_text_analyze",
IconURI: "https://troubleshoot.sh/images/analyzer-icons/text-analyze.svg",
},
},
files: map[string][]byte{
"test-collector/something/test-collector-stdout.txt": []byte("some output here"),
},
},
}
for _, test := range tests {
+359
View File
@@ -0,0 +1,359 @@
package autodiscovery
import (
"context"
"fmt"
"time"
"github.com/pkg/errors"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/klog/v2"
)
// Discoverer implements the AutoCollector interface
type Discoverer struct {
clientConfig *rest.Config
client kubernetes.Interface
rbacChecker *RBACChecker
expander *ResourceExpander
}
// NewDiscoverer creates a new autodiscovery discoverer
func NewDiscoverer(clientConfig *rest.Config, client kubernetes.Interface) (*Discoverer, error) {
if clientConfig == nil {
return nil, errors.New("client config is required")
}
if client == nil {
return nil, errors.New("kubernetes client is required")
}
rbacChecker, err := NewRBACChecker(client)
if err != nil {
return nil, errors.Wrap(err, "failed to create RBAC checker")
}
expander := NewResourceExpander()
return &Discoverer{
clientConfig: clientConfig,
client: client,
rbacChecker: rbacChecker,
expander: expander,
}, nil
}
// DiscoverFoundational discovers foundational collectors based on cluster state (Path 1)
func (d *Discoverer) DiscoverFoundational(ctx context.Context, opts DiscoveryOptions) ([]CollectorSpec, error) {
klog.V(2).Infof("Starting foundational discovery for namespaces: %v", opts.Namespaces)
// Set default timeout if not provided
if opts.Timeout == 0 {
opts.Timeout = 30 * time.Second
}
// Create context with timeout
discoveryCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
// Get target namespaces
namespaces, err := d.getTargetNamespaces(discoveryCtx, opts.Namespaces)
if err != nil {
return nil, errors.Wrap(err, "failed to get target namespaces")
}
// Generate foundational collectors
foundationalCollectors := d.generateFoundationalCollectors(namespaces, opts)
// Apply RBAC filtering if enabled
if opts.RBACCheck {
filteredCollectors, err := d.applyRBACFiltering(discoveryCtx, foundationalCollectors)
if err != nil {
klog.Warningf("RBAC filtering failed, proceeding without: %v", err)
} else {
foundationalCollectors = filteredCollectors
}
}
klog.V(2).Infof("Discovered %d foundational collectors", len(foundationalCollectors))
return foundationalCollectors, nil
}
// AugmentWithFoundational augments existing YAML collectors with foundational collectors (Path 2)
func (d *Discoverer) AugmentWithFoundational(ctx context.Context, yamlCollectors []CollectorSpec, opts DiscoveryOptions) ([]CollectorSpec, error) {
klog.V(2).Infof("Augmenting %d YAML collectors with foundational collectors", len(yamlCollectors))
// First, get foundational collectors
foundationalCollectors, err := d.DiscoverFoundational(ctx, opts)
if err != nil {
return nil, errors.Wrap(err, "failed to discover foundational collectors")
}
// Convert YAML collectors to CollectorSpec format if needed
yamlSpecs := make([]CollectorSpec, len(yamlCollectors))
for i, collector := range yamlCollectors {
collector.Source = SourceYAML
yamlSpecs[i] = collector
}
// Merge and deduplicate collectors
mergedCollectors := d.mergeAndDeduplicateCollectors(yamlSpecs, foundationalCollectors)
klog.V(2).Infof("Merged result: %d total collectors (%d YAML + %d foundational, deduplicated)",
len(mergedCollectors), len(yamlSpecs), len(foundationalCollectors))
return mergedCollectors, nil
}
// ValidatePermissions validates RBAC permissions for discovered resources
func (d *Discoverer) ValidatePermissions(ctx context.Context, resources []Resource) ([]Resource, error) {
if d.rbacChecker == nil {
return resources, nil
}
return d.rbacChecker.FilterByPermissions(ctx, resources)
}
// getTargetNamespaces returns the list of namespaces to target for discovery
func (d *Discoverer) getTargetNamespaces(ctx context.Context, requestedNamespaces []string) ([]string, error) {
if len(requestedNamespaces) > 0 {
return requestedNamespaces, nil
}
// If no namespaces specified, get all accessible namespaces
namespaceList, err := d.client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
if err != nil {
// Fall back to default namespace if we can't list namespaces
klog.Warningf("Could not list namespaces, falling back to 'default': %v", err)
return []string{"default"}, nil
}
var namespaces []string
for _, ns := range namespaceList.Items {
namespaces = append(namespaces, ns.Name)
}
return namespaces, nil
}
// generateFoundationalCollectors creates the standard set of foundational collectors
func (d *Discoverer) generateFoundationalCollectors(namespaces []string, opts DiscoveryOptions) []CollectorSpec {
var collectors []CollectorSpec
// Always include cluster-level info
collectors = append(collectors, d.generateClusterInfoCollectors()...)
// Add namespace-scoped collectors for each target namespace
for _, namespace := range namespaces {
collectors = append(collectors, d.generateNamespacedCollectors(namespace, opts)...)
}
return collectors
}
// generateClusterInfoCollectors creates cluster-level collectors
func (d *Discoverer) generateClusterInfoCollectors() []CollectorSpec {
return []CollectorSpec{
{
Type: CollectorTypeClusterInfo,
Name: "cluster-info",
Spec: &troubleshootv1beta2.ClusterInfo{},
Priority: 100,
Source: SourceFoundational,
},
{
Type: CollectorTypeClusterResources,
Name: "cluster-resources",
Spec: &troubleshootv1beta2.ClusterResources{},
Priority: 100,
Source: SourceFoundational,
},
}
}
// generateNamespacedCollectors creates namespace-specific collectors
func (d *Discoverer) generateNamespacedCollectors(namespace string, opts DiscoveryOptions) []CollectorSpec {
var collectors []CollectorSpec
// Pod logs collector
collectors = append(collectors, CollectorSpec{
Type: CollectorTypeLogs,
Name: fmt.Sprintf("logs-%s", namespace),
Namespace: namespace,
Spec: &troubleshootv1beta2.Logs{
CollectorMeta: troubleshootv1beta2.CollectorMeta{
CollectorName: fmt.Sprintf("logs/%s", namespace),
},
Namespace: namespace,
Selector: []string{}, // Empty selector to collect all pods
},
Priority: 90,
Source: SourceFoundational,
})
// ConfigMaps collector
collectors = append(collectors, CollectorSpec{
Type: CollectorTypeConfigMaps,
Name: fmt.Sprintf("configmaps-%s", namespace),
Namespace: namespace,
Spec: &troubleshootv1beta2.ConfigMap{
CollectorMeta: troubleshootv1beta2.CollectorMeta{
CollectorName: fmt.Sprintf("configmaps/%s", namespace),
},
Namespace: namespace,
Selector: []string{"*"}, // Select all configmaps in namespace
IncludeAllData: true,
},
Priority: 80,
Source: SourceFoundational,
})
// Secrets collector (metadata only by default)
collectors = append(collectors, CollectorSpec{
Type: CollectorTypeSecrets,
Name: fmt.Sprintf("secrets-%s", namespace),
Namespace: namespace,
Spec: &troubleshootv1beta2.Secret{
CollectorMeta: troubleshootv1beta2.CollectorMeta{
CollectorName: fmt.Sprintf("secrets/%s", namespace),
},
Namespace: namespace,
Selector: []string{"*"}, // Select all secrets in namespace
IncludeValue: false, // Don't include secret values by default
IncludeAllData: false, // Don't include secret data by default for security
},
Priority: 70,
Source: SourceFoundational,
})
// Add image facts collector if requested
if opts.IncludeImages {
collectors = append(collectors, CollectorSpec{
Type: CollectorTypeImageFacts,
Name: fmt.Sprintf("image-facts-%s", namespace),
Namespace: namespace,
Spec: &troubleshootv1beta2.Data{
CollectorMeta: troubleshootv1beta2.CollectorMeta{
CollectorName: fmt.Sprintf("image-facts/%s", namespace),
},
Name: fmt.Sprintf("image-facts-%s", namespace),
Data: "", // Empty - actual image facts JSON data will be collected at runtime
},
Priority: 60,
Source: SourceFoundational,
})
}
return collectors
}
// applyRBACFiltering filters collectors based on RBAC permissions
func (d *Discoverer) applyRBACFiltering(ctx context.Context, collectors []CollectorSpec) ([]CollectorSpec, error) {
// Convert collectors to resources for RBAC checking
var resources []Resource
for _, collector := range collectors {
resource := d.collectorToResource(collector)
if resource != nil {
resources = append(resources, *resource)
}
}
// Filter by permissions
allowedResources, err := d.rbacChecker.FilterByPermissions(ctx, resources)
if err != nil {
return nil, errors.Wrap(err, "failed to filter by permissions")
}
// Create map of allowed resource keys
allowedKeys := make(map[string]bool)
for _, resource := range allowedResources {
key := fmt.Sprintf("%s/%s/%s/%s", resource.APIVersion, resource.Kind, resource.Namespace, resource.Name)
allowedKeys[key] = true
}
// Filter collectors based on allowed resources
var filteredCollectors []CollectorSpec
for _, collector := range collectors {
resource := d.collectorToResource(collector)
if resource == nil {
// If we can't convert to resource, include it (might be cluster-level)
filteredCollectors = append(filteredCollectors, collector)
continue
}
key := fmt.Sprintf("%s/%s/%s/%s", resource.APIVersion, resource.Kind, resource.Namespace, resource.Name)
if allowedKeys[key] {
filteredCollectors = append(filteredCollectors, collector)
} else {
klog.V(3).Infof("Filtered out collector %s due to RBAC permissions", collector.Name)
}
}
return filteredCollectors, nil
}
// collectorToResource converts a CollectorSpec to a Resource for RBAC checking
func (d *Discoverer) collectorToResource(collector CollectorSpec) *Resource {
switch collector.Type {
case CollectorTypeLogs:
return &Resource{
APIVersion: "v1",
Kind: "Pod",
Namespace: collector.Namespace,
Name: "*",
}
case CollectorTypeConfigMaps:
return &Resource{
APIVersion: "v1",
Kind: "ConfigMap",
Namespace: collector.Namespace,
Name: "*",
}
case CollectorTypeSecrets:
return &Resource{
APIVersion: "v1",
Kind: "Secret",
Namespace: collector.Namespace,
Name: "*",
}
case CollectorTypeClusterInfo, CollectorTypeClusterResources:
return &Resource{
APIVersion: "v1",
Kind: "Node",
Namespace: "",
Name: "*",
}
default:
return nil
}
}
// mergeAndDeduplicateCollectors merges YAML and foundational collectors, removing duplicates
func (d *Discoverer) mergeAndDeduplicateCollectors(yamlCollectors, foundationalCollectors []CollectorSpec) []CollectorSpec {
collectorMap := make(map[string]CollectorSpec)
// Add foundational collectors first (lower priority)
for _, collector := range foundationalCollectors {
key := collector.GetUniqueKey()
collectorMap[key] = collector
}
// Add YAML collectors (higher priority, will override foundational)
for _, collector := range yamlCollectors {
key := collector.GetUniqueKey()
if existing, exists := collectorMap[key]; exists {
klog.V(3).Infof("YAML collector %s overriding foundational collector %s", collector.Name, existing.Name)
}
collectorMap[key] = collector
}
// Convert map back to slice
var result []CollectorSpec
for _, collector := range collectorMap {
result = append(result, collector)
}
return result
}
@@ -0,0 +1,575 @@
package autodiscovery
import (
"context"
"fmt"
"testing"
"time"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/fake"
"k8s.io/client-go/rest"
)
func TestNewDiscoverer(t *testing.T) {
tests := []struct {
name string
clientConfig *rest.Config
client kubernetes.Interface
wantErr bool
}{
{
name: "valid parameters",
clientConfig: &rest.Config{},
client: fake.NewSimpleClientset(),
wantErr: false,
},
{
name: "nil client config",
clientConfig: nil,
client: fake.NewSimpleClientset(),
wantErr: true,
},
{
name: "nil client",
clientConfig: &rest.Config{},
client: nil,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
discoverer, err := NewDiscoverer(tt.clientConfig, tt.client)
if (err != nil) != tt.wantErr {
t.Errorf("NewDiscoverer() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && discoverer == nil {
t.Error("NewDiscoverer() returned nil discoverer")
}
})
}
}
func TestDiscoverer_DiscoverFoundational(t *testing.T) {
// Create fake client with test data
client := fake.NewSimpleClientset()
// Add test namespaces
testNamespaces := []corev1.Namespace{
{
ObjectMeta: metav1.ObjectMeta{
Name: "default",
},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: "test-app",
},
},
{
ObjectMeta: metav1.ObjectMeta{
Name: "kube-system",
},
},
}
for _, ns := range testNamespaces {
client.CoreV1().Namespaces().Create(context.Background(), &ns, metav1.CreateOptions{})
}
discoverer, err := NewDiscoverer(&rest.Config{}, client)
if err != nil {
t.Fatalf("Failed to create discoverer: %v", err)
}
tests := []struct {
name string
opts DiscoveryOptions
wantCollectorTypes map[CollectorType]int // type -> expected count
wantMinCollectors int
wantErr bool
}{
{
name: "default options",
opts: DiscoveryOptions{
Namespaces: []string{"default"},
IncludeImages: false,
RBACCheck: false,
Timeout: 10 * time.Second,
},
wantCollectorTypes: map[CollectorType]int{
CollectorTypeClusterInfo: 1,
CollectorTypeClusterResources: 1,
CollectorTypeLogs: 1,
CollectorTypeConfigMaps: 1,
CollectorTypeSecrets: 1,
},
wantMinCollectors: 5,
wantErr: false,
},
{
name: "with images",
opts: DiscoveryOptions{
Namespaces: []string{"test-app"},
IncludeImages: true,
RBACCheck: false,
Timeout: 10 * time.Second,
},
wantCollectorTypes: map[CollectorType]int{
CollectorTypeClusterInfo: 1,
CollectorTypeClusterResources: 1,
CollectorTypeLogs: 1,
CollectorTypeConfigMaps: 1,
CollectorTypeSecrets: 1,
CollectorTypeImageFacts: 1,
},
wantMinCollectors: 6,
wantErr: false,
},
{
name: "multiple namespaces",
opts: DiscoveryOptions{
Namespaces: []string{"default", "test-app"},
IncludeImages: false,
RBACCheck: false,
Timeout: 10 * time.Second,
},
wantMinCollectors: 8, // 2 cluster + 3*2 namespace collectors
wantErr: false,
},
{
name: "no namespaces specified",
opts: DiscoveryOptions{
Namespaces: []string{},
IncludeImages: false,
RBACCheck: false,
Timeout: 10 * time.Second,
},
wantMinCollectors: 2, // At least cluster collectors
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
collectors, err := discoverer.DiscoverFoundational(ctx, tt.opts)
if (err != nil) != tt.wantErr {
t.Errorf("DiscoverFoundational() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err == nil {
if len(collectors) < tt.wantMinCollectors {
t.Errorf("DiscoverFoundational() returned %d collectors, want at least %d",
len(collectors), tt.wantMinCollectors)
}
// Check collector types if specified
if tt.wantCollectorTypes != nil {
collectorCounts := make(map[CollectorType]int)
for _, collector := range collectors {
collectorCounts[collector.Type]++
}
for expectedType, expectedCount := range tt.wantCollectorTypes {
if collectorCounts[expectedType] != expectedCount {
t.Errorf("DiscoverFoundational() got %d collectors of type %s, want %d",
collectorCounts[expectedType], expectedType, expectedCount)
}
}
}
// Verify all collectors have foundational source
for _, collector := range collectors {
if collector.Source != SourceFoundational {
t.Errorf("DiscoverFoundational() collector %s has source %s, want %s",
collector.Name, collector.Source, SourceFoundational)
}
}
}
})
}
}
func TestDiscoverer_AugmentWithFoundational(t *testing.T) {
client := fake.NewSimpleClientset()
// Add test namespace
client.CoreV1().Namespaces().Create(context.Background(), &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "test-app"},
}, metav1.CreateOptions{})
discoverer, err := NewDiscoverer(&rest.Config{}, client)
if err != nil {
t.Fatalf("Failed to create discoverer: %v", err)
}
tests := []struct {
name string
yamlCollectors []CollectorSpec
opts DiscoveryOptions
wantMinCount int
wantErr bool
}{
{
name: "augment with yaml collectors",
yamlCollectors: []CollectorSpec{
{
Type: CollectorTypeLogs,
Name: "custom-logs",
Namespace: "test-app",
Spec: &troubleshootv1beta2.Logs{},
Priority: 100,
Source: SourceYAML,
},
},
opts: DiscoveryOptions{
Namespaces: []string{"test-app"},
RBACCheck: false,
},
wantMinCount: 5, // Should have foundational + yaml (with deduplication)
wantErr: false,
},
{
name: "no yaml collectors",
yamlCollectors: []CollectorSpec{},
opts: DiscoveryOptions{
Namespaces: []string{"test-app"},
RBACCheck: false,
},
wantMinCount: 5, // Just foundational collectors
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
collectors, err := discoverer.AugmentWithFoundational(ctx, tt.yamlCollectors, tt.opts)
if (err != nil) != tt.wantErr {
t.Errorf("AugmentWithFoundational() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err == nil {
if len(collectors) < tt.wantMinCount {
t.Errorf("AugmentWithFoundational() returned %d collectors, want at least %d",
len(collectors), tt.wantMinCount)
}
// Check that YAML collectors are preserved
yamlCount := 0
foundationalCount := 0
for _, collector := range collectors {
switch collector.Source {
case SourceYAML:
yamlCount++
case SourceFoundational:
foundationalCount++
}
}
if yamlCount != len(tt.yamlCollectors) {
t.Errorf("AugmentWithFoundational() preserved %d YAML collectors, want %d",
yamlCount, len(tt.yamlCollectors))
}
if foundationalCount == 0 {
t.Error("AugmentWithFoundational() should include foundational collectors")
}
}
})
}
}
func TestDiscoverer_getTargetNamespaces(t *testing.T) {
client := fake.NewSimpleClientset()
// Add test namespaces
testNamespaces := []corev1.Namespace{
{ObjectMeta: metav1.ObjectMeta{Name: "default"}},
{ObjectMeta: metav1.ObjectMeta{Name: "test-app"}},
{ObjectMeta: metav1.ObjectMeta{Name: "kube-system"}},
}
for _, ns := range testNamespaces {
client.CoreV1().Namespaces().Create(context.Background(), &ns, metav1.CreateOptions{})
}
discoverer, err := NewDiscoverer(&rest.Config{}, client)
if err != nil {
t.Fatalf("Failed to create discoverer: %v", err)
}
tests := []struct {
name string
requestedNamespaces []string
wantNamespaces []string
wantErr bool
}{
{
name: "specific namespaces",
requestedNamespaces: []string{"default", "test-app"},
wantNamespaces: []string{"default", "test-app"},
wantErr: false,
},
{
name: "no namespaces specified",
requestedNamespaces: []string{},
wantNamespaces: []string{"default", "test-app", "kube-system"}, // All available
wantErr: false,
},
{
name: "single namespace",
requestedNamespaces: []string{"default"},
wantNamespaces: []string{"default"},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
namespaces, err := discoverer.getTargetNamespaces(ctx, tt.requestedNamespaces)
if (err != nil) != tt.wantErr {
t.Errorf("getTargetNamespaces() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err == nil {
if len(tt.requestedNamespaces) > 0 {
// For specific namespaces, check exact match
if len(namespaces) != len(tt.wantNamespaces) {
t.Errorf("getTargetNamespaces() returned %d namespaces, want %d",
len(namespaces), len(tt.wantNamespaces))
}
} else {
// For auto-discovery, check we got some namespaces
if len(namespaces) == 0 {
t.Error("getTargetNamespaces() should return at least one namespace")
}
}
}
})
}
}
func TestDiscoverer_generateFoundationalCollectors(t *testing.T) {
client := fake.NewSimpleClientset()
discoverer, err := NewDiscoverer(&rest.Config{}, client)
if err != nil {
t.Fatalf("Failed to create discoverer: %v", err)
}
tests := []struct {
name string
namespaces []string
opts DiscoveryOptions
wantMinCount int
wantClusterLevel bool
}{
{
name: "single namespace",
namespaces: []string{"default"},
opts: DiscoveryOptions{IncludeImages: false},
wantMinCount: 5, // 2 cluster + 3 namespace collectors
wantClusterLevel: true,
},
{
name: "multiple namespaces",
namespaces: []string{"default", "test-app"},
opts: DiscoveryOptions{IncludeImages: false},
wantMinCount: 8, // 2 cluster + 3*2 namespace collectors
wantClusterLevel: true,
},
{
name: "with images",
namespaces: []string{"default"},
opts: DiscoveryOptions{IncludeImages: true},
wantMinCount: 6, // 2 cluster + 4 namespace collectors
wantClusterLevel: true,
},
{
name: "no namespaces",
namespaces: []string{},
opts: DiscoveryOptions{IncludeImages: false},
wantMinCount: 2, // Just cluster collectors
wantClusterLevel: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
collectors := discoverer.generateFoundationalCollectors(tt.namespaces, tt.opts)
if len(collectors) < tt.wantMinCount {
t.Errorf("generateFoundationalCollectors() returned %d collectors, want at least %d",
len(collectors), tt.wantMinCount)
}
// Check for cluster-level collectors
hasClusterInfo := false
hasClusterResources := false
namespaceCollectors := make(map[string]int)
for _, collector := range collectors {
if collector.Type == CollectorTypeClusterInfo {
hasClusterInfo = true
}
if collector.Type == CollectorTypeClusterResources {
hasClusterResources = true
}
if collector.Namespace != "" {
namespaceCollectors[collector.Namespace]++
}
}
if tt.wantClusterLevel {
if !hasClusterInfo {
t.Error("generateFoundationalCollectors() missing cluster info collector")
}
if !hasClusterResources {
t.Error("generateFoundationalCollectors() missing cluster resources collector")
}
}
// Check namespace collectors
for _, namespace := range tt.namespaces {
if count := namespaceCollectors[namespace]; count == 0 {
t.Errorf("generateFoundationalCollectors() has no collectors for namespace %s", namespace)
}
}
})
}
}
func TestDiscoverer_mergeAndDeduplicateCollectors(t *testing.T) {
client := fake.NewSimpleClientset()
discoverer, err := NewDiscoverer(&rest.Config{}, client)
if err != nil {
t.Fatalf("Failed to create discoverer: %v", err)
}
tests := []struct {
name string
yamlCollectors []CollectorSpec
foundationalCollectors []CollectorSpec
wantCount int
wantYAMLPreferred bool
}{
{
name: "no conflicts",
yamlCollectors: []CollectorSpec{
{
Type: CollectorTypeLogs,
Name: "yaml-logs",
Namespace: "app1",
Priority: 100,
Source: SourceYAML,
},
},
foundationalCollectors: []CollectorSpec{
{
Type: CollectorTypeConfigMaps,
Name: "configmaps-app1",
Namespace: "app1",
Priority: 80,
Source: SourceFoundational,
},
},
wantCount: 2,
},
{
name: "with conflicts - YAML should win",
yamlCollectors: []CollectorSpec{
{
Type: CollectorTypeLogs,
Name: "logs-app1", // Same name to trigger deduplication
Namespace: "app1",
Priority: 100,
Source: SourceYAML,
},
},
foundationalCollectors: []CollectorSpec{
{
Type: CollectorTypeLogs,
Name: "logs-app1", // Same name to trigger deduplication
Namespace: "app1",
Priority: 90,
Source: SourceFoundational,
},
},
wantCount: 1,
wantYAMLPreferred: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
merged := discoverer.mergeAndDeduplicateCollectors(tt.yamlCollectors, tt.foundationalCollectors)
if len(merged) != tt.wantCount {
t.Errorf("mergeAndDeduplicateCollectors() returned %d collectors, want %d",
len(merged), tt.wantCount)
}
if tt.wantYAMLPreferred {
// Check that YAML collectors are preferred over foundational ones
yamlCollectorFound := false
for _, collector := range merged {
if collector.Source == SourceYAML {
yamlCollectorFound = true
break
}
}
if !yamlCollectorFound {
t.Error("mergeAndDeduplicateCollectors() should prefer YAML collectors over foundational")
}
}
})
}
}
// Benchmark tests for performance
func BenchmarkDiscoverer_DiscoverFoundational(b *testing.B) {
client := fake.NewSimpleClientset()
// Add multiple test namespaces
for i := 0; i < 10; i++ {
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("test-ns-%d", i),
},
}
client.CoreV1().Namespaces().Create(context.Background(), ns, metav1.CreateOptions{})
}
discoverer, err := NewDiscoverer(&rest.Config{}, client)
if err != nil {
b.Fatalf("Failed to create discoverer: %v", err)
}
opts := DiscoveryOptions{
Namespaces: []string{}, // Auto-discover all namespaces
IncludeImages: true,
RBACCheck: false,
Timeout: 30 * time.Second,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := discoverer.DiscoverFoundational(context.Background(), opts)
if err != nil {
b.Fatalf("DiscoverFoundational failed: %v", err)
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package autodiscovery
import (
"context"
"time"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
)
// AutoCollector defines the interface for automatic collector discovery
type AutoCollector interface {
// DiscoverFoundational discovers foundational collectors based on cluster state (Path 1)
DiscoverFoundational(ctx context.Context, opts DiscoveryOptions) ([]CollectorSpec, error)
// AugmentWithFoundational augments existing YAML collectors with foundational collectors (Path 2)
AugmentWithFoundational(ctx context.Context, yamlCollectors []CollectorSpec, opts DiscoveryOptions) ([]CollectorSpec, error)
// ValidatePermissions validates RBAC permissions for discovered resources
ValidatePermissions(ctx context.Context, resources []Resource) ([]Resource, error)
}
// DiscoveryOptions configures the autodiscovery behavior
type DiscoveryOptions struct {
// Target namespaces for discovery (empty = all accessible namespaces)
Namespaces []string
// Include container image metadata collection
IncludeImages bool
// Perform RBAC permission checking
RBACCheck bool
// Maximum discovery depth for resource relationships
MaxDepth int
// Path 1: Only collect foundational data
FoundationalOnly bool
// Path 2: Add foundational to existing YAML specs
AugmentMode bool
// Timeout for discovery operations
Timeout time.Duration
}
// CollectorSpec represents a collector specification that can be converted to troubleshootv1beta2.Collect
type CollectorSpec struct {
// Type of collector (logs, clusterResources, secret, etc.)
Type CollectorType
// Name of the collector for identification
Name string
// Namespace for namespaced resources
Namespace string
// Spec contains the actual collector configuration
Spec interface{}
// Priority for deduplication (higher wins)
Priority int
// Source indicates where this collector came from (foundational, yaml, etc.)
Source CollectorSource
}
// CollectorType represents the type of data being collected
type CollectorType string
const (
CollectorTypePods CollectorType = "pods"
CollectorTypeDeployments CollectorType = "deployments"
CollectorTypeServices CollectorType = "services"
CollectorTypeConfigMaps CollectorType = "configmaps"
CollectorTypeSecrets CollectorType = "secrets"
CollectorTypeEvents CollectorType = "events"
CollectorTypeLogs CollectorType = "logs"
CollectorTypeClusterInfo CollectorType = "clusterInfo"
CollectorTypeClusterResources CollectorType = "clusterResources"
CollectorTypeImageFacts CollectorType = "imageFacts"
)
// CollectorSource indicates the origin of a collector
type CollectorSource string
const (
SourceFoundational CollectorSource = "foundational"
SourceYAML CollectorSource = "yaml"
SourceAugmented CollectorSource = "augmented"
)
// Resource represents a Kubernetes resource for RBAC checking
type Resource struct {
APIVersion string
Kind string
Namespace string
Name string
}
// FoundationalCollectors represents the set of collectors that are always included
type FoundationalCollectors struct {
// Core Kubernetes resources always collected
Pods []CollectorSpec
Deployments []CollectorSpec
Services []CollectorSpec
ConfigMaps []CollectorSpec
Secrets []CollectorSpec
Events []CollectorSpec
Logs []CollectorSpec
ClusterInfo []CollectorSpec
ClusterResources []CollectorSpec
// Container image metadata
ImageFacts []CollectorSpec
}
// ToTroubleshootCollect converts a CollectorSpec to a troubleshootv1beta2.Collect
func (c CollectorSpec) ToTroubleshootCollect() (*troubleshootv1beta2.Collect, error) {
collect := &troubleshootv1beta2.Collect{}
switch c.Type {
case CollectorTypeLogs:
if logs, ok := c.Spec.(*troubleshootv1beta2.Logs); ok {
collect.Logs = logs
}
case CollectorTypeClusterInfo:
if clusterInfo, ok := c.Spec.(*troubleshootv1beta2.ClusterInfo); ok {
collect.ClusterInfo = clusterInfo
}
case CollectorTypeClusterResources:
if clusterResources, ok := c.Spec.(*troubleshootv1beta2.ClusterResources); ok {
collect.ClusterResources = clusterResources
}
case CollectorTypeSecrets:
if secret, ok := c.Spec.(*troubleshootv1beta2.Secret); ok {
collect.Secret = secret
}
case CollectorTypeConfigMaps:
if configMap, ok := c.Spec.(*troubleshootv1beta2.ConfigMap); ok {
collect.ConfigMap = configMap
}
case CollectorTypeImageFacts:
if data, ok := c.Spec.(*troubleshootv1beta2.Data); ok {
collect.Data = data
}
// Add more cases as needed for other collector types
}
return collect, nil
}
// GetUniqueKey returns a unique identifier for deduplication
func (c CollectorSpec) GetUniqueKey() string {
if c.Namespace != "" {
return string(c.Type) + "/" + c.Namespace + "/" + c.Name
}
return string(c.Type) + "/" + c.Name
}
@@ -0,0 +1,360 @@
package autodiscovery
import (
"context"
"fmt"
"strings"
"github.com/pkg/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/client-go/kubernetes"
"k8s.io/klog/v2"
)
// NamespaceScanner handles namespace discovery and filtering
type NamespaceScanner struct {
client kubernetes.Interface
}
// NewNamespaceScanner creates a new namespace scanner
func NewNamespaceScanner(client kubernetes.Interface) *NamespaceScanner {
return &NamespaceScanner{
client: client,
}
}
// ScanOptions configures namespace scanning behavior
type ScanOptions struct {
// IncludePatterns are glob patterns for namespaces to include
IncludePatterns []string
// ExcludePatterns are glob patterns for namespaces to exclude
ExcludePatterns []string
// LabelSelector filters namespaces by labels
LabelSelector string
// IncludeSystemNamespaces includes system namespaces like kube-system
IncludeSystemNamespaces bool
}
// NamespaceInfo contains information about a discovered namespace
type NamespaceInfo struct {
Name string
Labels map[string]string
// IsSystem indicates if this is a system namespace
IsSystem bool
// ResourceCount provides counts of key resources in the namespace
ResourceCount ResourceCount
}
// ResourceCount tracks resource counts in a namespace
type ResourceCount struct {
Pods int
Deployments int
Services int
ConfigMaps int
Secrets int
}
// ScanNamespaces discovers and returns information about accessible namespaces
func (ns *NamespaceScanner) ScanNamespaces(ctx context.Context, opts ScanOptions) ([]NamespaceInfo, error) {
klog.V(2).Info("Starting namespace scan")
// Get all namespaces the user can access
namespaceList, err := ns.client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
LabelSelector: opts.LabelSelector,
})
if err != nil {
return nil, errors.Wrap(err, "failed to list namespaces")
}
var namespaceInfos []NamespaceInfo
for _, namespace := range namespaceList.Items {
// Check if namespace should be included
if !ns.shouldIncludeNamespace(namespace.Name, namespace.Labels, opts) {
klog.V(4).Infof("Excluding namespace %s based on filters", namespace.Name)
continue
}
// Get resource counts for the namespace
resourceCount, err := ns.getResourceCount(ctx, namespace.Name)
if err != nil {
klog.Warningf("Failed to get resource count for namespace %s: %v", namespace.Name, err)
// Continue with empty resource count
resourceCount = ResourceCount{}
}
namespaceInfo := NamespaceInfo{
Name: namespace.Name,
Labels: namespace.Labels,
IsSystem: ns.isSystemNamespace(namespace.Name),
ResourceCount: resourceCount,
}
namespaceInfos = append(namespaceInfos, namespaceInfo)
}
klog.V(2).Infof("Namespace scan completed: found %d namespaces", len(namespaceInfos))
return namespaceInfos, nil
}
// GetTargetNamespaces returns a list of namespace names to target for collection
func (ns *NamespaceScanner) GetTargetNamespaces(ctx context.Context, requestedNamespaces []string, opts ScanOptions) ([]string, error) {
// If specific namespaces are requested, validate and return them
if len(requestedNamespaces) > 0 {
validNamespaces, err := ns.validateNamespaces(ctx, requestedNamespaces)
if err != nil {
return nil, errors.Wrap(err, "failed to validate requested namespaces")
}
return validNamespaces, nil
}
// Otherwise, scan and filter namespaces
namespaceInfos, err := ns.ScanNamespaces(ctx, opts)
if err != nil {
return nil, errors.Wrap(err, "failed to scan namespaces")
}
var targetNamespaces []string
for _, nsInfo := range namespaceInfos {
targetNamespaces = append(targetNamespaces, nsInfo.Name)
}
return targetNamespaces, nil
}
// shouldIncludeNamespace determines if a namespace should be included based on filters
func (ns *NamespaceScanner) shouldIncludeNamespace(name string, nsLabels map[string]string, opts ScanOptions) bool {
// Check system namespace exclusion
if !opts.IncludeSystemNamespaces && ns.isSystemNamespace(name) {
return false
}
// Check exclude patterns first
for _, pattern := range opts.ExcludePatterns {
if ns.matchesPattern(name, pattern) {
klog.V(4).Infof("Namespace %s excluded by pattern %s", name, pattern)
return false
}
}
// If include patterns are specified, namespace must match at least one
if len(opts.IncludePatterns) > 0 {
matched := false
for _, pattern := range opts.IncludePatterns {
if ns.matchesPattern(name, pattern) {
matched = true
break
}
}
if !matched {
klog.V(4).Infof("Namespace %s does not match any include pattern", name)
return false
}
}
return true
}
// isSystemNamespace determines if a namespace is a system namespace
func (ns *NamespaceScanner) isSystemNamespace(name string) bool {
systemNamespaces := []string{
"kube-system",
"kube-public",
"kube-node-lease",
"kubernetes-dashboard",
"cattle-system",
"rancher-system",
"longhorn-system",
"monitoring",
"logging",
"istio-system",
"linkerd",
}
for _, sysNs := range systemNamespaces {
if name == sysNs {
return true
}
}
// Also consider namespaces with common system prefixes
systemPrefixes := []string{
"kube-",
"cattle-",
"rancher-",
"istio-",
"linkerd-",
}
for _, prefix := range systemPrefixes {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
// matchesPattern checks if a name matches a glob pattern (simplified implementation)
func (ns *NamespaceScanner) matchesPattern(name, pattern string) bool {
// Simple pattern matching - support * wildcard
if pattern == "*" {
return true
}
if !strings.Contains(pattern, "*") {
return name == pattern
}
// Handle patterns with * wildcards
if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") {
// Pattern is "*substring*"
substring := pattern[1 : len(pattern)-1]
return strings.Contains(name, substring)
}
if strings.HasPrefix(pattern, "*") {
// Pattern is "*suffix"
suffix := pattern[1:]
return strings.HasSuffix(name, suffix)
}
if strings.HasSuffix(pattern, "*") {
// Pattern is "prefix*"
prefix := pattern[:len(pattern)-1]
return strings.HasPrefix(name, prefix)
}
// For more complex patterns, fall back to exact match
return name == pattern
}
// getResourceCount counts key resources in a namespace
func (ns *NamespaceScanner) getResourceCount(ctx context.Context, namespace string) (ResourceCount, error) {
count := ResourceCount{}
// Count pods
pods, err := ns.client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
klog.Warningf("Failed to count pods in namespace %s: %v", namespace, err)
} else {
count.Pods = len(pods.Items)
}
// Count deployments
deployments, err := ns.client.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
klog.Warningf("Failed to count deployments in namespace %s: %v", namespace, err)
} else {
count.Deployments = len(deployments.Items)
}
// Count services
services, err := ns.client.CoreV1().Services(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
klog.Warningf("Failed to count services in namespace %s: %v", namespace, err)
} else {
count.Services = len(services.Items)
}
// Count configmaps
configmaps, err := ns.client.CoreV1().ConfigMaps(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
klog.Warningf("Failed to count configmaps in namespace %s: %v", namespace, err)
} else {
count.ConfigMaps = len(configmaps.Items)
}
// Count secrets
secrets, err := ns.client.CoreV1().Secrets(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
klog.Warningf("Failed to count secrets in namespace %s: %v", namespace, err)
} else {
count.Secrets = len(secrets.Items)
}
klog.V(4).Infof("Resource count for namespace %s: pods=%d, deployments=%d, services=%d, configmaps=%d, secrets=%d",
namespace, count.Pods, count.Deployments, count.Services, count.ConfigMaps, count.Secrets)
return count, nil
}
// validateNamespaces checks if the requested namespaces exist and are accessible
func (ns *NamespaceScanner) validateNamespaces(ctx context.Context, requestedNamespaces []string) ([]string, error) {
var validNamespaces []string
for _, nsName := range requestedNamespaces {
_, err := ns.client.CoreV1().Namespaces().Get(ctx, nsName, metav1.GetOptions{})
if err != nil {
klog.Warningf("Cannot access namespace %s: %v", nsName, err)
continue
}
validNamespaces = append(validNamespaces, nsName)
}
if len(validNamespaces) == 0 {
return nil, fmt.Errorf("none of the requested namespaces are accessible: %v", requestedNamespaces)
}
if len(validNamespaces) < len(requestedNamespaces) {
klog.Warningf("Some requested namespaces are not accessible. Using: %v", validNamespaces)
}
return validNamespaces, nil
}
// FilterNamespacesByLabel filters namespaces using a label selector
func (ns *NamespaceScanner) FilterNamespacesByLabel(ctx context.Context, namespaces []string, labelSelector string) ([]string, error) {
if labelSelector == "" {
return namespaces, nil
}
// Parse label selector
selector, err := labels.Parse(labelSelector)
if err != nil {
return nil, errors.Wrap(err, "invalid label selector")
}
var filteredNamespaces []string
for _, nsName := range namespaces {
namespace, err := ns.client.CoreV1().Namespaces().Get(ctx, nsName, metav1.GetOptions{})
if err != nil {
klog.Warningf("Cannot get namespace %s: %v", nsName, err)
continue
}
if selector.Matches(labels.Set(namespace.Labels)) {
filteredNamespaces = append(filteredNamespaces, nsName)
}
}
return filteredNamespaces, nil
}
// GetNamespacesByResourceActivity returns namespaces sorted by resource activity
func (ns *NamespaceScanner) GetNamespacesByResourceActivity(ctx context.Context, opts ScanOptions) ([]NamespaceInfo, error) {
namespaceInfos, err := ns.ScanNamespaces(ctx, opts)
if err != nil {
return nil, err
}
// Sort by total resource count (descending)
for i := 0; i < len(namespaceInfos); i++ {
for j := i + 1; j < len(namespaceInfos); j++ {
countI := ns.getTotalResourceCount(namespaceInfos[i].ResourceCount)
countJ := ns.getTotalResourceCount(namespaceInfos[j].ResourceCount)
if countI < countJ {
namespaceInfos[i], namespaceInfos[j] = namespaceInfos[j], namespaceInfos[i]
}
}
}
return namespaceInfos, nil
}
// getTotalResourceCount calculates the total resource count for a namespace
func (ns *NamespaceScanner) getTotalResourceCount(count ResourceCount) int {
return count.Pods + count.Deployments + count.Services + count.ConfigMaps + count.Secrets
}

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