From 3edb9c3084111206de756d737ffece14470b31f0 Mon Sep 17 00:00:00 2001 From: Tobias Bauriedel Date: Wed, 9 Oct 2024 17:39:37 +0200 Subject: [PATCH] saving state. WIP --- doc/answer-file.yml.example | 18 ++ internal/answerfile/answer-file.yml.example | 29 --- internal/answerfile/answerfile.go | 32 ---- internal/collection/collection.go | 8 +- internal/config/answerfile.go | 49 +++++ internal/config/config.go | 80 ++++++++ .../arguments.go => config/wizard.go} | 101 +++++----- internal/controls/controls.go | 64 ------- internal/metrics/metrics.go | 4 +- main.go | 174 ++++++++---------- modules/icinga2/api.go | 128 ------------- modules/icinga2/collector.go | 32 +++- modules/icinga2/icingaApi/api.go | 85 +++++++++ 13 files changed, 392 insertions(+), 412 deletions(-) create mode 100644 doc/answer-file.yml.example delete mode 100644 internal/answerfile/answer-file.yml.example delete mode 100644 internal/answerfile/answerfile.go create mode 100644 internal/config/answerfile.go create mode 100644 internal/config/config.go rename internal/{arguments/arguments.go => config/wizard.go} (51%) delete mode 100644 internal/controls/controls.go delete mode 100644 modules/icinga2/api.go create mode 100644 modules/icinga2/icingaApi/api.go diff --git a/doc/answer-file.yml.example b/doc/answer-file.yml.example new file mode 100644 index 0000000..8964bab --- /dev/null +++ b/doc/answer-file.yml.example @@ -0,0 +1,18 @@ +general: + outputFile: support-collector_icinga-dev_20241010-1037.zip + enabledModules: + - all + disabledModules: [ ] + extraObfuscators: [ ] + detailedCollection: true + commandTimeout: 1m0s +icinga2: + endpoints: + - address: localhost + port: 5665 + username: poweruser + password: poweruser + - address: 127.0.0.1 + port: 5665 + username: poweruser + password: poweruser \ No newline at end of file diff --git a/internal/answerfile/answer-file.yml.example b/internal/answerfile/answer-file.yml.example deleted file mode 100644 index a98dba5..0000000 --- a/internal/answerfile/answer-file.yml.example +++ /dev/null @@ -1,29 +0,0 @@ -general: - outputFile: support-collector_icinga-dev_20240930-1312.zip - enabledModules: - - ansible - - base - - corosync - - elastic - - foreman - - grafana - - graphite - - graylog - - icinga-director - - icinga2 - - icingadb - - icingaweb2 - - influxdb - - keepalived - - mongodb - - mysql - - postgresql - - prometheus - - puppet - - redis - - webservers - disabledModules: [] - extraObfuscators: [] - detailedCollection: true - commandTimeout: 1m0s -icinga2: {} diff --git a/internal/answerfile/answerfile.go b/internal/answerfile/answerfile.go deleted file mode 100644 index 2db35d6..0000000 --- a/internal/answerfile/answerfile.go +++ /dev/null @@ -1,32 +0,0 @@ -package answerfile - -import ( - "fmt" - "github.com/NETWAYS/support-collector/internal/controls" - "gopkg.in/yaml.v3" - "io" - "os" -) - -func GenerateDefaultAnswerFile() error { - file, err := os.Create("answer-file.yml") - if err != nil { - return fmt.Errorf("could not create answer file: %w", err) - } - - defer file.Close() - - defaults := controls.GetControlDefaults() - - yamlData, err := yaml.Marshal(defaults) - if err != nil { - return fmt.Errorf("could not marshal yamldata for answer-file: %w", err) - } - - _, err = io.WriteString(file, string(yamlData)) - if err != nil { - return fmt.Errorf("could not write to answer file: %w", err) - } - - return nil -} diff --git a/internal/collection/collection.go b/internal/collection/collection.go index 04e381c..6b8be79 100644 --- a/internal/collection/collection.go +++ b/internal/collection/collection.go @@ -5,7 +5,7 @@ import ( "bytes" "encoding/json" "fmt" - "github.com/NETWAYS/support-collector/internal/controls" + "github.com/NETWAYS/support-collector/internal/config" "github.com/NETWAYS/support-collector/internal/metrics" "io" "strings" @@ -24,14 +24,12 @@ type Collection struct { Obfuscators []*obfuscate.Obfuscator Detailed bool JournalLoggingInterval string - Control controls.Control + Config config.Config Metric *metrics.Metrics } // New initializes new collection with defaults func New(w io.Writer) (c *Collection) { - // TODO: add Icinga Control - c = &Collection{ Output: zip.NewWriter(w), Log: logrus.New(), @@ -40,7 +38,7 @@ func New(w io.Writer) (c *Collection) { Obfuscators: nil, Detailed: true, JournalLoggingInterval: "7 days ago", - Control: controls.GetControlDefaults(), + Config: config.GetControlDefaultObject(), Metric: nil, } diff --git a/internal/config/answerfile.go b/internal/config/answerfile.go new file mode 100644 index 0000000..4d0b887 --- /dev/null +++ b/internal/config/answerfile.go @@ -0,0 +1,49 @@ +package config + +import ( + "fmt" + "gopkg.in/yaml.v3" + "io" + "os" +) + +const defaultAnswerFileName = "answer-file.yml" + +// GenerateDefaultAnswerFile creates a new answer-file with default values in current dir +func GenerateDefaultAnswerFile() error { + file, err := os.Create(defaultAnswerFileName) + if err != nil { + return fmt.Errorf("could not create answer file: %w", err) + } + + defer file.Close() + + defaults := GetControlDefaultObject() + + yamlData, err := yaml.Marshal(defaults) + if err != nil { + return fmt.Errorf("could not marshal yamldata for answer-file: %w", err) + } + + _, err = io.WriteString(file, string(yamlData)) + if err != nil { + return fmt.Errorf("could not write to answer file: %w", err) + } + + return nil +} + +// ReadAnswerFile reads given values from answerFile and returns new Config +func ReadAnswerFile(answerFile string, conf *Config) error { + data, err := os.ReadFile(answerFile) + if err != nil { + return fmt.Errorf("could not read answer file: %w", err) + } + + err = yaml.Unmarshal(data, &conf) + if err != nil { + return fmt.Errorf("could not unmarshal answer file: %w", err) + } + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ede9e9c --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,80 @@ +package config + +import ( + "fmt" + "github.com/NETWAYS/support-collector/internal/util" + "github.com/NETWAYS/support-collector/modules/icinga2/icingaApi" + "slices" + "time" +) + +var ( + ModulesOrder = []string{ + "ansible", + "base", + "corosync", + "elastic", + "foreman", + "grafana", + "graphite", + "graylog", + "icinga-director", + "icinga2", + "icingadb", + "icingaweb2", + "influxdb", + "keepalived", + "mongodb", + "mysql", + "postgresql", + "prometheus", + "puppet", + "redis", + "webservers", + } +) + +type Config struct { + General General `yaml:"general" json:"general"` + Icinga2 Icinga2 `yaml:"icinga2" json:"icinga2"` +} + +type General struct { + OutputFile string `yaml:"outputFile" json:"outputFile"` + AnswerFile string `yaml:"answerFile,omitempty" json:"answerFile,omitempty"` + EnabledModules []string `yaml:"enabledModules" json:"enabledModules"` + DisabledModules []string `yaml:"disabledModules" json:"disabledModules"` + ExtraObfuscators []string `yaml:"extraObfuscators" json:"extraObfuscators"` + DetailedCollection bool `yaml:"detailedCollection" json:"detailedCollection"` + CommandTimeout time.Duration `yaml:"commandTimeout" json:"commandTimeout"` +} + +type Icinga2 struct { + Endpoints []icingaApi.Endpoint `yaml:"endpoints" json:"endpoints"` +} + +// GetControlDefaultObject returns a new Config object with some pre-defined default values +func GetControlDefaultObject() Config { + return Config{ + General: General{ + OutputFile: util.BuildFileName(), + AnswerFile: "", + EnabledModules: []string{"all"}, + DisabledModules: nil, + ExtraObfuscators: nil, + DetailedCollection: true, + CommandTimeout: 60 * time.Second, + }, + Icinga2: Icinga2{}, + } +} + +// ValidateConfig validates the given config.Config for errors. Returns []error +func ValidateConfig(conf Config) (errors []error) { + for _, name := range conf.General.EnabledModules { + if !slices.Contains(ModulesOrder, name) { + errors = append(errors, fmt.Errorf("invalid module '%s' provided. Cant be enabled", name)) + } + } + return errors +} diff --git a/internal/arguments/arguments.go b/internal/config/wizard.go similarity index 51% rename from internal/arguments/arguments.go rename to internal/config/wizard.go index dde186c..65a9a55 100644 --- a/internal/arguments/arguments.go +++ b/internal/config/wizard.go @@ -1,4 +1,4 @@ -package arguments +package config import ( "bufio" @@ -8,7 +8,6 @@ import ( ) var ( - NonInteractive bool validBooleanInputs = map[string]bool{ "y": true, "yes": true, @@ -22,51 +21,43 @@ var ( const interactiveHelpText = `Welcome to the support-collector argument wizard! We will guide you through all required details.` -type Argument struct { - Name string - InputFunction func() - Dependency func() bool +type argument struct { + name string + inputFunction func() + dependency func() bool } -type Handler struct { - scanner *bufio.Scanner - arguments []Argument +type Wizard struct { + Scanner *bufio.Scanner + Arguments []argument } -// NewHandler creates a new Handler object -func NewHandler() Handler { - return Handler{ - scanner: bufio.NewScanner(os.Stdin), +// NewWizard creates a new Wizard +func NewWizard() Wizard { + return Wizard{ + Scanner: bufio.NewScanner(os.Stdin), } } -// Collect starts the interactive wizard for all arguments defined in Handler -func (args *Handler) Collect(availableModules string) []error { +// Parse starts the interactive wizard for all Arguments defined in Wizard +func (args *Wizard) Parse(availableModules string) { // Print "welcome" text for interactive wizard fmt.Printf("%s\n\nAvailable modules are: %s\n\n", interactiveHelpText, availableModules) - errors := make([]error, 0, len(args.arguments)) - - for _, argument := range args.arguments { - if argument.Dependency == nil { - argument.InputFunction() + for _, arg := range args.Arguments { + if arg.dependency == nil { + arg.inputFunction() continue } - if ok := argument.Dependency(); ok { - argument.InputFunction() + if ok := arg.dependency(); ok { + arg.inputFunction() continue } - - errors = append(errors, fmt.Errorf("argument '%s' is not matching the needed depenency. Skipping... ", argument.Name)) } - - fmt.Print("\nInteractive wizard finished. Starting...\n\n") - - return errors } -// NewPromptStringVar adds Argument for a string variable +// NewPromptStringVar adds argument for a string variable // // callback: Variable to save the input to // name: Internal name @@ -74,21 +65,21 @@ func (args *Handler) Collect(availableModules string) []error { // usage: usage string // required: bool // dependency: Add dependency function to validate if that argument will be added or not -func (args *Handler) NewPromptStringVar(callback *string, name, defaultValue, usage string, required bool, dependency func() bool) { - args.arguments = append(args.arguments, Argument{ - Name: name, - InputFunction: func() { +func (args *Wizard) NewPromptStringVar(callback *string, name, defaultValue, usage string, required bool, dependency func() bool) { + args.Arguments = append(args.Arguments, argument{ + name: name, + inputFunction: func() { if *callback != "" { defaultValue = *callback } args.newStringPrompt(callback, defaultValue, usage, required) }, - Dependency: dependency, + dependency: dependency, }) } -// NewPromptStringSliceVar adds Argument for a string slice variable +// NewPromptStringSliceVar adds argument for a string slice variable // // callback: Variable to save the input to // name: Internal name @@ -96,10 +87,10 @@ func (args *Handler) NewPromptStringVar(callback *string, name, defaultValue, us // usage: usage string // required: bool // dependency: Add dependency function to validate if that argument will be added or not -func (args *Handler) NewPromptStringSliceVar(callback *[]string, name string, defaultValue []string, usage string, required bool, dependency func() bool) { - args.arguments = append(args.arguments, Argument{ - Name: name, - InputFunction: func() { +func (args *Wizard) NewPromptStringSliceVar(callback *[]string, name string, defaultValue []string, usage string, required bool, dependency func() bool) { + args.Arguments = append(args.Arguments, argument{ + name: name, + inputFunction: func() { if len(*callback) > 0 { defaultValue = *callback } @@ -109,34 +100,34 @@ func (args *Handler) NewPromptStringSliceVar(callback *[]string, name string, de args.newStringPrompt(&input, strings.Join(defaultValue, ","), usage, required) *callback = strings.Split(input, ",") }, - Dependency: dependency, + dependency: dependency, }) } -// NewPromptBoolVar adds Argument for a boolean variable +// NewPromptBoolVar adds argument for a boolean variable // // callback: Variable to save the input to // name: Internal name // defaultValue: Default // usage: usage string // dependency: Add dependency function to validate if that argument will be added or not -func (args *Handler) NewPromptBoolVar(callback *bool, name string, defaultValue bool, usage string, dependency func() bool) { - args.arguments = append(args.arguments, Argument{ - Name: name, - InputFunction: func() { +func (args *Wizard) NewPromptBoolVar(callback *bool, name string, defaultValue bool, usage string, dependency func() bool) { + args.Arguments = append(args.Arguments, argument{ + name: name, + inputFunction: func() { args.newBoolPrompt(callback, defaultValue, usage) }, - Dependency: dependency, + dependency: dependency, }) } // newStringPrompt creates a new stdout / stdin prompt for a string -func (args *Handler) newStringPrompt(callback *string, defaultValue, usage string, required bool) { +func (args *Wizard) newStringPrompt(callback *string, defaultValue, usage string, required bool) { for { - fmt.Printf("%s - (Preselection: '%s'): ", usage, defaultValue) + fmt.Printf("%s - (Default: '%s'): ", usage, defaultValue) - if args.scanner.Scan() { - input := args.scanner.Text() + if args.Scanner.Scan() { + input := args.Scanner.Text() switch { case input != "": @@ -149,7 +140,7 @@ func (args *Handler) newStringPrompt(callback *string, defaultValue, usage strin return } } else { - if err := args.scanner.Err(); err != nil { + if err := args.Scanner.Err(); err != nil { _, _ = fmt.Fprintln(os.Stderr, "reading standard input:", err) return } @@ -158,12 +149,12 @@ func (args *Handler) newStringPrompt(callback *string, defaultValue, usage strin } // newBoolPrompt creates a new stdout / stdin prompt for a boolean -func (args *Handler) newBoolPrompt(callback *bool, defaultValue bool, usage string) { +func (args *Wizard) newBoolPrompt(callback *bool, defaultValue bool, usage string) { for { - fmt.Printf("%s [y/n] - (Preselection: '%t'): ", usage, defaultValue) + fmt.Printf("%s [y/n] - (Default: '%t'): ", usage, defaultValue) - if args.scanner.Scan() { - input := strings.ToLower(args.scanner.Text()) + if args.Scanner.Scan() { + input := strings.ToLower(args.Scanner.Text()) if input != "" && isValidBoolString(input) { *callback = validBooleanInputs[input] diff --git a/internal/controls/controls.go b/internal/controls/controls.go deleted file mode 100644 index b2caeb0..0000000 --- a/internal/controls/controls.go +++ /dev/null @@ -1,64 +0,0 @@ -package controls - -import ( - "github.com/NETWAYS/support-collector/internal/util" - "time" -) - -var ModulesOrder = []string{ - "ansible", - "base", - "corosync", - "elastic", - "foreman", - "grafana", - "graphite", - "graylog", - "icinga-director", - "icinga2", - "icingadb", - "icingaweb2", - "influxdb", - "keepalived", - "mongodb", - "mysql", - "postgresql", - "prometheus", - "puppet", - "redis", - "webservers", -} - -type Control struct { - General ControlGeneral `yaml:"general" json:"general"` - Icinga2 ControlIcinga2 `yaml:"icinga2" json:"icinga2"` -} - -type ControlGeneral struct { - OutputFile string `yaml:"outputFile" json:"outputFile"` - AnswerFile string `yaml:"answerFile,omitempty" json:"answerFile"` - EnabledModules []string `yaml:"enabledModules" json:"enabledModules"` - DisabledModules []string `yaml:"disabledModules" json:"disabledModules"` - ExtraObfuscators []string `yaml:"extraObfuscators" json:"extraObfuscators"` - DetailedCollection bool `yaml:"detailedCollection" json:"detailedCollection"` - CommandTimeout time.Duration `yaml:"commandTimeout" json:"commandTimeout"` -} - -// TODO -type ControlIcinga2 struct { -} - -func GetControlDefaults() Control { - return Control{ - General: ControlGeneral{ - OutputFile: util.BuildFileName(), - AnswerFile: "", - EnabledModules: ModulesOrder, - DisabledModules: nil, - ExtraObfuscators: nil, - DetailedCollection: true, - CommandTimeout: 60 * time.Second, - }, - Icinga2: ControlIcinga2{}, - } -} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 8b8d122..1f6938f 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -1,7 +1,7 @@ package metrics import ( - "github.com/NETWAYS/support-collector/internal/controls" + "github.com/NETWAYS/support-collector/internal/config" "github.com/NETWAYS/support-collector/internal/obfuscate" "os" "strings" @@ -10,7 +10,7 @@ import ( type Metrics struct { Command string `json:"command"` - Controls controls.Control `json:"controls"` + Controls config.Config `json:"controls"` Version string `json:"version"` Timings map[string]time.Duration `json:"timings"` } diff --git a/main.go b/main.go index c056979..f19ba3d 100644 --- a/main.go +++ b/main.go @@ -3,10 +3,8 @@ package main import ( "encoding/json" "fmt" - "github.com/NETWAYS/support-collector/internal/answerfile" - "github.com/NETWAYS/support-collector/internal/arguments" "github.com/NETWAYS/support-collector/internal/collection" - "github.com/NETWAYS/support-collector/internal/controls" + "github.com/NETWAYS/support-collector/internal/config" "github.com/NETWAYS/support-collector/internal/metrics" "github.com/NETWAYS/support-collector/modules/ansible" "github.com/NETWAYS/support-collector/modules/base" @@ -31,6 +29,7 @@ import ( flag "github.com/spf13/pflag" "os" "path/filepath" + "slices" "strings" "time" @@ -59,12 +58,10 @@ email, it contains potential sensitive information! ` var ( - outputFile = util.BuildFileName() - answerFile string - enabledModules, disabledModules []string - verbose, printVersion, detailedCollection, generateAnswerFile bool - startTime = time.Now() - wizardErrors []error + disableWizard bool + answerFile string + verbose, printVersion, detailedCollection bool + startTime = time.Now() ) var modules = map[string]func(*collection.Collection){ @@ -97,64 +94,47 @@ func init() { } func main() { + // Create new config object with defaults + conf := config.GetControlDefaultObject() // Add and parse flags - addFlags() + if err := parseFlags(); err != nil { + logrus.Fatal(err) + } - if generateAnswerFile { - err := answerfile.GenerateDefaultAnswerFile() - if err != nil { - logrus.Fatalf("Failed to generate answer file: %v", err) + // Read input from answer-file if provided + // Needs to done after parsing flags to have the value for answerFile + if answerFile != "" { + if err := config.ReadAnswerFile(answerFile, &conf); err != nil { + logrus.Fatal(err) } - - os.Exit(0) + conf.General.AnswerFile = answerFile } - if answerFile == "" && arguments.NonInteractive { - logrus.Fatal("--non-interactive provided but no answer-file were given") + // Start interactive config wizard if not disabled via flag and no answer-file is provided + if !disableWizard && answerFile == "" { + startConfigWizard(&conf) } - if answerFile != "" { - // TODO read values from answer-file + // If "all" provided for enabled modules, enable all + if slices.Contains(conf.General.EnabledModules, "all") { + conf.General.EnabledModules = config.ModulesOrder } - // Init arguments for interactive wizard and collect them - if !arguments.NonInteractive { - wizardErrors = addInteractiveWizard() + // Validate conf. If errors found, print them and exit + if validationErrors := config.ValidateConfig(conf); len(validationErrors) > 0 { + for _, e := range validationErrors { + logrus.Error(e) + } + os.Exit(1) } - // TODO: Add answer-file - // Format: YAML - // Input via --answer-file - - // TODO: Validate inputs and save "control" variables to metrics - // Initialize new collection with default values - c, closeCollection := NewCollection(outputFile) - - // Save control variables from input - c.Control.General.OutputFile = outputFile - c.Control.General.AnswerFile = answerFile - c.Control.General.EnabledModules = enabledModules - c.Control.General.DisabledModules = disabledModules + c, closeCollection := NewCollection(conf) // Close collection defer closeCollection() - // Verify enabled modules - for _, name := range c.Control.General.EnabledModules { - if _, ok := modules[name]; !ok { - logrus.Fatal("Unknown module to enable: ", name) - } - } - - // Check for errors in init() - if len(wizardErrors) > 0 { - for _, err := range wizardErrors { - c.Log.Info(err) - } - } - // Initialize new metrics and defer function to save it to json c.Metric = metrics.New(getVersion()) defer func() { @@ -166,14 +146,15 @@ func main() { c.AddFileJSON("metrics.json", body) }() - c.Metric.Controls = c.Control + c.Metric.Controls = c.Config // Choose whether detailed collection will be enabled or not - if !detailedCollection { + if !conf.General.DetailedCollection { c.Detailed = false - c.Control.General.DetailedCollection = false + c.Config.General.DetailedCollection = false c.Log.Warn("Detailed collection is disabled") } else { + c.Detailed = true c.Log.Info("Detailed collection is enabled") } @@ -182,9 +163,9 @@ func main() { } // Set command Timeout from argument - c.ExecTimeout = c.Control.General.CommandTimeout + c.ExecTimeout = c.Config.General.CommandTimeout - // Collect modules + // Parse modules collectModules(c) // Save overall timing @@ -192,7 +173,7 @@ func main() { c.Log.Infof("Collection complete, took us %.3f seconds", c.Metric.Timings["total"].Seconds()) - // Collect obfuscation info + // Parse obfuscation info var files, count uint for _, o := range c.Obfuscators { @@ -206,7 +187,7 @@ func main() { } // get absolute path of outputFile - path, err := filepath.Abs(c.Control.General.OutputFile) + path, err := filepath.Abs(c.Config.General.OutputFile) if err != nil { c.Log.Debug(err) } @@ -214,16 +195,17 @@ func main() { c.Log.Infof("Generated ZIP file located at %s", path) } -// NewCollection starts a new collection. outputFile will be created. +// NewCollection starts a new collection based on given controls.Config // // Collection and cleanup function to defer are returned -func NewCollection(outputFile string) (*collection.Collection, func()) { - file, err := os.Create(outputFile) +func NewCollection(control config.Config) (*collection.Collection, func()) { + file, err := os.Create(control.General.OutputFile) if err != nil { - logrus.Fatalf("cant create or open file for collection for given value '%s' - %s", outputFile, err) + logrus.Fatalf("cant create or open file for collection for given value '%s' - %s", control.General.OutputFile, err) } c := collection.New(file) + c.Config = control consoleLevel := logrus.InfoLevel if verbose { @@ -257,15 +239,15 @@ func NewCollection(outputFile string) (*collection.Collection, func()) { } } -func addFlags() { +// parseFlags adds the default control arguments and parses them +func parseFlags() (err error) { + var generateAnswerFile bool // General arguments without interactive prompt - flag.BoolVar(&arguments.NonInteractive, "non-interactive", false, "Enable non-interactive mode") - flag.BoolVar(&printVersion, "version", false, "Print version and exit") - flag.BoolVar(&verbose, "verbose", false, "Enable verbose logging") - flag.BoolVar(&generateAnswerFile, "generate-answer-file", false, "Generate example answer file") - flag.StringVar(&answerFile, "answer-file", "", "Path to the answer file") - - flag.CommandLine.SortFlags = false + flag.BoolVar(&disableWizard, "disable-wizard", false, "Disable interactive wizard for input via stdin") + flag.BoolVarP(&printVersion, "version", "v", false, "Print version and exit") + flag.BoolVarP(&verbose, "verbose", "V", false, "Enable verbose logging") + flag.BoolVar(&generateAnswerFile, "generate-answer-file", false, "Generate an example answer-file with default values") + flag.StringVarP(&answerFile, "answer-file", "f", "", "Provide an answer-file to control the collection") // Output a proper help message with details flag.Usage = func() { @@ -284,48 +266,53 @@ func addFlags() { fmt.Println(Product, "version", getBuildInfo()) //nolint:forbidigo os.Exit(0) } + + if generateAnswerFile { + err = config.GenerateDefaultAnswerFile() + if err != nil { + return err + } + + os.Exit(0) + } + + return nil } -func addInteractiveWizard() []error { - args := arguments.NewHandler() +// startConfigWizard will start the stdin config wizard to provide config +func startConfigWizard(conf *config.Config) { + wizard := config.NewWizard() // Define arguments for interactive input via stdin - args.NewPromptStringVar(&outputFile, "output", util.BuildFileName(), "Filename for resulting zip", true, nil) - args.NewPromptStringSliceVar(&enabledModules, "enable", controls.ModulesOrder, "Enabled modules for collection (comma separated)", false, nil) - args.NewPromptStringSliceVar(&disabledModules, "disable", []string{}, "Explicit disabled modules for collection (comma separated)", false, func() bool { - if len(enabledModules) == 0 || len(enabledModules) == len(controls.ModulesOrder) { + wizard.NewPromptStringVar(&conf.General.OutputFile, "output", util.BuildFileName(), "Filename for resulting zip", true, nil) + wizard.NewPromptStringSliceVar(&conf.General.EnabledModules, "enable", []string{"all"}, "Which modules should be enabled? (Comma-seperated list of modules)", false, nil) + wizard.NewPromptStringSliceVar(&conf.General.DisabledModules, "disable", []string{}, "Explicit disabled modules for collection (comma separated)", false, func() bool { + if len(conf.General.EnabledModules) == 0 || len(conf.General.EnabledModules) == len(config.ModulesOrder) { return true } return false }) - args.NewPromptBoolVar(&detailedCollection, "detailed", true, "Enable detailed collection including logs and more", nil) + wizard.NewPromptBoolVar(&detailedCollection, "detailed", true, "Enable detailed collection including logs and more", nil) + // TODO Icinga 2 API endpoints // Icinga 2 specific arguments - args.NewPromptStringVar(&icinga2.APICred.Username, "icinga2-api-user", "", "Icinga 2: Username of global API user to collect data about Icinga 2 Infrastructure", false, isIcingaEnabled) - args.NewPromptStringVar(&icinga2.APICred.Password, "icinga2-api-pass", "", "Icinga 2: Password for global API user to collect data about Icinga 2 Infrastructure", false, isIcingaEnabled) - args.NewPromptStringSliceVar(&icinga2.APIEndpoints, "icinga2-api-endpoints", []string{}, "Icinga 2: Comma separated list of API Endpoints (including port) to collect data from. FQDN or IP address must be reachable. (Example: i2-master01.local:5665)", false, isIcingaEnabled) - - return args.Collect(strings.Join(controls.ModulesOrder, ",")) -} + //wizard.NewPromptStringVar(&icinga2.APICred.Username, "icinga2-api-user", "", "Icinga 2: Username of global API user to collect data about Icinga 2 Infrastructure", false, isIcingaEnabled) + //wizard.NewPromptStringVar(&icinga2.APICred.Password, "icinga2-api-pass", "", "Icinga 2: Password for global API user to collect data about Icinga 2 Infrastructure", false, isIcingaEnabled) + //wizard.NewPromptStringSliceVar(&icinga2.APIEndpoints, "icinga2-api-endpoints", []string{}, "Icinga 2: Comma separated list of API Endpoints (including port) to collect data from. FQDN or IP address must be reachable. (Example: i2-master01.local:5665)", false, isIcingaEnabled) -func isIcingaEnabled() bool { - for _, name := range enabledModules { - if name == "icinga2" { - return true - } - } + wizard.Parse(strings.Join(config.ModulesOrder, ",")) - return false + fmt.Printf("\nArgument wizard finished. Starting...\n\n") } func collectModules(c *collection.Collection) { // Check if module is enabled / disabled and call it - for _, name := range controls.ModulesOrder { + for _, name := range config.ModulesOrder { switch { - case util.StringInSlice(name, c.Control.General.DisabledModules): + case util.StringInSlice(name, c.Config.General.DisabledModules): c.Log.Debugf("Module %s is disabled", name) - case !util.StringInSlice(name, c.Control.General.EnabledModules): + case !util.StringInSlice(name, c.Config.General.EnabledModules): c.Log.Debugf("Module %s is not enabled", name) default: // Save current time @@ -334,12 +321,13 @@ func collectModules(c *collection.Collection) { c.Log.Debugf("Start collecting data for module %s", name) // Register custom obfuscators - for _, o := range c.Control.General.ExtraObfuscators { + for _, o := range c.Config.General.ExtraObfuscators { c.Log.Debugf("Adding custom obfuscator for '%s' to module %s", o, name) c.RegisterObfuscator(obfuscate.NewAny(o)) } // Call collection function for module + // TODO return errors? modules[name](c) // Save runtime of module diff --git a/modules/icinga2/api.go b/modules/icinga2/api.go deleted file mode 100644 index 98874f6..0000000 --- a/modules/icinga2/api.go +++ /dev/null @@ -1,128 +0,0 @@ -package icinga2 - -import ( - "context" - "crypto/tls" - "fmt" - "github.com/NETWAYS/support-collector/internal/collection" - "io" - "net" - "net/http" - "path/filepath" - "strings" - "time" -) - -type UserAuth struct { - Username string - Password string -} - -// APICred saves the user and password. Provided as arguments -var APICred UserAuth - -// APIEndpoints saves the FQDN or ip address for the endpoints, that will be collected. Provided as arguments. -var APIEndpoints []string - -// InitAPICollection starts to collect data from the Icinga 2 API for given endpoints -func InitAPICollection(c *collection.Collection) error { - // return if no endpoints are provided - if len(APIEndpoints) == 0 { - return fmt.Errorf("0 API endpoints provided. No data will be collected from remote targets") - } - - c.Log.Info("Start collection of Icinga 2 API endpoints") - - // return if username or password is not provided - if APICred.Username == "" || APICred.Password == "" { - return fmt.Errorf("API Endpoints provided but username and/or password are missing") - } - - for _, endpoint := range APIEndpoints { - // check if endpoint is reachable - err := endpointIsReachable(endpoint) - if err != nil { - c.Log.Warn(err) - continue - } - - c.Log.Debugf("Endpoint '%s' is reachable", endpoint) - - // collect /v1/status from endpoint - err = collectStatus(endpoint, c) - if err != nil { - c.Log.Warn(err) - } - } - - return nil -} - -// endpointIsReachable checks if the given endpoint is reachable within 5 sec -func endpointIsReachable(endpoint string) error { - timeout := 5 * time.Second - - // try to dial tcp connection within 5 seconds - conn, err := net.DialTimeout("tcp", endpoint, timeout) - if err != nil { - return fmt.Errorf("cant connect to endpoint '%s' within 5 seconds: %w", endpoint, err) - } - defer conn.Close() - - return nil -} - -// collectStatus requests $endpoint$/v1/status with APICred and saves the json result to file -func collectStatus(endpoint string, c *collection.Collection) error { - c.Log.Debugf("request data from endpoint '%s/v1/status'", endpoint) - - // allow insecure connections because of Icinga 2 certificates and add proxy if one is defined in the environments - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec - Proxy: http.ProxyFromEnvironment, - } - client := &http.Client{Transport: tr} - - // build context for request - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // build request - req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://%s/v1/status", endpoint), nil) - if err != nil { - return fmt.Errorf("cant build new request for '%s': %w", endpoint, err) - } - - // set authentication for request - req.SetBasicAuth(APICred.Username, APICred.Password) - - // make request - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("cant requests status from '%s': %w", endpoint, err) - } - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("cant read from response: %w", err) - } - - // if response code is not '200 OK' throw error and return - if resp.Status != "200 OK" { - return fmt.Errorf("request failed with status code %s: %s", resp.Status, string(body)) - } - - // add body to file - c.AddFileJSON(filepath.Join(ModuleName, fmt.Sprintf("api-v1_status_%s.json", extractHostname(endpoint))), body) - - return nil -} - -// extractsHostname takes the endpoint and extract the hostname of it -func extractHostname(endpoint string) string { - splits := strings.Split(endpoint, ":") - - return splits[0] -} diff --git a/modules/icinga2/collector.go b/modules/icinga2/collector.go index dc87034..846b0e1 100644 --- a/modules/icinga2/collector.go +++ b/modules/icinga2/collector.go @@ -1,10 +1,12 @@ package icinga2 import ( + "fmt" "github.com/NETWAYS/support-collector/internal/util" "os" "path/filepath" "regexp" + "strings" "github.com/NETWAYS/support-collector/internal/collection" "github.com/NETWAYS/support-collector/internal/obfuscate" @@ -151,9 +153,31 @@ func Collect(c *collection.Collection) { } } - // start the collection of remote api endpoints - err = InitAPICollection(c) - if err != nil { - c.Log.Warn(err) + // Collect from API endpoints if given + if len(c.Config.Icinga2.Endpoints) > 0 { + c.Log.Debug("Start to collect data from Icinga API endpoints") + for _, e := range c.Config.Icinga2.Endpoints { + c.Log.Debugf("New API endpoint found: '%s'. Trying...", e.Address) + + // Check if endpoint is reachable + if err := e.IsReachable(); err != nil { + c.Log.Warn(err) + continue + } + + c.Log.Debug("Collect from resource 'v1/status'") + + // Request stats and health from endpoint + res, err := e.Request("v1/status") + if err != nil { + c.Log.Warn(err) + continue + } + + // Save output to file. Replace "." in address with "_" and use as filename. + c.AddFileJSON(filepath.Join(ModuleName, "api", "v1", "status", fmt.Sprintf("%s.json", strings.Replace(e.Address, ".", "_", -1))), res) + + c.Log.Debugf("Successfully finished endpoint '%s'", e.Address) + } } } diff --git a/modules/icinga2/icingaApi/api.go b/modules/icinga2/icingaApi/api.go new file mode 100644 index 0000000..cc1c89f --- /dev/null +++ b/modules/icinga2/icingaApi/api.go @@ -0,0 +1,85 @@ +package icingaApi + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "time" +) + +type Endpoint struct { + Address string `yaml:"address" json:"address"` + Port int `yaml:"port" json:"port"` + Username string `yaml:"username" json:"-"` + Password string `yaml:"password" json:"-"` +} + +// Returns new *http.Client with insecure TLS and Proxy from ENV +func newClient() *http.Client { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec + Proxy: http.ProxyFromEnvironment, + } + client := &http.Client{Transport: tr} + + return client +} + +// IsReachable checks if the endpoint is reachable within 5 sec +func (endpoint *Endpoint) IsReachable() error { + // try to dial tcp connection within 5 seconds + conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", endpoint.Address, endpoint.Port), 5*time.Second) + if err != nil { + return fmt.Errorf("cant connect to endpoint '%s' within 5 seconds: %w", endpoint.Address, err) + } + defer conn.Close() + + return nil +} + +// Request prepares a new request for the given resourcePath and executes it. +// Url for the request is build by the given resourcePath, and the Endpoint details (url => 'https://:/') +// +// A context with 10sec timeout for the request is build. BasicAuth with username and password set. +// Returns err if something went wrong. Result is given as []byte. +func (endpoint *Endpoint) Request(resourcePath string) ([]byte, error) { + // Return err if no username or password provided + if endpoint.Username == "" || endpoint.Password == "" { + return nil, fmt.Errorf("invalid or no username or password provided for api endpoint '%s'", endpoint.Address) + } + + // Build context for the request + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Build with context and url + request, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://%s:%d/%s", endpoint.Address, endpoint.Port, resourcePath), nil) + if err != nil { + return nil, fmt.Errorf("cant build new request for '%s': %w", endpoint.Address, err) + } + + // Set basic auth for request + request.SetBasicAuth(endpoint.Username, endpoint.Password) + + response, err := newClient().Do(request) + if err != nil { + return nil, fmt.Errorf("cant make request for '%s' to '%s': %w", endpoint.Address, resourcePath, err) + } + + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, fmt.Errorf("cant read response for '%s' to '%s': %w", endpoint.Address, resourcePath, err) + } + + // if response code is not '200 OK' throw error and return + if response.Status != "200 OK" { + return nil, fmt.Errorf("request failed with status code %s: %s", response.Status, string(body)) + } + + return body, nil +}