Add per-collector subcommands to the collect binary (#2071)

* Add per-collector subcommands to the collect binary

Add `collect http|postgres|mysql|mssql|redis` subcommands that run a single
collector and print its native result JSON to stdout, so a collector can run
inside a Pod via the troubleshoot image (e.g. as a runPod collector).

Each has its own flags mapping to the collector's spec fields and its own help.
The Kubernetes client (used only for TLS-from-Secret) is built to match the
production preflight/support-bundle path (QPS/Burst/UserAgent).

* Add clickhouse subcommand to the collect binary

Add `collect clickhouse`, matching the other per-collector subcommands, so the
clickhouse collector can run inside a Pod via the troubleshoot image (e.g. as a
runPod collector). Same flags as the other database collectors (--uri + --tls-*).

* Make global flags work on the collect subcommands

Register --debug and the Kubernetes flags (--kubeconfig, --context, etc.) as
persistent flags, and move the shared setup to PersistentPreRun/PersistentPostRun,
so the per-collector subcommands inherit the global flags and run the same
logger/profiling setup as `collect [url]`. Previously these were local flags with
setup in PreRun, so subcommands rejected --debug/--kubeconfig and ignored the setup.
This commit is contained in:
Salah Al Saleh
2026-07-10 09:31:14 -07:00
committed by GitHub
parent 5e6f3cdac1
commit 2a87b280bf
8 changed files with 547 additions and 4 deletions
+88
View File
@@ -0,0 +1,88 @@
package cli
import (
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/collect"
"github.com/spf13/cobra"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
// ClickhouseCmd runs the clickhouse collector against a single database and
// prints the native result JSON ({"isConnected":..,"version":..,"error":..}).
func ClickhouseCmd() *cobra.Command {
var (
uri string
skipVerify bool
caCert string
clientCert string
clientKey string
secretName string
secretNamespace string
)
cmd := &cobra.Command{
Use: "clickhouse",
Short: "Run the clickhouse collector against a database",
Long: `Run the clickhouse collector: connect to a ClickHouse server, run "SELECT version()",
and print the collector result as JSON.
--uri is a clickhouse-go DSN:
clickhouse://user:password@host:9000/dbname
TLS material may be provided inline / by file path (--tls-cacert, --tls-client-cert,
--tls-client-key) or sourced from a Secret (--tls-secret-name), which requires
cluster access.
Example:
collect clickhouse --uri "clickhouse://default:@ch.default.svc:9000/default"`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
db := &troubleshootv1beta2.Database{
CollectorMeta: troubleshootv1beta2.CollectorMeta{CollectorName: "clickhouse"},
URI: uri,
}
var (
client kubernetes.Interface
cfg *rest.Config
)
if skipVerify || caCert != "" || clientCert != "" || clientKey != "" || secretName != "" {
tls := &troubleshootv1beta2.TLSParams{
SkipVerify: skipVerify,
CACert: caCert,
ClientCert: clientCert,
ClientKey: clientKey,
}
if secretName != "" {
tls.Secret = &troubleshootv1beta2.TLSSecret{Name: secretName, Namespace: secretNamespace}
var err error
client, cfg, err = k8sClientForCollectors()
if err != nil {
return err
}
}
db.TLS = tls
}
c := &collect.CollectClickhouse{Collector: db, ClientConfig: cfg, Client: client, Context: cmd.Context()}
res, err := c.Collect(nil)
if err != nil {
return err
}
return printCollectorResult(res)
},
}
f := cmd.Flags()
f.StringVar(&uri, "uri", "", "ClickHouse connection DSN, e.g. clickhouse://user:pass@host:9000/db (required)")
cmd.MarkFlagRequired("uri")
f.BoolVar(&skipVerify, "tls-skip-verify", false, "skip TLS certificate verification")
f.StringVar(&caCert, "tls-cacert", "", "CA certificate (PEM contents or a file path)")
f.StringVar(&clientCert, "tls-client-cert", "", "client certificate (PEM contents or a file path)")
f.StringVar(&clientKey, "tls-client-key", "", "client key (PEM contents or a file path)")
f.StringVar(&secretName, "tls-secret-name", "", "name of a Secret holding TLS material (requires cluster access)")
f.StringVar(&secretNamespace, "tls-secret-namespace", "default", "namespace of the TLS Secret")
return cmd
}
+47
View File
@@ -0,0 +1,47 @@
// Package cli implements the `collect` command and its subcommands.
//
// The per-collector subcommands (http, postgres, mysql, mssql, redis) run a
// single collector and print its native result JSON to stdout. This lets a
// collector run inside a Pod using the troubleshoot image — e.g. as a runPod
// collector — so the check executes from within the cluster instead of from
// wherever the CLI happens to be invoked.
package cli
import (
"fmt"
"github.com/replicatedhq/troubleshoot/pkg/collect"
"github.com/replicatedhq/troubleshoot/pkg/constants"
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
"github.com/replicatedhq/troubleshoot/pkg/version"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
// printCollectorResult writes the native collector result JSON to stdout.
// The single-collector subcommands run with an empty BundlePath, so the result
// is held in memory (map value = the JSON bytes) rather than written to disk.
func printCollectorResult(res collect.CollectorResult) error {
for _, b := range res {
fmt.Println(string(b))
}
return nil
}
// k8sClientForCollectors builds a Kubernetes client from the ambient kubeconfig
// or in-cluster config. Only the collectors that resolve TLS material from a
// Secret need this; plain connections and inline/file TLS do not.
func k8sClientForCollectors() (kubernetes.Interface, *rest.Config, error) {
cfg, err := k8sutil.GetRESTConfig()
if err != nil {
return nil, nil, err
}
cfg.QPS = constants.DEFAULT_CLIENT_QPS
cfg.Burst = constants.DEFAULT_CLIENT_BURST
cfg.UserAgent = fmt.Sprintf("%s/%s", constants.DEFAULT_CLIENT_USER_AGENT, version.Version())
client, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, nil, err
}
return client, cfg, nil
}
+79
View File
@@ -0,0 +1,79 @@
package cli
import (
"fmt"
"strings"
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/collect"
"github.com/spf13/cobra"
)
// HTTPCmd runs the http collector against a single endpoint and prints the
// native result JSON ({"response":{"status":...}} or {"error":{...}}).
func HTTPCmd() *cobra.Command {
var (
method string
url string
headers map[string]string
body string
timeout string
proxy string
insecure bool
caCert string
)
cmd := &cobra.Command{
Use: "http",
Short: "Run the http collector against an endpoint",
Long: `Run the http collector: issue an HTTP request and print the collector result as JSON.
The result contains the response status, body and headers, or an error object.
Examples:
collect http --url http://myapp.default.svc:8080/healthz
collect http --method POST --url https://api.internal/ping \
--header 'Content-Type=application/json' --body '{"ping":true}'`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
var tls *troubleshootv1beta2.TLSParams
if caCert != "" {
tls = &troubleshootv1beta2.TLSParams{CACert: caCert}
}
spec := &troubleshootv1beta2.HTTP{
CollectorMeta: troubleshootv1beta2.CollectorMeta{CollectorName: "http"},
}
switch strings.ToUpper(method) {
case "GET":
spec.Get = &troubleshootv1beta2.Get{URL: url, Headers: headers, Timeout: timeout, Proxy: proxy, InsecureSkipVerify: insecure, TLS: tls}
case "POST":
spec.Post = &troubleshootv1beta2.Post{URL: url, Headers: headers, Body: body, Timeout: timeout, Proxy: proxy, InsecureSkipVerify: insecure, TLS: tls}
case "PUT":
spec.Put = &troubleshootv1beta2.Put{URL: url, Headers: headers, Body: body, Timeout: timeout, Proxy: proxy, InsecureSkipVerify: insecure, TLS: tls}
default:
return fmt.Errorf("unsupported --method %q (use GET, POST, or PUT)", method)
}
c := &collect.CollectHTTP{Collector: spec}
res, err := c.Collect(nil)
if err != nil {
return err
}
return printCollectorResult(res)
},
}
f := cmd.Flags()
f.StringVar(&url, "url", "", "request URL (required)")
cmd.MarkFlagRequired("url")
f.StringVar(&method, "method", "GET", "HTTP method: GET, POST, or PUT")
f.StringToStringVar(&headers, "header", nil, "request header as key=value (repeatable)")
f.StringVar(&body, "body", "", "request body (POST/PUT only)")
f.StringVar(&timeout, "timeout", "", "request timeout, e.g. 15s (empty = no timeout)")
f.StringVar(&proxy, "proxy", "", "proxy URL to use for the request")
f.BoolVar(&insecure, "insecure-skip-verify", false, "do not verify the server's TLS certificate")
f.StringVar(&caCert, "tls-cacert", "", "CA certificate to trust (PEM contents or a file path)")
return cmd
}
+49
View File
@@ -0,0 +1,49 @@
package cli
import (
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/collect"
"github.com/spf13/cobra"
)
// MssqlCmd runs the mssql collector against a single database and prints the
// native result JSON ({"isConnected":..,"version":..,"error":..}).
//
// Unlike the other database collectors, the mssql collector does not honor a
// separate TLS field — TLS is configured through the connection URI query
// parameters (e.g. encrypt=true) — so this subcommand only takes --uri.
func MssqlCmd() *cobra.Command {
var uri string
cmd := &cobra.Command{
Use: "mssql",
Short: "Run the mssql collector against a database",
Long: `Run the mssql collector: connect to a Microsoft SQL Server, run "select @@VERSION",
and print the collector result as JSON.
--uri is a go-mssqldb connection URL; TLS options are passed as query parameters:
sqlserver://user:password@host:1433?database=app&encrypt=true
Example:
collect mssql --uri "sqlserver://sa:pass@mssql.default.svc:1433?encrypt=true"`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
db := &troubleshootv1beta2.Database{
CollectorMeta: troubleshootv1beta2.CollectorMeta{CollectorName: "mssql"},
URI: uri,
}
c := &collect.CollectMssql{Collector: db, Context: cmd.Context()}
res, err := c.Collect(nil)
if err != nil {
return err
}
return printCollectorResult(res)
},
}
cmd.Flags().StringVar(&uri, "uri", "", "SQL Server connection URI, e.g. sqlserver://user:pass@host:1433 (required)")
cmd.MarkFlagRequired("uri")
return cmd
}
+90
View File
@@ -0,0 +1,90 @@
package cli
import (
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/collect"
"github.com/spf13/cobra"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
// MysqlCmd runs the mysql collector against a single database and prints the
// native result JSON ({"isConnected":..,"version":..,"variables":..,"error":..}).
func MysqlCmd() *cobra.Command {
var (
uri string
parameters []string
skipVerify bool
caCert string
clientCert string
clientKey string
secretName string
secretNamespace string
)
cmd := &cobra.Command{
Use: "mysql",
Short: "Run the mysql collector against a database",
Long: `Run the mysql collector: connect to a MySQL server, run "select version()",
optionally collect server variables, and print the collector result as JSON.
--uri is a go-sql-driver DSN (note: not a URL):
user:password@tcp(host:3306)/dbname
--parameters names server variables to collect via "SHOW VARIABLES"; the matching
values are returned under "variables" in the result. This flag is unique to mysql.
Example:
collect mysql --uri "root:pass@tcp(mysql.default.svc:3306)/" --parameters max_connections,version`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
db := &troubleshootv1beta2.Database{
CollectorMeta: troubleshootv1beta2.CollectorMeta{CollectorName: "mysql"},
URI: uri,
Parameters: parameters,
}
var (
client kubernetes.Interface
cfg *rest.Config
)
if skipVerify || caCert != "" || clientCert != "" || clientKey != "" || secretName != "" {
tls := &troubleshootv1beta2.TLSParams{
SkipVerify: skipVerify,
CACert: caCert,
ClientCert: clientCert,
ClientKey: clientKey,
}
if secretName != "" {
tls.Secret = &troubleshootv1beta2.TLSSecret{Name: secretName, Namespace: secretNamespace}
var err error
client, cfg, err = k8sClientForCollectors()
if err != nil {
return err
}
}
db.TLS = tls
}
c := &collect.CollectMysql{Collector: db, ClientConfig: cfg, Client: client, Context: cmd.Context()}
res, err := c.Collect(nil)
if err != nil {
return err
}
return printCollectorResult(res)
},
}
f := cmd.Flags()
f.StringVar(&uri, "uri", "", "MySQL connection DSN, e.g. user:pass@tcp(host:3306)/db (required)")
cmd.MarkFlagRequired("uri")
f.StringSliceVar(&parameters, "parameters", nil, "server variables to collect via SHOW VARIABLES (comma-separated or repeatable)")
f.BoolVar(&skipVerify, "tls-skip-verify", false, "skip TLS certificate verification")
f.StringVar(&caCert, "tls-cacert", "", "CA certificate (PEM contents or a file path)")
f.StringVar(&clientCert, "tls-client-cert", "", "client certificate (PEM contents or a file path)")
f.StringVar(&clientKey, "tls-client-key", "", "client key (PEM contents or a file path)")
f.StringVar(&secretName, "tls-secret-name", "", "name of a Secret holding TLS material (requires cluster access)")
f.StringVar(&secretNamespace, "tls-secret-namespace", "default", "namespace of the TLS Secret")
return cmd
}
+88
View File
@@ -0,0 +1,88 @@
package cli
import (
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/collect"
"github.com/spf13/cobra"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
// PostgresCmd runs the postgres collector against a single database and prints
// the native result JSON ({"isConnected":..,"version":..,"error":..}).
func PostgresCmd() *cobra.Command {
var (
uri string
skipVerify bool
caCert string
clientCert string
clientKey string
secretName string
secretNamespace string
)
cmd := &cobra.Command{
Use: "postgres",
Short: "Run the postgres collector against a database",
Long: `Run the postgres collector: connect to a PostgreSQL server, run "select version()",
and print the collector result as JSON.
--uri is a libpq/pgx connection URI:
postgres://user:password@host:5432/dbname?sslmode=disable
TLS material may be provided inline / by file path (--tls-cacert, --tls-client-cert,
--tls-client-key) or sourced from a Secret (--tls-secret-name), which requires
cluster access.
Example:
collect postgres --uri "postgres://user:pass@pg.default.svc:5432/app?sslmode=disable"`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
db := &troubleshootv1beta2.Database{
CollectorMeta: troubleshootv1beta2.CollectorMeta{CollectorName: "postgres"},
URI: uri,
}
var (
client kubernetes.Interface
cfg *rest.Config
)
if skipVerify || caCert != "" || clientCert != "" || clientKey != "" || secretName != "" {
tls := &troubleshootv1beta2.TLSParams{
SkipVerify: skipVerify,
CACert: caCert,
ClientCert: clientCert,
ClientKey: clientKey,
}
if secretName != "" {
tls.Secret = &troubleshootv1beta2.TLSSecret{Name: secretName, Namespace: secretNamespace}
var err error
client, cfg, err = k8sClientForCollectors()
if err != nil {
return err
}
}
db.TLS = tls
}
c := &collect.CollectPostgres{Collector: db, ClientConfig: cfg, Client: client, Context: cmd.Context()}
res, err := c.Collect(nil)
if err != nil {
return err
}
return printCollectorResult(res)
},
}
f := cmd.Flags()
f.StringVar(&uri, "uri", "", "PostgreSQL connection URI (required)")
cmd.MarkFlagRequired("uri")
f.BoolVar(&skipVerify, "tls-skip-verify", false, "skip TLS certificate verification")
f.StringVar(&caCert, "tls-cacert", "", "CA certificate (PEM contents or a file path)")
f.StringVar(&clientCert, "tls-client-cert", "", "client certificate (PEM contents or a file path)")
f.StringVar(&clientKey, "tls-client-key", "", "client key (PEM contents or a file path)")
f.StringVar(&secretName, "tls-secret-name", "", "name of a Secret holding TLS material (requires cluster access)")
f.StringVar(&secretNamespace, "tls-secret-namespace", "default", "namespace of the TLS Secret")
return cmd
}
+89
View File
@@ -0,0 +1,89 @@
package cli
import (
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
"github.com/replicatedhq/troubleshoot/pkg/collect"
"github.com/spf13/cobra"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
// RedisCmd runs the redis collector against a single instance and prints the
// native result JSON ({"isConnected":..,"version":..,"error":..}).
func RedisCmd() *cobra.Command {
var (
uri string
skipVerify bool
caCert string
clientCert string
clientKey string
secretName string
secretNamespace string
)
cmd := &cobra.Command{
Use: "redis",
Short: "Run the redis collector against an instance",
Long: `Run the redis collector: connect to a Redis server, read "INFO server" (server
version), and print the collector result as JSON.
--uri is a go-redis connection URL:
redis://host:6379 (plaintext)
rediss://user:pass@host:6379 (TLS)
TLS material may be provided inline / by file path (--tls-cacert, --tls-client-cert,
--tls-client-key) or sourced from a Secret (--tls-secret-name), which requires
cluster access.
Example:
collect redis --uri "redis://redis.default.svc:6379"`,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
db := &troubleshootv1beta2.Database{
CollectorMeta: troubleshootv1beta2.CollectorMeta{CollectorName: "redis"},
URI: uri,
}
var (
client kubernetes.Interface
cfg *rest.Config
)
if skipVerify || caCert != "" || clientCert != "" || clientKey != "" || secretName != "" {
tls := &troubleshootv1beta2.TLSParams{
SkipVerify: skipVerify,
CACert: caCert,
ClientCert: clientCert,
ClientKey: clientKey,
}
if secretName != "" {
tls.Secret = &troubleshootv1beta2.TLSSecret{Name: secretName, Namespace: secretNamespace}
var err error
client, cfg, err = k8sClientForCollectors()
if err != nil {
return err
}
}
db.TLS = tls
}
c := &collect.CollectRedis{Collector: db, ClientConfig: cfg, Client: client, Context: cmd.Context()}
res, err := c.Collect(nil)
if err != nil {
return err
}
return printCollectorResult(res)
},
}
f := cmd.Flags()
f.StringVar(&uri, "uri", "", "Redis connection URL, e.g. redis://host:6379 (required)")
cmd.MarkFlagRequired("uri")
f.BoolVar(&skipVerify, "tls-skip-verify", false, "skip TLS certificate verification")
f.StringVar(&caCert, "tls-cacert", "", "CA certificate (PEM contents or a file path)")
f.StringVar(&clientCert, "tls-client-cert", "", "client certificate (PEM contents or a file path)")
f.StringVar(&clientKey, "tls-client-key", "", "client key (PEM contents or a file path)")
f.StringVar(&secretName, "tls-secret-name", "", "name of a Secret holding TLS material (requires cluster access)")
f.StringVar(&secretNamespace, "tls-secret-namespace", "default", "namespace of the TLS Secret")
return cmd
}
+17 -4
View File
@@ -19,7 +19,9 @@ func RootCmd() *cobra.Command {
Short: "Run a collector",
Long: `Run a collector and output the results.`,
SilenceUsage: true,
PreRun: func(cmd *cobra.Command, args []string) {
// PersistentPreRun/PersistentPostRun (rather than PreRun/PostRun) so this
// setup also runs for the per-collector subcommands, not just `collect [url]`.
PersistentPreRun: func(cmd *cobra.Command, args []string) {
v := viper.GetViper()
v.BindPFlags(cmd.Flags())
@@ -38,7 +40,7 @@ func RootCmd() *cobra.Command {
return runCollect(v, args[0])
},
PostRun: func(cmd *cobra.Command, args []string) {
PersistentPostRun: func(cmd *cobra.Command, args []string) {
if err := util.StopProfiling(); err != nil {
klog.Errorf("Failed to stop profiling: %v", err)
}
@@ -49,6 +51,17 @@ func RootCmd() *cobra.Command {
cmd.AddCommand(util.VersionCmd())
// Per-collector subcommands: run a single collector and print its native
// result JSON. Each has its own flags and help. These let a collector run
// inside a Pod via the troubleshoot image (e.g. as a runPod collector), so
// the check executes from within the cluster rather than wherever the CLI runs.
cmd.AddCommand(HTTPCmd())
cmd.AddCommand(PostgresCmd())
cmd.AddCommand(MysqlCmd())
cmd.AddCommand(MssqlCmd())
cmd.AddCommand(RedisCmd())
cmd.AddCommand(ClickhouseCmd())
cmd.Flags().StringSlice("redactors", []string{}, "names of the additional redactors to use")
cmd.Flags().Bool("redact", true, "enable/disable default redactions")
cmd.Flags().String("format", "json", "output format, one of json or raw.")
@@ -56,7 +69,7 @@ func RootCmd() *cobra.Command {
cmd.Flags().String("collector-pull-policy", "", "the pull policy of the collector image")
cmd.Flags().String("selector", "", "selector (label query) to filter remote collection nodes on.")
cmd.Flags().Bool("collect-without-permissions", false, "always generate a support bundle, even if it some require additional permissions")
cmd.Flags().Bool("debug", false, "enable debug logging")
cmd.PersistentFlags().Bool("debug", false, "enable debug logging")
cmd.Flags().String("chroot", "", "Chroot to path")
// hidden in favor of the `insecure-skip-tls-verify` flag
@@ -67,7 +80,7 @@ func RootCmd() *cobra.Command {
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
k8sutil.AddFlags(cmd.Flags())
k8sutil.AddFlags(cmd.PersistentFlags())
// Initialize klog flags
logger.InitKlogFlags(cmd)