Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add diagnostics collect command to elastic-agent. #28461

Merged
merged 6 commits into from
Oct 19, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions x-pack/elastic-agent/CHANGELOG.next.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,5 @@
- Support ephemeral containers in Kubernetes dynamic provider. {issue}27020[#27020] {pull}27707[27707]
- Add complete k8s metadata through composable provider. {pull}27691[27691]
- Add diagnostics command to gather beat metadata. {pull}28265[28265]
- Add diagnostics collect command to gather beat metadata, config, policy, and logs and bundle it into an archieve. {pull}28461[28461]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/archieve/archive

- Add `KIBANA_FLEET_SERVICE_TOKEN` to Elastic Agent container. {pull}28096[28096]
241 changes: 235 additions & 6 deletions x-pack/elastic-agent/pkg/agent/cmd/diagnostics.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,28 @@
package cmd

import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"time"

"github.com/hashicorp/go-multierror"
"github.com/spf13/cobra"
"gopkg.in/yaml.v2"

"github.com/elastic/beats/v7/x-pack/elastic-agent/pkg/agent/application/paths"
"github.com/elastic/beats/v7/x-pack/elastic-agent/pkg/agent/configuration"
"github.com/elastic/beats/v7/x-pack/elastic-agent/pkg/agent/control/client"
"github.com/elastic/beats/v7/x-pack/elastic-agent/pkg/agent/errors"
"github.com/elastic/beats/v7/x-pack/elastic-agent/pkg/cli"
"github.com/elastic/beats/v7/x-pack/elastic-agent/pkg/config/operations"
)

var diagOutputs = map[string]outputter{
Expand All @@ -25,7 +35,19 @@ var diagOutputs = map[string]outputter{
"yaml": yamlOutput,
}

func newDiagnosticsCommand(_ []string, streams *cli.IOStreams) *cobra.Command {
// DiagnosticsInfo a struct to track all inforation related to diagnostics for the agent.
type DiagnosticsInfo struct {
ProcMeta []client.ProcMeta
AgentVersion client.Version
}

// AgentConfig tracks all configuration that the agent uses, local files, rendered policies, beat inputs etc.
type AgentConfig struct {
ConfigLocal *configuration.Configuration
ConfigRendered map[string]interface{}
}

func newDiagnosticsCommand(s []string, streams *cli.IOStreams) *cobra.Command {
cmd := &cobra.Command{
Use: "diagnostics",
Short: "Gather diagnostics information from the elastic-agent and running processes.",
Expand All @@ -39,14 +61,41 @@ func newDiagnosticsCommand(_ []string, streams *cli.IOStreams) *cobra.Command {
}

cmd.Flags().String("output", "human", "Output the diagnostics information in either human, json, or yaml (default: human)")
cmd.AddCommand(newDiagnosticsCollectCommandWithArgs(s, streams))

return cmd
}

// DiagnosticsInfo a struct to track all inforation related to diagnostics for the agent.
type DiagnosticsInfo struct {
ProcMeta []client.ProcMeta
AgentVersion client.Version
func newDiagnosticsCollectCommandWithArgs(_ []string, streams *cli.IOStreams) *cobra.Command {
cmd := &cobra.Command{
Use: "collect",
Short: "Collect diagnostics information from the elastic-agent and write it to a zip archive.",
Long: "Collect diagnostics information from the elastic-agent and write it to a zip archive.\nNote that any credentials will appear in plain text.",
Args: cobra.MaximumNArgs(1),
RunE: func(c *cobra.Command, args []string) error {
file, _ := c.Flags().GetString("file")

if file == "" {
ts := time.Now().UTC()
file = "elastic-agent-diagnostics-" + ts.Format(time.RFC3339) + ".zip"
}

output, _ := c.Flags().GetString("output")
switch output {
case "yaml":
case "json":
default:
return fmt.Errorf("unsupported output: %s", output)
}

return diagnosticsCollectCmd(streams, file, output)
},
}

cmd.Flags().StringP("file", "f", "", "name of the output diagnostics zip archeive")
cmd.Flags().String("output", "yaml", "Output the collected information in either json, or yaml (default: yaml)") // replace output flag with different options

return cmd
}

func diagnosticCmd(streams *cli.IOStreams, cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -77,6 +126,39 @@ func diagnosticCmd(streams *cli.IOStreams, cmd *cobra.Command, args []string) er
return outputFunc(streams.Out, diag)
}

func diagnosticsCollectCmd(streams *cli.IOStreams, fileName, outputFormat string) error {
err := tryContainerLoadPaths()
if err != nil {
return err
}

ctx := handleSignal(context.Background())
innerCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()

diag, err := getDiagnostics(innerCtx)
if err == context.DeadlineExceeded {
return errors.New("timed out after 30 seconds trying to connect to Elastic Agent daemon")
} else if err == context.Canceled {
return nil
} else if err != nil {
return fmt.Errorf("failed to communicate with Elastic Agent daemon: %w", err)
}

cfg, err := gatherConfig()
if err != nil {
return fmt.Errorf("unable to gather config data: %w", err)
}

err = createZip(fileName, outputFormat, diag, cfg)
if err != nil {
return fmt.Errorf("unable to create archieve %q: %w", fileName, err)
}
fmt.Fprintf(streams.Out, "Created diagnostics archive %q\n", fileName)
fmt.Fprintln(streams.Out, "***** WARNING *****\nCreated archive may contain plain text credentials.\nEnsure that files in archieve are redacted before sharing.\n*******************")
return nil
}

func getDiagnostics(ctx context.Context) (DiagnosticsInfo, error) {
daemon := client.New()
diag := DiagnosticsInfo{}
Expand All @@ -94,7 +176,7 @@ func getDiagnostics(ctx context.Context) (DiagnosticsInfo, error) {

version, err := daemon.Version(ctx)
if err != nil {
return DiagnosticsInfo{}, err
return diag, err
}
diag.AgentVersion = version

Expand Down Expand Up @@ -132,3 +214,150 @@ func outputDiagnostics(w io.Writer, d DiagnosticsInfo) error {
tw.Flush()
return nil
}

func gatherConfig() (AgentConfig, error) {
cfg := AgentConfig{}
localCFG, err := loadConfig(nil)
if err != nil {
return cfg, err
}
cfg.ConfigLocal = localCFG

renderedCFG, err := operations.LoadFullAgentConfig(paths.ConfigFile(), true)
if err != nil {
return cfg, err
}
// Must force *config.Config to map[string]interface{} in order to write to a file.
mapCFG, err := renderedCFG.ToMapStr()
if err != nil {
return cfg, err
}
cfg.ConfigRendered = mapCFG

return cfg, nil
}

func createZip(fileName, outputFormat string, diag DiagnosticsInfo, cfg AgentConfig) error {
f, err := os.Create(fileName)
if err != nil {
return err
}
zw := zip.NewWriter(f)

zf, err := zw.Create("meta/")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the docs for zip indicate that ending a name with a slash creates a directory.

if err != nil {
return closeHandlers(err, zw, f)
}

zf, err = zw.Create("meta/elastic-agent-version." + outputFormat)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tested on windows as well? i'm wondering because of / vs \ as a path separator.
same thing all over this file

if err != nil {
return closeHandlers(err, zw, f)
}
if err := writeFile(zf, outputFormat, diag.AgentVersion); err != nil {
return closeHandlers(err, zw, f)
}

for _, m := range diag.ProcMeta {
zf, err = zw.Create("meta/" + m.Name + "-" + m.RouteKey + "." + outputFormat)
if err != nil {
return closeHandlers(err, zw, f)
}

if err := writeFile(zf, outputFormat, m); err != nil {
return closeHandlers(err, zw, f)
}
}

zf, err = zw.Create("config/")
if err != nil {
return closeHandlers(err, zw, f)
}

zf, err = zw.Create("config/elastic-agent-local." + outputFormat)
if err != nil {
return closeHandlers(err, zw, f)
}
if err := writeFile(zf, outputFormat, cfg.ConfigLocal); err != nil {
return closeHandlers(err, zw, f)
}

zf, err = zw.Create("config/elastic-agent-policy." + outputFormat)
if err != nil {
return closeHandlers(err, zw, f)
}
if err := writeFile(zf, outputFormat, cfg.ConfigRendered); err != nil {
return closeHandlers(err, zw, f)
}

if err := zipLogs(zw); err != nil {
return closeHandlers(err, zw, f)
}

return closeHandlers(nil, zw, f)
}

// zipLogs walks paths.Logs() and copies the file structure into zw in "logs/"
func zipLogs(zw *zip.Writer) error {
_, err := zw.Create("logs/")
if err != nil {
return err
}

// using Data() + "/logs", for some reason default paths/Logs() is the home dir...
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused as to why path.Logs() returned <path> instead of <path>/data/logs (as I expected), anyone have insight into this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logPath defaults to topPath which is what you see if no override is provided x-pack/elastic-agent/pkg/agent/application/paths/common.go:L40

logPath := filepath.Join(paths.Data(), "logs") + string(filepath.Separator)
return filepath.WalkDir(logPath, func(path string, d fs.DirEntry, fErr error) error {
if fErr != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's hande IsNotExist(err) and continue without processing if so. i've had issues with Walk before and this is not serious but can break the process

return fmt.Errorf("unable to walk log dir: %w", fErr)
}
name := strings.TrimPrefix(path, logPath)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

name in this case gives the name of the directory/file within log/ to write into the archive. For exampledefault/filebeat-json.log

if name == "" {
return nil
}

if d.IsDir() {
_, err := zw.Create("logs/" + name + "/")
if err != nil {
fmt.Errorf("unable to create log directory in archive: %w", err)
}
return nil
}

lf, err := os.Open(path)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not closed, better to extract to a func and use defer to close the file

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, just added closeHandlers to my exit points

if err != nil {
return fmt.Errorf("unable to open log file: %w", err)
}
zf, err := zw.Create("logs/" + name)
if err != nil {
return fmt.Errorf("unable to create log file in archive: %w", err)
}
_, err = io.Copy(zf, lf)
if err != nil {
return fmt.Errorf("log file copy failed: %w", err)
}

return nil
})
}

// writeFile writes json or yaml data from the interface to the writer.
func writeFile(w io.Writer, outputFormat string, v interface{}) error {
if outputFormat == "yaml" {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not serious at all but we default to yaml when using a command, we could also use json here if specified and default to yaml as well

ye := yaml.NewEncoder(w)
err := ye.Encode(v)
return closeHandlers(err, ye)
}
je := json.NewEncoder(w)
je.SetIndent("", " ")
return je.Encode(v)
}

func closeHandlers(err error, closers ...io.Closer) error {
var mErr *multierror.Error
mErr = multierror.Append(mErr, err)
for _, c := range closers {
if inErr := c.Close(); inErr != nil {
mErr = multierror.Append(mErr, inErr)
}
}
return mErr.ErrorOrNil()
}