diff --git a/cmd/finch/main.go b/cmd/finch/main.go index 6b301b8fc..e0faf8740 100644 --- a/cmd/finch/main.go +++ b/cmd/finch/main.go @@ -9,6 +9,8 @@ import ( "io" "os" + "github.com/runfinch/finch/pkg/support" + "github.com/runfinch/finch/pkg/disk" "github.com/runfinch/finch/pkg/command" @@ -88,6 +90,11 @@ var newApp = func(logger flog.Logger, fp path.Finch, fs afero.Fs, fc *config.Fin fp.QEMUBinDir(), system.NewStdLib(), ) + supportBundleBuilder := support.NewBundleBuilder( + logger, + fs, + support.NewBundleConfig(fp, system.NewStdLib().Env("HOME"), ecc), + ) // append nerdctl commands allCommands := initializeNerdctlCommands(lcc, logger) @@ -95,6 +102,7 @@ var newApp = func(logger flog.Logger, fp path.Finch, fs afero.Fs, fc *config.Fin allCommands = append(allCommands, newVersionCommand(lcc, logger, stdOut), virtualMachineCommands(logger, fp, lcc, ecc, fs, fc), + newSupportBundleCommand(logger, supportBundleBuilder, lcc), ) rootCmd.AddCommand(allCommands...) diff --git a/cmd/finch/main_test.go b/cmd/finch/main_test.go index 09a16f50a..43df113a7 100644 --- a/cmd/finch/main_test.go +++ b/cmd/finch/main_test.go @@ -138,8 +138,8 @@ func TestNewApp(t *testing.T) { assert.Equal(t, cmd.Version, version.Version) assert.Equal(t, cmd.SilenceUsage, true) assert.Equal(t, cmd.SilenceErrors, true) - // confirm the number of command, comprised of nerdctl commands + finch commands (version, vm) - assert.Equal(t, len(cmd.Commands()), len(nerdctlCmds)+2) + // confirm the number of command, comprised of nerdctl commands + finch commands (version, vm, support-bundle) + assert.Equal(t, len(cmd.Commands()), len(nerdctlCmds)+3) // PersistentPreRunE should set logger level to debug if the debug flag exists. mockCmd := &cobra.Command{} diff --git a/cmd/finch/support_bundle.go b/cmd/finch/support_bundle.go new file mode 100644 index 000000000..211bd82de --- /dev/null +++ b/cmd/finch/support_bundle.go @@ -0,0 +1,87 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/runfinch/finch/pkg/command" + "github.com/runfinch/finch/pkg/flog" + "github.com/runfinch/finch/pkg/lima" + "github.com/runfinch/finch/pkg/support" +) + +func newSupportBundleCommand(logger flog.Logger, builder *support.BundleBuilder, lcc command.LimaCmdCreator) *cobra.Command { + supportBundleCommand := &cobra.Command{ + Use: "support-bundle", + Short: "Gathers logs and info into a bundle to help debug issues", + } + supportBundleCommand.AddCommand( + newSupportBundleGenerateCommand(logger, builder, lcc), + ) + return supportBundleCommand +} + +func newSupportBundleGenerateCommand(logger flog.Logger, builder *support.BundleBuilder, lcc command.LimaCmdCreator) *cobra.Command { + supportBundleGenerateCommand := &cobra.Command{ + Use: "generate", + Args: cobra.NoArgs, + Short: "Generate support bundle", + RunE: newGenerateSupportBundleAction(logger, builder, lcc).runAdapter, + } + + return supportBundleGenerateCommand +} + +type generateSupportBundleAction struct { + logger flog.Logger + builder *support.BundleBuilder + lcc command.LimaCmdCreator +} + +func newGenerateSupportBundleAction( + logger flog.Logger, + builder *support.BundleBuilder, + lcc command.LimaCmdCreator, +) *generateSupportBundleAction { + return &generateSupportBundleAction{ + logger: logger, + builder: builder, + lcc: lcc, + } +} + +func (a *generateSupportBundleAction) runAdapter(cmd *cobra.Command, args []string) error { + return a.run() +} + +func (a *generateSupportBundleAction) run() error { + err := a.assertVMExists() + if err != nil { + return err + } + a.logger.Info("Generating support bundle...") + bundleFile, err := a.builder.GenerateSupportBundle() + if err != nil { + return err + } + a.logger.Infof("Bundle created: %s", bundleFile) + return nil +} + +func (a *generateSupportBundleAction) assertVMExists() error { + status, err := lima.GetVMStatus(a.lcc, a.logger, limaInstanceName) + if err != nil { + return err + } + switch status { + case lima.Nonexistent: + return fmt.Errorf("cannot create support bundle for nonexistent VM, run `finch %s init` to create a new instance", + virtualMachineRootCmd) + default: + return nil + } +} diff --git a/cmd/finch/support_bundle_test.go b/cmd/finch/support_bundle_test.go new file mode 100644 index 000000000..5c6bdc398 --- /dev/null +++ b/cmd/finch/support_bundle_test.go @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewSupportBundleCommand(t *testing.T) { + t.Parallel() + + cmd := newSupportBundleCommand(nil, nil, nil) + assert.Equal(t, cmd.Name(), "support-bundle") +} + +func TestNewSupportBundleGenerateCommand(t *testing.T) { + t.Parallel() + + cmd := newSupportBundleGenerateCommand(nil, nil, nil) + assert.Equal(t, cmd.Name(), "generate") +} diff --git a/pkg/support/config.go b/pkg/support/config.go new file mode 100644 index 000000000..30fefeb13 --- /dev/null +++ b/pkg/support/config.go @@ -0,0 +1,100 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package support + +import ( + "github.com/runfinch/finch/pkg/command" + fpath "github.com/runfinch/finch/pkg/path" + "github.com/runfinch/finch/pkg/version" + "path" + "strings" +) + +type platformData struct { + Os string `yaml:"os"` + Arch string `yaml:"arch"` + Finch string `yaml:"finch"` +} + +// BundleConfig provides methods that configure what is included in a support bundle +type BundleConfig struct { + finch fpath.Finch + homeDir string + ecc command.Creator +} + +// NewBundleConfig creates a new BundleConfig +func NewBundleConfig(finch fpath.Finch, homeDir string, ecc command.Creator) BundleConfig { + return BundleConfig{ + finch: finch, + homeDir: homeDir, + ecc: ecc, + } +} + +func (c *BundleConfig) logFiles() []string { + return []string{ + path.Join(c.finch.LimaInstancePath(), "ha.stderr.log"), + path.Join(c.finch.LimaInstancePath(), "ha.stdout.log"), + path.Join(c.finch.LimaInstancePath(), "serial.log"), + } +} + +func (c *BundleConfig) configFiles() []string { + return []string{ + path.Join(c.finch.LimaInstancePath(), "lima.yaml"), + c.finch.ConfigFilePath(c.homeDir), + } +} + +func (c *BundleConfig) platformData() (*platformData, error) { + platform := &platformData{} + + // populate OS version + os, err := c.getOSVersion() + if err != nil { + return nil, err + } + platform.Os = os + + // populate arch + arch, err := c.getArch() + if err != nil { + return nil, err + } + platform.Arch = arch + + // populate Finch version + platform.Finch = getFinchVersion() + + return platform, nil +} + +func (c *BundleConfig) getOSVersion() (string, error) { + cmd := c.ecc.Create("sw_vers", "-productVersion") + out, err := cmd.Output() + if err != nil { + return "", err + } + + os := strings.TrimSuffix(string(out), "\n") + + return os, nil +} + +func (c *BundleConfig) getArch() (string, error) { + cmd := c.ecc.Create("uname", "-m") + out, err := cmd.Output() + if err != nil { + return "", err + } + + arch := strings.TrimSuffix(string(out), "\n") + + return arch, nil +} + +func getFinchVersion() string { + return version.Version +} diff --git a/pkg/support/support.go b/pkg/support/support.go new file mode 100644 index 000000000..b4a1b37b3 --- /dev/null +++ b/pkg/support/support.go @@ -0,0 +1,135 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package support provides functions and methods to produce Finch support bundles +package support + +import ( + "archive/zip" + "fmt" + "io" + "path" + "time" + + "github.com/runfinch/finch/pkg/flog" + "github.com/spf13/afero" + "gopkg.in/yaml.v3" +) + +const ( + bundlePrefix = "finch-support" + platformFileName = "platform.yaml" + logPrefix = "logs" + configPrefix = "configs" +) + +// BundleBuilder provides methods to generate support bundles. +type BundleBuilder struct { + logger flog.Logger + fs afero.Fs + config BundleConfig +} + +// NewBundleBuilder produces a new BundleBuilder. +func NewBundleBuilder(logger flog.Logger, fs afero.Fs, config BundleConfig) *BundleBuilder { + return &BundleBuilder{ + logger: logger, + fs: fs, + config: config, + } +} + +// GenerateSupportBundle generates a new support bundle. +func (b *BundleBuilder) GenerateSupportBundle() (string, error) { + zipFileName := bundleFileName() + b.logger.Debugf("Creating %s...", zipFileName) + zipFile, err := b.fs.Create(zipFileName) + if err != nil { + return "", err + } + + writer := zip.NewWriter(zipFile) + + _, err = writer.Create(fmt.Sprintf("%s/", bundlePrefix)) + if err != nil { + return "", err + } + + platform, err := b.config.platformData() + if err != nil { + return "", err + } + + b.logger.Debugln("Gathering platform data...") + err = b.writePlatformData(writer, platform) + if err != nil { + return "", err + } + + b.logger.Debugln("Copying in log files...") + for _, file := range b.config.logFiles() { + err := b.copyInFile(writer, file, logPrefix) + if err != nil { + return "", err + } + } + + b.logger.Debugln("Copying in config files...") + for _, file := range b.config.configFiles() { + err := b.copyInFile(writer, file, configPrefix) + if err != nil { + return "", err + } + } + + err = writer.Close() + if err != nil { + return "", err + } + + return zipFileName, nil +} + +func (b *BundleBuilder) writePlatformData(writer *zip.Writer, platform *platformData) error { + platformFile, err := writer.Create(path.Join(bundlePrefix, platformFileName)) + if err != nil { + return err + } + + toWrite, err := yaml.Marshal(&platform) + if err != nil { + return err + } + + _, err = platformFile.Write(toWrite) + if err != nil { + return err + } + + return nil +} + +func (b *BundleBuilder) copyInFile(writer *zip.Writer, fileName string, prefix string) error { + f, err := b.fs.Open(fileName) + if err != nil { + return err + } + + baseName := path.Base(fileName) + zipCopy, err := writer.Create(path.Join(bundlePrefix, prefix, baseName)) + if err != nil { + return err + } + + _, err = io.Copy(zipCopy, f) + if err != nil { + return err + } + + return nil +} + +func bundleFileName() string { + timestamp := time.Now().Format("20060102150405") + return fmt.Sprintf("%s-%s.zip", bundlePrefix, timestamp) +} diff --git a/pkg/support/support_test.go b/pkg/support/support_test.go new file mode 100644 index 000000000..2fddbd166 --- /dev/null +++ b/pkg/support/support_test.go @@ -0,0 +1,118 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package support + +import ( + "archive/zip" + "github.com/spf13/afero/zipfs" + "path" + "testing" + "time" + + "github.com/runfinch/finch/pkg/mocks" + fpath "github.com/runfinch/finch/pkg/path" + + "github.com/golang/mock/gomock" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + mockSvc func(logger *mocks.Logger, creator *mocks.CommandCreator, cmd *mocks.Command) + }{ + { + name: "Generate support bundle", + mockSvc: func(logger *mocks.Logger, creator *mocks.CommandCreator, cmd *mocks.Command) { + logger.EXPECT().Debugf("Creating %s...", gomock.Any()) + logger.EXPECT().Debugln("Gathering platform data...") + creator.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + cmd.EXPECT().Output().Return([]byte("1.2.3\n"), nil) + creator.EXPECT().Create("uname", "-m").Return(cmd) + cmd.EXPECT().Output().Return([]byte("1.2.3\n"), nil).Return([]byte("arch\n"), nil) + + logger.EXPECT().Debugln("Copying in log files...") + logger.EXPECT().Debugln("Copying in config files...") + + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + fs := afero.NewMemMapFs() + logger := mocks.NewLogger(ctrl) + finch := fpath.Finch("mockfinch") + ecc := mocks.NewCommandCreator(ctrl) + cmd := mocks.NewCommand(ctrl) + config := NewBundleConfig(finch, "mock_home", ecc) + + tc.mockSvc(logger, ecc, cmd) + + builder := NewBundleBuilder(logger, fs, config) + + for _, f := range builder.config.logFiles() { + err := afero.WriteFile(fs, f, []byte("logs"), 0o644) + require.NoError(t, err) + } + + for _, f := range builder.config.configFiles() { + err := afero.WriteFile(fs, f, []byte("configs"), 0o644) + require.NoError(t, err) + } + + zipFile, err := builder.GenerateSupportBundle() + + assert.NoError(t, err) + + exists, err := afero.Exists(fs, zipFile) + assert.NoError(t, err) + assert.True(t, exists) + + zf, err := fs.Open(zipFile) + assert.NoError(t, err) + + zstat, err := zf.Stat() + assert.NoError(t, err) + + reader, err := zip.NewReader(zf, zstat.Size()) + assert.NoError(t, err) + + zfs := zipfs.New(reader) + + for _, f := range builder.config.logFiles() { + _, err := zfs.Stat(path.Join(bundlePrefix, logPrefix, path.Base(f))) + assert.NoError(t, err) + } + + for _, f := range builder.config.configFiles() { + _, err := zfs.Stat(path.Join(bundlePrefix, configPrefix, path.Base(f))) + assert.NoError(t, err) + } + + err = zf.Close() + assert.NoError(t, err) + }) + } +} + +func TestBundleFileName(t *testing.T) { + t.Parallel() + + first := bundleFileName() + time.Sleep(time.Second) + second := bundleFileName() + + assert.Contains(t, first, bundlePrefix) + assert.Contains(t, second, bundlePrefix) + assert.NotEqual(t, first, second) +}