diff --git a/CHANGELOG.md b/CHANGELOG.md index b2b15938fb..27e9b9873f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ v1.5.0-rc.0 ### Features +- Add support bundle generation via the API endpoint /-/support (@dehaansa) + - Add the function `path_join` to the stdlib. (@wildum) - Add `pyroscope.receive_http` component to receive and forward Pyroscope profiles (@marcsanmi) diff --git a/docs/sources/reference/cli/run.md b/docs/sources/reference/cli/run.md index f246bb894e..c154696260 100644 --- a/docs/sources/reference/cli/run.md +++ b/docs/sources/reference/cli/run.md @@ -42,6 +42,7 @@ The following flags are supported: * `--server.http.ui-path-prefix`: Base path where the UI is exposed (default `/`). * `--storage.path`: Base directory where components can store data (default `data-alloy/`). * `--disable-reporting`: Disable [data collection][] (default `false`). +* `--disable-support-bundle`: Disable [support bundle][] endpoint (default `false`). * `--cluster.enabled`: Start {{< param "PRODUCT_NAME" >}} in clustered mode (default `false`). * `--cluster.node-name`: The name to use for this node (defaults to the environment's hostname). * `--cluster.join-addresses`: Comma-separated list of addresses to join the cluster at (default `""`). Mutually exclusive with `--cluster.discover-peers`. @@ -178,6 +179,7 @@ Refer to [alloy convert][] for more details on how `extra-args` work. [go-discover]: https://github.com/hashicorp/go-discover [in-memory HTTP traffic]: ../../../get-started/component_controller/#in-memory-traffic [data collection]: ../../../data-collection/ +[support bundle]: ../../../troubleshoot/support_bundle [components]: ../../get-started/components/ [component controller]: ../../../get-started/component_controller/ [UI]: ../../../troubleshoot/debug/#clustering-page diff --git a/docs/sources/troubleshoot/support_bundle.md b/docs/sources/troubleshoot/support_bundle.md new file mode 100644 index 0000000000..2bb870bc5b --- /dev/null +++ b/docs/sources/troubleshoot/support_bundle.md @@ -0,0 +1,51 @@ +--- +canonical: https://grafana.com/docs/alloy/latest/troubleshoot/support_bundle/ +description: Learn how to generate a support bundle +title: Generate a support bundle +menuTitle: Generate a support bundle +weight: 300 +--- + +Public preview + +# Generate a support bundle + +{{< docs/public-preview product="Generate support bundle" >}} + +The `/-/support?duration=N` endpoint returns a support bundle, a zip file that contains information +about a running {{< param "PRODUCT_NAME" >}} instance, and can be used as a baseline of information when trying +to debug an issue. + +This feature is not covered by our [backward-compatibility][backward-compatibility] guarantees. + +{{< admonition type="note" >}} +This endpoint is enabled by default, but may be disabled using the `--disable-support-bundle` runtime flag. +{{< /admonition >}} + +The duration parameter is optional, must be less than or equal to the +configured HTTP server write timeout, and if not provided, defaults to it. +The endpoint is only exposed to the {{< param "PRODUCT_NAME" >}} HTTP server listen address, which +defaults to `localhost:12345`. + +The support bundle contains all information in plain text, so you can +inspect it before sharing to verify that no sensitive information has leaked. + +In addition, you can inspect the [supportbundle implementation](https://github.com/grafana/alloy/tree/internal/service/http/supportbundle.go) +to verify the code used to generate these bundles. + +A support bundle contains the following data: +* `alloy-components.json` contains information about the [components][components] running on this {{< param "PRODUCT_NAME" >}} instance, generated by the +`/api/v0/web/components` endpoint. +* `alloy-logs.txt` contains the logs during the bundle generation. +* `alloy-metadata.yaml` contains the {{< param "PRODUCT_NAME" >}} build version and the installation's operating system, architecture, and uptime. +* `alloy-metrics.txt` contains a snapshot of the internal metrics for {{< param "PRODUCT_NAME" >}}. +* `alloy-peers.json` contains information about the identified cluster peers of this {{< param "PRODUCT_NAME" >}} instance, generated by the +`/api/v0/web/peers` endpoint. +* `alloy-runtime-flags.txt` contains the values of the runtime flags available in {{< param "PRODUCT_NAME" >}}. +* The `pprof/` directory contains Go runtime profiling data (CPU, heap, goroutine, mutex, block profiles) as exported by the pprof package. +Refer to the [profile][profile] documentation for more details on how to use this information. + +[profile]: ../profile +[components]: ../../get-started/components/ +[alloy-repo]: https://github.com/grafana/alloy/issues +[backward-compatibility]: ../../introduction/backward-compatibility \ No newline at end of file diff --git a/go.mod b/go.mod index 2265f3d386..1f1e516f04 100644 --- a/go.mod +++ b/go.mod @@ -845,6 +845,8 @@ require ( go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.7.0 // indirect ) +require github.com/mackerelio/go-osstat v0.2.5 + // NOTE: replace directives below must always be *temporary*. // // Adding a replace directive to change a module to a fork of a module will diff --git a/go.sum b/go.sum index 568a6f297c..5aa02756c4 100644 --- a/go.sum +++ b/go.sum @@ -1702,6 +1702,8 @@ github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c h1:VtwQ41oftZwlMn github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= github.com/lyft/protoc-gen-validate v0.0.0-20180911180927-64fcb82c878e/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= +github.com/mackerelio/go-osstat v0.2.5 h1:+MqTbZUhoIt4m8qzkVoXUJg1EuifwlAJSk4Yl2GXh+o= +github.com/mackerelio/go-osstat v0.2.5/go.mod h1:atxwWF+POUZcdtR1wnsUcQxTytoHG4uhl2AKKzrOajY= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= diff --git a/internal/alloycli/cmd_run.go b/internal/alloycli/cmd_run.go index 33f9798eb7..306a030c55 100644 --- a/internal/alloycli/cmd_run.go +++ b/internal/alloycli/cmd_run.go @@ -23,6 +23,7 @@ import ( "github.com/grafana/ckit/peer" "github.com/prometheus/client_golang/prometheus" "github.com/spf13/cobra" + "github.com/spf13/pflag" "go.opentelemetry.io/otel" "golang.org/x/exp/maps" @@ -64,6 +65,7 @@ func runCommand() *cobra.Command { clusterAdvInterfaces: advertise.DefaultInterfaces, clusterMaxJoinPeers: 5, clusterRejoinInterval: 60 * time.Second, + disableSupportBundle: false, } cmd := &cobra.Command{ @@ -100,7 +102,7 @@ depending on the nature of the reload error. SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - return r.Run(args[0]) + return r.Run(cmd, args[0]) }, } @@ -111,6 +113,8 @@ depending on the nature of the reload error. cmd.Flags().StringVar(&r.uiPrefix, "server.http.ui-path-prefix", r.uiPrefix, "Prefix to serve the HTTP UI at") cmd.Flags(). BoolVar(&r.enablePprof, "server.http.enable-pprof", r.enablePprof, "Enable /debug/pprof profiling endpoints.") + cmd.Flags(). + BoolVar(&r.disableSupportBundle, "server.http.disable-support-bundle", r.disableSupportBundle, "Disable /-/support support bundle retrieval.") // Cluster flags cmd.Flags(). @@ -184,9 +188,10 @@ type alloyRun struct { configBypassConversionErrors bool configExtraArgs string enableCommunityComps bool + disableSupportBundle bool } -func (fr *alloyRun) Run(configPath string) error { +func (fr *alloyRun) Run(cmd *cobra.Command, configPath string) error { var wg sync.WaitGroup defer wg.Wait() @@ -275,8 +280,15 @@ func (fr *alloyRun) Run(configPath string) error { return err } + runtimeFlags := []string{} + if !fr.disableSupportBundle { + cmd.Flags().VisitAll(func(f *pflag.Flag) { + runtimeFlags = append(runtimeFlags, fmt.Sprintf("%s=%s", f.Name, f.Value.String())) + }) + } + httpService := httpservice.New(httpservice.Options{ - Logger: log.With(l, "service", "http"), + Logger: l, Tracer: t, Gatherer: prometheus.DefaultGatherer, @@ -286,6 +298,11 @@ func (fr *alloyRun) Run(configPath string) error { HTTPListenAddr: fr.httpListenAddr, MemoryListenAddr: fr.inMemoryAddr, EnablePProf: fr.enablePprof, + MinStability: fr.minStability, + BundleContext: httpservice.SupportBundleContext{ + RuntimeFlags: runtimeFlags, + DisableSupportBundle: fr.disableSupportBundle, + }, }) remoteCfgService, err := remotecfgservice.New(remotecfgservice.Options{ diff --git a/internal/runtime/logging/logger.go b/internal/runtime/logging/logger.go index 5bc2402772..4e1b5bd5a3 100644 --- a/internal/runtime/logging/logger.go +++ b/internal/runtime/logging/logger.go @@ -55,6 +55,12 @@ func New(w io.Writer, o Options) (*Logger, error) { return l, nil } +// NewNop returns a logger that does nothing +func NewNop() *Logger { + l, _ := NewDeferred(io.Discard) + return l +} + // NewDeferred creates a new logger with the default log level and format. // The logger is not updated during initialization. func NewDeferred(w io.Writer) (*Logger, error) { @@ -63,7 +69,6 @@ func NewDeferred(w io.Writer) (*Logger, error) { format formatVar writer writerVar ) - l := &Logger{ inner: w, @@ -104,11 +109,10 @@ func (l *Logger) Update(o Options) error { l.level.Set(slogLevel(o.Level).Level()) l.format.Set(o.Format) - newWriter := l.inner + l.writer.SetInnerWriter(l.inner) if len(o.WriteTo) > 0 { - newWriter = io.MultiWriter(l.inner, &lokiWriter{o.WriteTo}) + l.writer.SetLokiWriter(&lokiWriter{o.WriteTo}) } - l.writer.Set(newWriter) // Build all our deferred handlers if l.deferredSlog != nil { @@ -133,6 +137,14 @@ func (l *Logger) Update(o Options) error { return nil } +func (l *Logger) SetTemporaryWriter(w io.Writer) { + l.writer.SetTemporaryWriter(w) +} + +func (l *Logger) RemoveTemporaryWriter() { + l.writer.RemoveTemporaryWriter() +} + // Log implements log.Logger. func (l *Logger) Log(kvps ...interface{}) error { // Buffer logs before confirming log format is configured in `logging` block @@ -215,24 +227,63 @@ func (f *formatVar) Set(format Format) { type writerVar struct { mut sync.RWMutex - w io.Writer + + lokiWriter *lokiWriter + innerWriter io.Writer + tmpWriter io.Writer } -func (w *writerVar) Set(inner io.Writer) { +func (w *writerVar) SetTemporaryWriter(writer io.Writer) { w.mut.Lock() defer w.mut.Unlock() - w.w = inner + w.tmpWriter = writer } -func (w *writerVar) Write(p []byte) (n int, err error) { +func (w *writerVar) RemoveTemporaryWriter() { + w.mut.Lock() + defer w.mut.Unlock() + w.tmpWriter = nil +} + +func (w *writerVar) SetInnerWriter(writer io.Writer) { + w.mut.Lock() + defer w.mut.Unlock() + w.innerWriter = writer +} + +func (w *writerVar) SetLokiWriter(writer *lokiWriter) { + w.mut.Lock() + defer w.mut.Unlock() + w.lokiWriter = writer +} + +func (w *writerVar) Write(p []byte) (int, error) { w.mut.RLock() defer w.mut.RUnlock() - if w.w == nil { + if w.innerWriter == nil { return 0, fmt.Errorf("no writer available") } - return w.w.Write(p) + // The following is effectively an io.Multiwriter, but without updating + // the Multiwriter each time tmpWriter is added or removed. + if _, err := w.innerWriter.Write(p); err != nil { + return 0, err + } + + if w.lokiWriter != nil { + if _, err := w.lokiWriter.Write(p); err != nil { + return 0, err + } + } + + if w.tmpWriter != nil { + if _, err := w.tmpWriter.Write(p); err != nil { + return 0, err + } + } + + return len(p), nil } type bufferedItem struct { diff --git a/internal/service/http/http.go b/internal/service/http/http.go index ff070b330b..590802b9b4 100644 --- a/internal/service/http/http.go +++ b/internal/service/http/http.go @@ -2,6 +2,7 @@ package http import ( + "bytes" "context" "crypto/tls" "fmt" @@ -11,14 +12,17 @@ import ( "os" "path" "sort" + "strconv" "strings" "sync" + "time" "github.com/go-kit/log" "github.com/gorilla/mux" "github.com/grafana/alloy/internal/component" "github.com/grafana/alloy/internal/featuregate" alloy_runtime "github.com/grafana/alloy/internal/runtime" + "github.com/grafana/alloy/internal/runtime/logging" "github.com/grafana/alloy/internal/runtime/logging/level" "github.com/grafana/alloy/internal/service" "github.com/grafana/alloy/internal/service/remotecfg" @@ -40,16 +44,18 @@ const ServiceName = "http" // Options are used to configure the HTTP service. Options are constant for the // lifetime of the HTTP service. type Options struct { - Logger log.Logger // Where to send logs. + Logger *logging.Logger // Where to send logs. Tracer trace.TracerProvider // Where to send traces. Gatherer prometheus.Gatherer // Where to collect metrics from. ReadyFunc func() bool ReloadFunc func() (*alloy_runtime.Source, error) - HTTPListenAddr string // Address to listen for HTTP traffic on. - MemoryListenAddr string // Address to accept in-memory traffic on. - EnablePProf bool // Whether pprof endpoints should be exposed. + HTTPListenAddr string // Address to listen for HTTP traffic on. + MemoryListenAddr string // Address to accept in-memory traffic on. + EnablePProf bool // Whether pprof endpoints should be exposed. + MinStability featuregate.Stability // Minimum stability level to utilize for feature gates + BundleContext SupportBundleContext // Context for delivering a support bundle } // Arguments holds runtime settings for the HTTP service. @@ -58,14 +64,20 @@ type Arguments struct { } type Service struct { - log log.Logger - tracer trace.TracerProvider - gatherer prometheus.Gatherer - opts Options + // globalLogger allows us to leverage the logging struct for setting a temporary + // logger for support bundle usage and still leverage log.With for logging in the service + globalLogger *logging.Logger + log log.Logger + tracer trace.TracerProvider + gatherer prometheus.Gatherer + opts Options winMut sync.Mutex win *server.WinCertStoreHandler + // Used to enforce single-flight requests to supportHandler + supportBundleMut sync.Mutex + // publicLis and tcpLis are used to lazily enable TLS, since TLS is // optionally configurable at runtime. // @@ -95,7 +107,7 @@ func New(opts Options) *Service { ) if l == nil { - l = log.NewNopLogger() + l = logging.NewNop() } if t == nil { t = noop.NewTracerProvider() @@ -113,10 +125,11 @@ func New(opts Options) *Service { _ = publicLis.SetInner(tcpLis) return &Service{ - log: l, - tracer: t, - gatherer: r, - opts: opts, + globalLogger: l, + log: log.With(l, "service", "http"), + tracer: t, + gatherer: r, + opts: opts, publicLis: publicLis, tcpLis: tcpLis, @@ -211,6 +224,9 @@ func (s *Service) Run(ctx context.Context, host service.Host) error { }).Methods(http.MethodGet, http.MethodPost) } + // Wire in support bundle generator + r.HandleFunc("/-/support", s.supportHandler).Methods("GET") + // Wire custom service handlers for services which depend on the http // service. // @@ -243,6 +259,70 @@ func (s *Service) Run(ctx context.Context, host service.Host) error { return nil } +func (s *Service) supportHandler(rw http.ResponseWriter, r *http.Request) { + s.supportBundleMut.Lock() + defer s.supportBundleMut.Unlock() + + // TODO(dehaansa) remove this check once the support bundle is generally available + if !s.opts.MinStability.Permits(featuregate.StabilityPublicPreview) { + rw.WriteHeader(http.StatusForbidden) + _, _ = rw.Write([]byte("support bundle generation is only available in public preview. Use" + + " --stability.level command-line flag to enable public-preview features")) + return + } + + if s.opts.BundleContext.DisableSupportBundle { + rw.WriteHeader(http.StatusForbidden) + _, _ = rw.Write([]byte("support bundle generation is disabled; it can be re-enabled by removing the --disable-support-bundle flag")) + return + } + + duration := getServerWriteTimeout(r) + if r.URL.Query().Has("duration") { + d, err := strconv.Atoi(r.URL.Query().Get("duration")) + if err != nil { + http.Error(rw, fmt.Sprintf("duration value (in seconds) should be a positive integer: %s", err), http.StatusBadRequest) + return + } + if d < 1 { + http.Error(rw, "duration value (in seconds) should be larger than 1", http.StatusBadRequest) + return + } + if float64(d) > duration.Seconds() { + http.Error(rw, "duration value exceeds the server's write timeout", http.StatusBadRequest) + return + } + duration = time.Duration(d) * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), duration) + defer cancel() + + var logsBuffer bytes.Buffer + syncBuff := log.NewSyncWriter(&logsBuffer) + s.globalLogger.SetTemporaryWriter(syncBuff) + defer func() { + s.globalLogger.RemoveTemporaryWriter() + }() + + bundle, err := ExportSupportBundle(ctx, s.opts.BundleContext.RuntimeFlags, s.opts.HTTPListenAddr, s.Data().(Data).DialFunc) + if err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } + if err := ServeSupportBundle(rw, bundle, &logsBuffer); err != nil { + http.Error(rw, err.Error(), http.StatusInternalServerError) + return + } +} + +func getServerWriteTimeout(r *http.Request) time.Duration { + srv, ok := r.Context().Value(http.ServerContextKey).(*http.Server) + if ok && srv.WriteTimeout != 0 { + return srv.WriteTimeout + } + return 30 * time.Second +} + // getServiceRoutes returns a sorted list of service routes for services which // depend on the HTTP service. // diff --git a/internal/service/http/http_test.go b/internal/service/http/http_test.go index bfc212471d..2481fcd6cb 100644 --- a/internal/service/http/http_test.go +++ b/internal/service/http/http_test.go @@ -169,7 +169,7 @@ func newTestEnvironment(t *testing.T) (*testEnvironment, error) { } svc := New(Options{ - Logger: util.TestLogger(t), + Logger: util.TestAlloyLogger(t), Tracer: noop.NewTracerProvider(), Gatherer: prometheus.NewRegistry(), diff --git a/internal/service/http/supportbundle.go b/internal/service/http/supportbundle.go new file mode 100644 index 0000000000..3c75c35150 --- /dev/null +++ b/internal/service/http/supportbundle.go @@ -0,0 +1,211 @@ +package http + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "io" + "net/http" + "path/filepath" + "runtime" + "runtime/pprof" + "strings" + "time" + + "github.com/grafana/alloy/internal/build" + "github.com/grafana/alloy/internal/static/server" + "github.com/mackerelio/go-osstat/uptime" + "gopkg.in/yaml.v3" +) + +// SupportBundleContext groups the relevant context that is used in the HTTP +// service config for the support bundle +type SupportBundleContext struct { + DisableSupportBundle bool // Whether support bundle endpoint should be disabled. + RuntimeFlags []string // Alloy runtime flags to send with support bundle +} + +// Bundle collects all the data that is exposed as a support bundle. +type Bundle struct { + meta []byte + alloyMetrics []byte + components []byte + peers []byte + runtimeFlags []byte + heapBuf *bytes.Buffer + goroutineBuf *bytes.Buffer + blockBuf *bytes.Buffer + mutexBuf *bytes.Buffer + cpuBuf *bytes.Buffer +} + +// Metadata contains general runtime information about the current Alloy environment. +type Metadata struct { + BuildVersion string `yaml:"build_version"` + OS string `yaml:"os"` + Architecture string `yaml:"architecture"` + Uptime float64 `yaml:"uptime"` +} + +// ExportSupportBundle gathers the information required for the support bundle. +func ExportSupportBundle(ctx context.Context, runtimeFlags []string, srvAddress string, dialContext server.DialContextFunc) (*Bundle, error) { + // The block profiler is disabled by default. Temporarily enable recording + // of all blocking events. Also, temporarily record all mutex contentions, + // and defer restoring of earlier mutex profiling fraction. + runtime.SetBlockProfileRate(1) + old := runtime.SetMutexProfileFraction(1) + defer func() { + runtime.SetBlockProfileRate(0) + runtime.SetMutexProfileFraction(old) + }() + + // Gather runtime metadata. + ut, err := uptime.Get() + if err != nil { + return nil, err + } + m := Metadata{ + BuildVersion: build.Version, + OS: runtime.GOOS, + Architecture: runtime.GOARCH, + Uptime: ut.Seconds(), + } + meta, err := yaml.Marshal(m) + if err != nil { + return nil, fmt.Errorf("failed to marshal support bundle metadata: %s", err) + } + + var httpClient http.Client + httpClient.Transport = &http.Transport{DialContext: dialContext} + // Gather Alloy's own metrics. + alloyMetrics, err := retrieveAPIEndpoint(httpClient, srvAddress, "metrics") + if err != nil { + return nil, fmt.Errorf("failed to get internal Alloy metrics: %s", err) + } + // Gather running component configuration + components, err := retrieveAPIEndpoint(httpClient, srvAddress, "api/v0/web/components") + if err != nil { + return nil, fmt.Errorf("failed to get component details: %s", err) + } + // Gather cluster peers information + peers, err := retrieveAPIEndpoint(httpClient, srvAddress, "api/v0/web/peers") + if err != nil { + return nil, fmt.Errorf("failed to get peer details: %s", err) + } + + // Export pprof data. + var ( + cpuBuf bytes.Buffer + heapBuf bytes.Buffer + goroutineBuf bytes.Buffer + blockBuf bytes.Buffer + mutexBuf bytes.Buffer + ) + err = pprof.StartCPUProfile(&cpuBuf) + if err != nil { + return nil, err + } + deadline, _ := ctx.Deadline() + // Sleep for the remaining of the context deadline, but leave some time for + // the rest of the bundle to be exported successfully. + time.Sleep(time.Until(deadline) - 200*time.Millisecond) + pprof.StopCPUProfile() + + p := pprof.Lookup("heap") + if err := p.WriteTo(&heapBuf, 0); err != nil { + return nil, err + } + p = pprof.Lookup("goroutine") + if err := p.WriteTo(&goroutineBuf, 0); err != nil { + return nil, err + } + p = pprof.Lookup("block") + if err := p.WriteTo(&blockBuf, 0); err != nil { + return nil, err + } + p = pprof.Lookup("mutex") + if err := p.WriteTo(&mutexBuf, 0); err != nil { + return nil, err + } + + // Finally, bundle everything up to be served, either as a zip from + // memory, or exported to a directory. + bundle := &Bundle{ + meta: meta, + alloyMetrics: alloyMetrics, + components: components, + peers: peers, + runtimeFlags: []byte(strings.Join(runtimeFlags, "\n")), + heapBuf: &heapBuf, + goroutineBuf: &goroutineBuf, + blockBuf: &blockBuf, + mutexBuf: &mutexBuf, + cpuBuf: &cpuBuf, + } + + return bundle, nil +} + +func retrieveAPIEndpoint(httpClient http.Client, srvAddress, endpoint string) ([]byte, error) { + url := fmt.Sprintf("http://%s/%s", srvAddress, endpoint) + resp, err := httpClient.Get(url) + if err != nil { + return nil, err + } + res, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return res, nil +} + +// ServeSupportBundle the collected data and logs as a zip file over the given +// http.ResponseWriter. +func ServeSupportBundle(rw http.ResponseWriter, b *Bundle, logsBuf *bytes.Buffer) error { + zw := zip.NewWriter(rw) + rw.Header().Set("Content-Type", "application/zip") + rw.Header().Set("Content-Disposition", "attachment; filename=\"alloy-support-bundle.zip\"") + + zipStructure := map[string][]byte{ + "alloy-metadata.yaml": b.meta, + "alloy-components.json": b.components, + "alloy-peers.json": b.peers, + "alloy-metrics.txt": b.alloyMetrics, + "alloy-runtime-flags.txt": b.runtimeFlags, + "alloy-logs.txt": logsBuf.Bytes(), + "pprof/cpu.pprof": b.cpuBuf.Bytes(), + "pprof/heap.pprof": b.heapBuf.Bytes(), + "pprof/goroutine.pprof": b.goroutineBuf.Bytes(), + "pprof/mutex.pprof": b.mutexBuf.Bytes(), + "pprof/block.pprof": b.blockBuf.Bytes(), + } + + for fn, b := range zipStructure { + if b != nil { + path := append([]string{"alloy-support-bundle"}, strings.Split(fn, "/")...) + if err := writeByteSlice(zw, b, path...); err != nil { + return err + } + } + } + + err := zw.Close() + if err != nil { + return fmt.Errorf("failed to flush the zip writer: %v", err) + } + return nil +} + +func writeByteSlice(zw *zip.Writer, b []byte, fn ...string) error { + f, err := zw.Create(filepath.Join(fn...)) + if err != nil { + return err + } + _, err = f.Write(b) + if err != nil { + return err + } + return nil +}