diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 5b64f42c6..17825bc1d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -26,3 +26,6 @@ If applicable, add screenshots or logs to help explain your problem. **Additional context** Add any other context about the problem here. + + +_To help debug the issue as quickly as possible, we recommend generating a support bundle with `finch support-bundle-generate` and attaching it to this issue. This packages all Finch-related configs and logs into one file._ \ No newline at end of file diff --git a/cmd/finch/main.go b/cmd/finch/main.go index a93cf811b..83f3ff9e1 100644 --- a/cmd/finch/main.go +++ b/cmd/finch/main.go @@ -9,16 +9,16 @@ import ( "io" "os" - "github.com/runfinch/finch/pkg/disk" - "github.com/runfinch/finch/pkg/command" "github.com/runfinch/finch/pkg/config" "github.com/runfinch/finch/pkg/dependency" "github.com/runfinch/finch/pkg/dependency/vmnet" + "github.com/runfinch/finch/pkg/disk" "github.com/runfinch/finch/pkg/flog" "github.com/runfinch/finch/pkg/fmemory" "github.com/runfinch/finch/pkg/fssh" "github.com/runfinch/finch/pkg/path" + "github.com/runfinch/finch/pkg/support" "github.com/runfinch/finch/pkg/system" "github.com/runfinch/finch/pkg/version" @@ -88,6 +88,13 @@ 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")), + fp, + ecc, + ) // append nerdctl commands allCommands := initializeNerdctlCommands(lcc, logger, fs) @@ -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..da8d4abfc --- /dev/null +++ b/cmd/finch/support_bundle.go @@ -0,0 +1,103 @@ +// 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: "Support bundle management", + } + 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", + Long: "Generates a collection of logs and configs that can be uploaded to a Github issue to help debug issues.", + RunE: newGenerateSupportBundleAction(logger, builder, lcc).runAdapter, + } + + supportBundleGenerateCommand.Flags().StringArray("include", []string{}, + "additional files to include in the support bundle, specified by absolute or relative path") + supportBundleGenerateCommand.Flags().StringArray("exclude", []string{}, + "files to exclude from the support bundle. if you specify a base name, all files matching that base name will be excluded. if you specify an absolute or relative path, only exact matches will be excluded") + 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 (gsa *generateSupportBundleAction) runAdapter(cmd *cobra.Command, args []string) error { + additionalFiles, err := cmd.Flags().GetStringArray("include") + if err != nil { + return err + } + excludeFiles, err := cmd.Flags().GetStringArray("exclude") + if err != nil { + return err + } + return gsa.run(additionalFiles, excludeFiles) +} + +func (gsa *generateSupportBundleAction) run(additionalFiles []string, excludeFiles []string) error { + err := gsa.assertVMExists() + if err != nil { + return err + } + gsa.logger.Info("Generating support bundle...") + bundleFile, err := gsa.builder.GenerateSupportBundle(additionalFiles, excludeFiles) + if err != nil { + return err + } + gsa.logger.Infof("Bundle created: %s", bundleFile) + gsa.logger.Info("Files posted on a Github issue can be read by anyone.") + gsa.logger.Info("Please ensure there is no sensitive information in the bundle before uploading.") + gsa.logger.Info("By default, this bundle contains basic logs and configs for Finch.") + return nil +} + +func (gsa *generateSupportBundleAction) assertVMExists() error { + status, err := lima.GetVMStatus(gsa.lcc, gsa.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..babccbab8 --- /dev/null +++ b/cmd/finch/support_bundle_test.go @@ -0,0 +1,324 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + + "github.com/runfinch/finch/pkg/mocks" +) + +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") +} + +func TestGenerateSupportBundleAction_runAdapter(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + args []string + mockSvc func(*gomock.Controller, *mocks.Logger, *mocks.BundleBuilder, *mocks.LimaCmdCreator) + }{ + { + name: "no flags", + args: []string{}, + mockSvc: func( + ctrl *gomock.Controller, + logger *mocks.Logger, + builder *mocks.BundleBuilder, + lcc *mocks.LimaCmdCreator, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + logger.EXPECT().Info(gomock.Any()) + builder.EXPECT().GenerateSupportBundle([]string{}, []string{}).Return("bundleName", nil) + logger.EXPECT().Infof(gomock.Any(), gomock.Any()) + logger.EXPECT().Info(gomock.Any()) + logger.EXPECT().Info(gomock.Any()) + }, + }, + { + name: "one include flag", + args: []string{ + "--include", + "testfile", + }, + mockSvc: func( + ctrl *gomock.Controller, + logger *mocks.Logger, + builder *mocks.BundleBuilder, + lcc *mocks.LimaCmdCreator, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + logger.EXPECT().Info(gomock.Any()) + builder.EXPECT().GenerateSupportBundle([]string{"testfile"}, []string{}).Return("bundleName", nil) + logger.EXPECT().Infof(gomock.Any(), gomock.Any()) + logger.EXPECT().Info(gomock.Any()) + logger.EXPECT().Info(gomock.Any()) + }, + }, + { + name: "multiple include flags", + args: []string{ + "--include", + "testfile", + "--include", + "secondfile", + }, + mockSvc: func( + ctrl *gomock.Controller, + logger *mocks.Logger, + builder *mocks.BundleBuilder, + lcc *mocks.LimaCmdCreator, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + logger.EXPECT().Info(gomock.Any()) + builder.EXPECT().GenerateSupportBundle([]string{"testfile", "secondfile"}, []string{}).Return("bundleName", nil) + logger.EXPECT().Infof(gomock.Any(), gomock.Any()) + logger.EXPECT().Info(gomock.Any()) + logger.EXPECT().Info(gomock.Any()) + }, + }, + { + name: "one exclude flag", + args: []string{ + "--exclude", + "testfile", + }, + mockSvc: func( + ctrl *gomock.Controller, + logger *mocks.Logger, + builder *mocks.BundleBuilder, + lcc *mocks.LimaCmdCreator, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + logger.EXPECT().Info(gomock.Any()) + builder.EXPECT().GenerateSupportBundle([]string{}, []string{"testfile"}).Return("bundleName", nil) + logger.EXPECT().Infof(gomock.Any(), gomock.Any()) + logger.EXPECT().Info(gomock.Any()) + logger.EXPECT().Info(gomock.Any()) + }, + }, + { + name: "multiple exclude flags", + args: []string{ + "--exclude", + "testfile", + "--exclude", + "secondfile", + }, + mockSvc: func( + ctrl *gomock.Controller, + logger *mocks.Logger, + builder *mocks.BundleBuilder, + lcc *mocks.LimaCmdCreator, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + logger.EXPECT().Info(gomock.Any()) + builder.EXPECT().GenerateSupportBundle([]string{}, []string{"testfile", "secondfile"}).Return("bundleName", nil) + logger.EXPECT().Infof(gomock.Any(), gomock.Any()) + logger.EXPECT().Info(gomock.Any()) + logger.EXPECT().Info(gomock.Any()) + }, + }, + { + name: "combo of include and exclude flags", + args: []string{ + "--include", + "testfile", + "--exclude", + "secondfile", + }, + mockSvc: func( + ctrl *gomock.Controller, + logger *mocks.Logger, + builder *mocks.BundleBuilder, + lcc *mocks.LimaCmdCreator, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf(gomock.Any(), gomock.Any()) + + logger.EXPECT().Info(gomock.Any()) + builder.EXPECT().GenerateSupportBundle([]string{"testfile"}, []string{"secondfile"}).Return("bundleName", nil) + logger.EXPECT().Infof(gomock.Any(), gomock.Any()) + logger.EXPECT().Info(gomock.Any()) + logger.EXPECT().Info(gomock.Any()) + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + logger := mocks.NewLogger(ctrl) + builder := mocks.NewBundleBuilder(ctrl) + lcc := mocks.NewLimaCmdCreator(ctrl) + tc.mockSvc(ctrl, logger, builder, lcc) + + cmd := newSupportBundleGenerateCommand(logger, builder, lcc) + cmd.SetArgs(tc.args) + assert.NoError(t, cmd.Execute()) + }) + } +} + +func TestGenerateSupportBundleAction_run(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + wantErr error + mockSvc func(*gomock.Controller, *mocks.Logger, *mocks.BundleBuilder, *mocks.LimaCmdCreator, []string, []string) + include []string + exclude []string + }{ + { + name: "VM is running, no error", + wantErr: nil, + mockSvc: func( + ctrl *gomock.Controller, + logger *mocks.Logger, + builder *mocks.BundleBuilder, + lcc *mocks.LimaCmdCreator, + include []string, + exclude []string, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + + logger.EXPECT().Info("Generating support bundle...") + builder.EXPECT().GenerateSupportBundle(include, exclude).Return("bundleName", nil) + logger.EXPECT().Infof("Bundle created: %s", "bundleName") + logger.EXPECT().Info("Files posted on a Github issue can be read by anyone.") + logger.EXPECT().Info("Please ensure there is no sensitive information in the bundle before uploading.") + }, + include: []string{}, + exclude: []string{}, + }, + { + name: "VM is stopped, no error", + wantErr: nil, + mockSvc: func( + ctrl *gomock.Controller, + logger *mocks.Logger, + builder *mocks.BundleBuilder, + lcc *mocks.LimaCmdCreator, + include []string, + exclude []string, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Stopped"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Stopped") + + logger.EXPECT().Info("Generating support bundle...") + builder.EXPECT().GenerateSupportBundle(include, exclude).Return("bundleName", nil) + logger.EXPECT().Infof("Bundle created: %s", "bundleName") + logger.EXPECT().Info("Files posted on a Github issue can be read by anyone.") + logger.EXPECT().Info("Please ensure there is no sensitive information in the bundle before uploading.") + }, + include: []string{}, + exclude: []string{}, + }, + { + name: "VM is nonexistent", + wantErr: fmt.Errorf("cannot create support bundle for nonexistent VM, run `finch %s init` to create a new instance", + virtualMachineRootCmd), + mockSvc: func( + ctrl *gomock.Controller, + logger *mocks.Logger, + builder *mocks.BundleBuilder, + lcc *mocks.LimaCmdCreator, + include []string, + exclude []string, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte(""), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "") + }, + include: []string{}, + exclude: []string{}, + }, + { + name: "VM is running, error generating bundle", + wantErr: fmt.Errorf("foo"), + mockSvc: func( + ctrl *gomock.Controller, + logger *mocks.Logger, + builder *mocks.BundleBuilder, + lcc *mocks.LimaCmdCreator, + include []string, + exclude []string, + ) { + getVMStatusC := mocks.NewCommand(ctrl) + lcc.EXPECT().CreateWithoutStdio("ls", "-f", "{{.Status}}", limaInstanceName).Return(getVMStatusC) + getVMStatusC.EXPECT().Output().Return([]byte("Running"), nil) + logger.EXPECT().Debugf("Status of virtual machine: %s", "Running") + + logger.EXPECT().Info("Generating support bundle...") + builder.EXPECT().GenerateSupportBundle(include, exclude).Return("", fmt.Errorf("foo")) + }, + include: []string{}, + exclude: []string{}, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + logger := mocks.NewLogger(ctrl) + builder := mocks.NewBundleBuilder(ctrl) + lcc := mocks.NewLimaCmdCreator(ctrl) + + tc.mockSvc(ctrl, logger, builder, lcc, tc.include, tc.exclude) + err := newGenerateSupportBundleAction(logger, builder, lcc).run(tc.include, tc.exclude) + assert.Equal(t, tc.wantErr, err) + }) + } +} diff --git a/e2e/vm/support_bundle_test.go b/e2e/vm/support_bundle_test.go new file mode 100644 index 000000000..109c5c743 --- /dev/null +++ b/e2e/vm/support_bundle_test.go @@ -0,0 +1,159 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package vm + +import ( + "archive/zip" + "fmt" + "os" + "path" + "strings" + "time" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/runfinch/common-tests/command" + "github.com/runfinch/common-tests/option" +) + +var testSupportBundle = func(o *option.Option) { + ginkgo.Describe("Support bundles", func() { + ginkgo.It("Should generate a support bundle", func() { + command.Run(o, "support-bundle", "generate") + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } + + err = os.Remove(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeTrue()) + }) + ginkgo.It("Should generate a support bundle with an extra file included", func() { + includeFilename := fmt.Sprintf("tempTestfile%s", time.Now().Format("20060102150405")) + _, err := os.Create(includeFilename) + gomega.Expect(err).Should(gomega.BeNil()) + defer func() { + err := os.Remove(includeFilename) + gomega.Expect(err).Should(gomega.BeNil()) + }() + + command.Run(o, "support-bundle", "generate", "--include", includeFilename) + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } + + reader, err := zip.OpenReader(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + + zipBaseName := path.Base(dirEntry.Name()) + zipPrefix := strings.TrimSuffix(zipBaseName, path.Ext(zipBaseName)) + _, err = reader.Open(path.Join(zipPrefix, "misc", includeFilename)) + gomega.Expect(err).Should(gomega.BeNil()) + + err = os.Remove(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeTrue()) + }) + ginkgo.It("Should generate a support bundle with a default file excluded", func() { + command.Run(o, "support-bundle", "generate", "--exclude", "serial.log") + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } + + reader, err := zip.OpenReader(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + + zipBaseName := path.Base(dirEntry.Name()) + zipPrefix := strings.TrimSuffix(zipBaseName, path.Ext(zipBaseName)) + _, err = reader.Open(path.Join(zipPrefix, "logs", "serial.log")) + gomega.Expect(err).ShouldNot(gomega.BeNil()) + + err = os.Remove(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeTrue()) + }) + ginkgo.It("Should generate a support bundle with the same file included and excluded", func() { + includeFilename := fmt.Sprintf("tempTestfile%s", time.Now().Format("20060102150405")) + _, err := os.Create(includeFilename) + gomega.Expect(err).Should(gomega.BeNil()) + defer func() { + err := os.Remove(includeFilename) + gomega.Expect(err).Should(gomega.BeNil()) + }() + + command.Run(o, "support-bundle", "generate", "--include", includeFilename, "--exclude", includeFilename) + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } + + reader, err := zip.OpenReader(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + + zipBaseName := path.Base(dirEntry.Name()) + zipPrefix := strings.TrimSuffix(zipBaseName, path.Ext(zipBaseName)) + _, err = reader.Open(path.Join(zipPrefix, "misc", includeFilename)) + gomega.Expect(err).ShouldNot(gomega.BeNil()) + + err = os.Remove(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeTrue()) + }) + ginkgo.It("Should fail to generate a support bundle when the VM is nonexistent", func() { + command.New(o, "vm", "stop").WithTimeoutInSeconds(90).Run() + command.New(o, "vm", "remove").WithTimeoutInSeconds(60).Run() + + command.New(o, "support-bundle", "generate").WithoutSuccessfulExit().Run() + + entries, err := os.ReadDir(".") + gomega.Expect(err).Should(gomega.BeNil()) + bundleExists := false + for _, dirEntry := range entries { + if strings.Contains(dirEntry.Name(), "finch-support") { + _, err := os.Stat(dirEntry.Name()) + if err == nil { + bundleExists = true + } + + err = os.Remove(dirEntry.Name()) + gomega.Expect(err).Should(gomega.BeNil()) + } + } + gomega.Expect(bundleExists).Should(gomega.BeFalse()) + + command.New(o, "vm", "init").WithTimeoutInSeconds(600).Run() + }) + }) +} diff --git a/e2e/vm/vm_test.go b/e2e/vm/vm_test.go index b938df6e3..e0694f9cf 100644 --- a/e2e/vm/vm_test.go +++ b/e2e/vm/vm_test.go @@ -42,6 +42,7 @@ func TestVM(t *testing.T) { testAdditionalDisk(o) testConfig(o, *e2e.Installed) testVersion(o) + testSupportBundle(o) }) gomega.RegisterFailHandler(ginkgo.Fail) diff --git a/pkg/flog/log.go b/pkg/flog/log.go index fdb09d2cd..30d4fc98e 100644 --- a/pkg/flog/log.go +++ b/pkg/flog/log.go @@ -14,6 +14,7 @@ type Logger interface { Infof(format string, args ...interface{}) Infoln(args ...interface{}) Warnln(args ...interface{}) + Warnf(format string, args ...interface{}) Error(args ...interface{}) Errorf(format string, args ...interface{}) Fatal(args ...interface{}) diff --git a/pkg/flog/logrus.go b/pkg/flog/logrus.go index 4cb8d4a14..977211757 100644 --- a/pkg/flog/logrus.go +++ b/pkg/flog/logrus.go @@ -45,6 +45,11 @@ func (l *Logrus) Warnln(args ...interface{}) { logrus.Warnln(args...) } +// Warnf logs a message at level Warn. +func (l *Logrus) Warnf(format string, args ...interface{}) { + logrus.Warnf(format, args...) +} + // Error logs a message at level Error. func (l *Logrus) Error(args ...interface{}) { logrus.Error(args...) diff --git a/pkg/mocks/logger.go b/pkg/mocks/logger.go index 459ad9732..36f172250 100644 --- a/pkg/mocks/logger.go +++ b/pkg/mocks/logger.go @@ -180,6 +180,23 @@ func (mr *LoggerMockRecorder) SetLevel(arg0 interface{}) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetLevel", reflect.TypeOf((*Logger)(nil).SetLevel), arg0) } +// Warnf mocks base method. +func (m *Logger) Warnf(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Warnf", varargs...) +} + +// Warnf indicates an expected call of Warnf. +func (mr *LoggerMockRecorder) Warnf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warnf", reflect.TypeOf((*Logger)(nil).Warnf), varargs...) +} + // Warnln mocks base method. func (m *Logger) Warnln(arg0 ...interface{}) { m.ctrl.T.Helper() diff --git a/pkg/mocks/pkg_support_config.go b/pkg/mocks/pkg_support_config.go new file mode 100644 index 000000000..f828d6135 --- /dev/null +++ b/pkg/mocks/pkg_support_config.go @@ -0,0 +1,62 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: pkg/support/config.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// BundleConfig is a mock of BundleConfig interface. +type BundleConfig struct { + ctrl *gomock.Controller + recorder *BundleConfigMockRecorder +} + +// BundleConfigMockRecorder is the mock recorder for BundleConfig. +type BundleConfigMockRecorder struct { + mock *BundleConfig +} + +// NewBundleConfig creates a new mock instance. +func NewBundleConfig(ctrl *gomock.Controller) *BundleConfig { + mock := &BundleConfig{ctrl: ctrl} + mock.recorder = &BundleConfigMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *BundleConfig) EXPECT() *BundleConfigMockRecorder { + return m.recorder +} + +// ConfigFiles mocks base method. +func (m *BundleConfig) ConfigFiles() []string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ConfigFiles") + ret0, _ := ret[0].([]string) + return ret0 +} + +// ConfigFiles indicates an expected call of ConfigFiles. +func (mr *BundleConfigMockRecorder) ConfigFiles() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConfigFiles", reflect.TypeOf((*BundleConfig)(nil).ConfigFiles)) +} + +// LogFiles mocks base method. +func (m *BundleConfig) LogFiles() []string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LogFiles") + ret0, _ := ret[0].([]string) + return ret0 +} + +// LogFiles indicates an expected call of LogFiles. +func (mr *BundleConfigMockRecorder) LogFiles() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LogFiles", reflect.TypeOf((*BundleConfig)(nil).LogFiles)) +} diff --git a/pkg/mocks/pkg_support_support.go b/pkg/mocks/pkg_support_support.go new file mode 100644 index 000000000..cb33f540f --- /dev/null +++ b/pkg/mocks/pkg_support_support.go @@ -0,0 +1,49 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: pkg/support/support.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// BundleBuilder is a mock of BundleBuilder interface. +type BundleBuilder struct { + ctrl *gomock.Controller + recorder *BundleBuilderMockRecorder +} + +// BundleBuilderMockRecorder is the mock recorder for BundleBuilder. +type BundleBuilderMockRecorder struct { + mock *BundleBuilder +} + +// NewBundleBuilder creates a new mock instance. +func NewBundleBuilder(ctrl *gomock.Controller) *BundleBuilder { + mock := &BundleBuilder{ctrl: ctrl} + mock.recorder = &BundleBuilderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *BundleBuilder) EXPECT() *BundleBuilderMockRecorder { + return m.recorder +} + +// GenerateSupportBundle mocks base method. +func (m *BundleBuilder) GenerateSupportBundle(arg0, arg1 []string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GenerateSupportBundle", arg0, arg1) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GenerateSupportBundle indicates an expected call of GenerateSupportBundle. +func (mr *BundleBuilderMockRecorder) GenerateSupportBundle(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateSupportBundle", reflect.TypeOf((*BundleBuilder)(nil).GenerateSupportBundle), arg0, arg1) +} diff --git a/pkg/support/config.go b/pkg/support/config.go new file mode 100644 index 000000000..b9c2076ef --- /dev/null +++ b/pkg/support/config.go @@ -0,0 +1,44 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package support + +import ( + "path" + + fpath "github.com/runfinch/finch/pkg/path" +) + +// BundleConfig provides methods that configure what is included in a support bundle. +type BundleConfig interface { + LogFiles() []string + ConfigFiles() []string +} + +type bundleConfig struct { + finch fpath.Finch + homeDir string +} + +// NewBundleConfig creates a new bundleConfig. +func NewBundleConfig(finch fpath.Finch, homeDir string) BundleConfig { + return &bundleConfig{ + finch: finch, + homeDir: homeDir, + } +} + +func (bc *bundleConfig) LogFiles() []string { + return []string{ + path.Join(bc.finch.LimaInstancePath(), "ha.stderr.log"), + path.Join(bc.finch.LimaInstancePath(), "ha.stdout.log"), + path.Join(bc.finch.LimaInstancePath(), "serial.log"), + } +} + +func (bc *bundleConfig) ConfigFiles() []string { + return []string{ + path.Join(bc.finch.LimaInstancePath(), "lima.yaml"), + bc.finch.ConfigFilePath(bc.homeDir), + } +} diff --git a/pkg/support/config_test.go b/pkg/support/config_test.go new file mode 100644 index 000000000..d0f3f5c23 --- /dev/null +++ b/pkg/support/config_test.go @@ -0,0 +1,45 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package support + +import ( + "path" + "testing" + + "github.com/stretchr/testify/assert" + + fpath "github.com/runfinch/finch/pkg/path" +) + +func TestNewBundleConfig(t *testing.T) { + t.Parallel() + + finch := fpath.Finch("/mockfinch") + homeDir := "/mockhome" + NewBundleConfig(finch, homeDir) +} + +func TestBundleConfig_LogFiles(t *testing.T) { + t.Parallel() + + finch := fpath.Finch("/mockfinch") + homeDir := "/mockhome" + config := NewBundleConfig(finch, homeDir) + + for _, fileName := range config.LogFiles() { + assert.True(t, path.IsAbs(fileName)) + } +} + +func TestBundleConfig_ConfigFiles(t *testing.T) { + t.Parallel() + + finch := fpath.Finch("/mockfinch") + homeDir := "/mockhome" + config := NewBundleConfig(finch, homeDir) + + for _, fileName := range config.ConfigFiles() { + assert.True(t, path.IsAbs(fileName)) + } +} diff --git a/pkg/support/redact.go b/pkg/support/redact.go new file mode 100644 index 000000000..1e20bee13 --- /dev/null +++ b/pkg/support/redact.go @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package support + +import ( + "regexp" + + "github.com/runfinch/finch/pkg/path" +) + +func redactFinchInstall(content []byte, finch path.Finch) (redacted []byte, err error) { + finchInstallMatcher, err := regexp.Compile(string(finch)) + if err != nil { + return nil, err + } + redacted = finchInstallMatcher.ReplaceAll(content, []byte("")) + return redacted, nil +} + +func redactUsername(content []byte, username string) (redacted []byte, err error) { + usernameMatcher, err := regexp.Compile(username) + if err != nil { + return nil, err + } + redacted = usernameMatcher.ReplaceAll(content, []byte("")) + return redacted, nil +} + +func redactNetworkAddresses(content []byte) []byte { + ipv4Matcher := regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}(:[0-9]{1,5})?`) + redacted := ipv4Matcher.ReplaceAll(content, []byte("")) + + ipv6Matcher := regexp.MustCompile(`(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}`) + redacted = ipv6Matcher.ReplaceAll(redacted, []byte("")) + + macMatcher := regexp.MustCompile(`([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})`) + redacted = macMatcher.ReplaceAll(redacted, []byte("")) + + return redacted +} + +func redactSSHKeys(content []byte) []byte { + ecdsaMatcher := regexp.MustCompile(`ecdsa-sha2-nistp256 .* root@lima-finch`) + redacted := ecdsaMatcher.ReplaceAll(content, []byte("")) + + ed25519Matcher := regexp.MustCompile(`ssh-ed25519 .* root@lima-finch`) + redacted = ed25519Matcher.ReplaceAll(redacted, []byte("")) + + rsaMatcher := regexp.MustCompile(`ssh-rsa .* root@lima-finch`) + redacted = rsaMatcher.ReplaceAll(redacted, []byte("")) + + return redacted +} + +func redactPorts(content []byte) []byte { + // we can't redact every 1-5 digit number, so we have to redact based on specific context. + nonEmptyMatcher := regexp.MustCompile(`('\[' -n )[0-9]{1,5}( ']')`) + redacted := nonEmptyMatcher.ReplaceAll(content, []byte("$1$2")) + + nonZeroMatcher := regexp.MustCompile(`('\[' )[0-9]{1,5}( -ne 0 ']')`) + redacted = nonZeroMatcher.ReplaceAll(redacted, []byte("$1$2")) + + sshCommandMatcher := regexp.MustCompile(`(\[ssh -F .* -p )[0-9]{1,5}(.*])`) + redacted = sshCommandMatcher.ReplaceAll(redacted, []byte("$1$2")) + + statusMatcher := regexp.MustCompile(`([{,]"sshLocalPort":)[0-9]{1,5}(})`) + redacted = statusMatcher.ReplaceAll(redacted, []byte("$1$2")) + + portMatcher := regexp.MustCompile(`(port )[0-9]{1,5}`) + redacted = portMatcher.ReplaceAll(redacted, []byte("$1")) + + return redacted +} diff --git a/pkg/support/redact_test.go b/pkg/support/redact_test.go new file mode 100644 index 000000000..ecb91b3fa --- /dev/null +++ b/pkg/support/redact_test.go @@ -0,0 +1,217 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package support + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/runfinch/finch/pkg/path" +) + +func TestSupport_redactFinchInstall(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + input []byte + expect []byte + }{ + { + name: "redact Finch install location", + input: []byte("/Applications/Finch/"), + expect: []byte(""), + }, + { + name: "install location redaction not needed", + input: []byte("/Applications/Flinch/"), + expect: []byte("/Applications/Flinch/"), + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + out, err := redactFinchInstall(tc.input, path.Finch("/Applications/Finch/")) + assert.NoError(t, err) + assert.Equal(t, tc.expect, out) + }) + } +} + +func TestSupport_redactUsername(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + input []byte + expect []byte + }{ + { + name: "redact username", + input: []byte("this is foo_user's username: foo_user"), + expect: []byte("this is 's username: "), + }, + { + name: "username redaction not needed", + input: []byte("this will not include a username"), + expect: []byte("this will not include a username"), + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + out, err := redactUsername(tc.input, "foo_user") + assert.NoError(t, err) + assert.Equal(t, tc.expect, out) + }) + } +} + +func TestSupport_redactNetworkAddresses(t *testing.T) { + t.Parallel() + + timestamp := time.Now().String() + + testCases := []struct { + name string + input []byte + expect []byte + }{ + { + name: "redact ipv4 address", + input: []byte("127.0.0.1"), + expect: []byte(""), + }, + { + name: "redact ipv4 address with port", + input: []byte("192.168.5.4:12497"), + expect: []byte(""), + }, + { + name: "redact ipv6 address", + input: []byte("DEAD:BEEF:0123:4567:89AB:CDEF:DEAD:BEEF"), + expect: []byte(""), + }, + { + name: "redact mac address", + input: []byte("de:ad:be:ef:01:23"), + expect: []byte(""), + }, + { + name: "do not redact timestamps", + input: []byte(timestamp), + expect: []byte(timestamp), + }, + { + name: "do not redact seconds", + input: []byte("1.234567"), + expect: []byte("1.234567"), + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + out := redactNetworkAddresses(tc.input) + assert.Equal(t, tc.expect, out) + }) + } +} + +func TestSupport_redactSSHKeys(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + input []byte + expect []byte + }{ + { + name: "should replace ecdsa key", + input: []byte("ecdsa-sha2-nistp256 AAA/BBB/CCC...xyz= root@lima-finch"), + expect: []byte(""), + }, + { + name: "should replace ed25519 key", + input: []byte("ssh-ed25519 AAABBBCCC...xyz root@lima-finch"), + expect: []byte(""), + }, + { + name: "should replace rsa key", + input: []byte("ssh-rsa AAABBBCCC12/abc+...xyz= root@lima-finch"), + expect: []byte(""), + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + out := redactSSHKeys(tc.input) + assert.Equal(t, tc.expect, out) + }) + } +} + +func TestSupport_redactPorts(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + input []byte + expect []byte + }{ + { + name: "should replace in non-empty test statement", + input: []byte("'[' -n 12345 ']'"), + expect: []byte("'[' -n ']'"), + }, + { + name: "should replace in non-zero test statement", + input: []byte("'[' 1 -ne 0 ']'"), + expect: []byte("'[' -ne 0 ']'"), + }, + { + name: "should replace in ssh command", + input: []byte("[ssh -F /dev/null ... -p 12 ... -- /bin/bash]"), + expect: []byte("[ssh -F /dev/null ... -p ... -- /bin/bash]"), + }, + { + name: "should replace in ha.stdout.log status", + input: []byte("{\"time\":\"2023-03-16T15:51:17.188418-07:00\",\"status\":{\"running\":true,\"sshLocalPort\":1234}}"), + expect: []byte("{\"time\":\"2023-03-16T15:51:17.188418-07:00\",\"status\":{\"running\":true,\"sshLocalPort\":}}"), + }, + { + name: "should replace when preceded by port", + input: []byte("port 123"), + expect: []byte("port "), + }, + { + name: "should not replace 1-5 digit number", + input: []byte("12345"), + expect: []byte("12345"), + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + out := redactPorts(tc.input) + assert.Equal(t, tc.expect, out) + }) + } +} diff --git a/pkg/support/support.go b/pkg/support/support.go new file mode 100644 index 000000000..752b4ac74 --- /dev/null +++ b/pkg/support/support.go @@ -0,0 +1,288 @@ +// 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" + "bytes" + "errors" + "fmt" + "io" + "path" + "strings" + "time" + + "github.com/lima-vm/lima/pkg/osutil" + "github.com/spf13/afero" + "gopkg.in/yaml.v3" + + "github.com/runfinch/finch/pkg/command" + "github.com/runfinch/finch/pkg/flog" + fpath "github.com/runfinch/finch/pkg/path" + "github.com/runfinch/finch/pkg/version" +) + +const ( + bundlePrefix = "finch-support" + platformFileName = "platform.yaml" + logPrefix = "logs" + configPrefix = "configs" + additionalPrefix = "misc" +) + +// PlatformData defines the YAML structure for the platform data included in a support bundle. +type PlatformData struct { + Os string `yaml:"os"` + Arch string `yaml:"arch"` + Finch string `yaml:"finch"` +} + +// BundleBuilder provides methods to generate support bundles. +type BundleBuilder interface { + GenerateSupportBundle([]string, []string) (string, error) +} + +type bundleBuilder struct { + logger flog.Logger + fs afero.Fs + config BundleConfig + finch fpath.Finch + ecc command.Creator +} + +// NewBundleBuilder produces a new BundleBuilder. +func NewBundleBuilder( + logger flog.Logger, + fs afero.Fs, + config BundleConfig, + finch fpath.Finch, + ecc command.Creator, +) BundleBuilder { + return &bundleBuilder{ + logger: logger, + fs: fs, + config: config, + finch: finch, + ecc: ecc, + } +} + +// GenerateSupportBundle generates a new support bundle. +func (bb *bundleBuilder) GenerateSupportBundle(additionalFiles []string, excludeFiles []string) (string, error) { + zipFileName := bundleFileName() + bb.logger.Debugf("Creating %s...", zipFileName) + zipFile, err := bb.fs.Create(zipFileName) + if err != nil { + return "", err + } + + zipPrefix := strings.TrimSuffix(zipFileName, path.Ext(zipFileName)) + + writer := zip.NewWriter(zipFile) + + _, err = writer.Create(fmt.Sprintf("%s/", zipPrefix)) + if err != nil { + return "", err + } + + platform, err := bb.getPlatformData() + if err != nil { + return "", err + } + + bb.logger.Debugln("Gathering platform data...") + err = writePlatformData(writer, platform, zipPrefix) + if err != nil { + return "", err + } + + bb.logger.Debugln("Copying in log files...") + for _, file := range bb.config.LogFiles() { + if fileShouldBeExcluded(file, excludeFiles) { + bb.logger.Infof("Excluding %s...", file) + continue + } + err := bb.copyInFile(writer, file, path.Join(zipPrefix, logPrefix)) + if err != nil { + bb.logger.Warnf("Could not copy in %q. Error: %s", file, err) + } + } + + bb.logger.Debugln("Copying in config files...") + for _, file := range bb.config.ConfigFiles() { + if fileShouldBeExcluded(file, excludeFiles) { + bb.logger.Infof("Excluding %s...", file) + continue + } + err := bb.copyInFile(writer, file, path.Join(zipPrefix, configPrefix)) + if err != nil { + bb.logger.Warnf("Could not copy in %q. Error: %s", file, err) + } + } + + bb.logger.Debugln("Copying in additional files...") + for _, file := range additionalFiles { + if fileShouldBeExcluded(file, excludeFiles) { + bb.logger.Infof("Excluding %s...", file) + continue + } + err := bb.copyInFile(writer, file, path.Join(zipPrefix, additionalPrefix)) + if err != nil { + bb.logger.Warnf("Could not add additional file %s. Error: %s", file, err) + } + } + + err = writer.Close() + if err != nil { + return "", err + } + + return zipFileName, nil +} + +func (bb *bundleBuilder) copyInFile(writer *zip.Writer, fileName string, prefix string) error { + f, err := bb.fs.Open(fileName) + if err != nil { + return err + } + + bb.logger.Debugf("Copying %s...", fileName) + + var buf bytes.Buffer + _, err = buf.ReadFrom(f) + if err != nil { + return err + } + + var redacted []byte + var bufErr error + for bufErr == nil { + var line []byte + line, bufErr = buf.ReadBytes('\n') + if bufErr != nil && !errors.Is(bufErr, io.EOF) { + continue + } + + line, err = redactFinchInstall(line, bb.finch) + if err != nil { + return err + } + + user, err := osutil.LimaUser(false) + if err != nil { + return err + } + line, err = redactUsername(line, user.Username) + if err != nil { + return err + } + + line = redactNetworkAddresses(line) + line = redactPorts(line) + line = redactSSHKeys(line) + + redacted = append(redacted, line...) + } + + baseName := path.Base(fileName) + zipCopy, err := writer.Create(path.Join(prefix, baseName)) + if err != nil { + return err + } + + _, err = zipCopy.Write(redacted) + if err != nil { + return err + } + + return nil +} + +func (bb *bundleBuilder) getPlatformData() (*PlatformData, error) { + platform := &PlatformData{} + + // populate OS version + os, err := bb.getOSVersion() + if err != nil { + return nil, err + } + platform.Os = os + + // populate arch + arch, err := bb.getArch() + if err != nil { + return nil, err + } + platform.Arch = arch + + // populate Finch version + platform.Finch = getFinchVersion() + + return platform, nil +} + +func (bb *bundleBuilder) getOSVersion() (string, error) { + cmd := bb.ecc.Create("sw_vers", "-productVersion") + out, err := cmd.Output() + if err != nil { + return "", err + } + + os := strings.TrimSuffix(string(out), "\n") + + return os, nil +} + +func (bb *bundleBuilder) getArch() (string, error) { + cmd := bb.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 +} + +func writePlatformData(writer *zip.Writer, platform *PlatformData, prefix string) error { + platformFile, err := writer.Create(path.Join(prefix, platformFileName)) + if err != nil { + return err + } + + platformYaml, err := yaml.Marshal(&platform) + if err != nil { + return err + } + + _, err = platformFile.Write(platformYaml) + if err != nil { + return err + } + + return nil +} + +func bundleFileName() string { + timestamp := time.Now().Format("20060102150405") + return fmt.Sprintf("%s-%s.zip", bundlePrefix, timestamp) +} + +func fileShouldBeExcluded(filename string, exclude []string) bool { + for _, excludeFile := range exclude { + if filename == excludeFile { + return true + } + if path.Base(filename) == excludeFile { + return true + } + } + return false +} diff --git a/pkg/support/support_test.go b/pkg/support/support_test.go new file mode 100644 index 000000000..af4a73b11 --- /dev/null +++ b/pkg/support/support_test.go @@ -0,0 +1,343 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package support + +import ( + "archive/zip" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/runfinch/finch/pkg/mocks" + fpath "github.com/runfinch/finch/pkg/path" +) + +func TestSupport_NewBundleBuilder(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + ecc := mocks.NewCommandCreator(ctrl) + logger := mocks.NewLogger(ctrl) + fs := afero.NewMemMapFs() + finch := fpath.Finch("mockfinch") + + config := NewBundleConfig(finch, "mockhome") + NewBundleBuilder(logger, fs, config, finch, ecc) +} + +func TestSupportBundleBuilder_GenerateSupportBundle(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + mockSvc func(*mocks.Logger, *mocks.BundleConfig, *mocks.CommandCreator, *mocks.Command) + include []string + exclude []string + }{ + { + name: "Generate support bundle", + mockSvc: func(logger *mocks.Logger, config *mocks.BundleConfig, ecc *mocks.CommandCreator, cmd *mocks.Command) { + logger.EXPECT().Debugf("Creating %s...", gomock.Any()) + logger.EXPECT().Debugln("Gathering platform data...") + + ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + cmd.EXPECT().Output().Return([]byte("1.2.3\n"), nil) + ecc.EXPECT().Create("uname", "-m").Return(cmd) + cmd.EXPECT().Output().Return([]byte("arch\n"), nil) + + config.EXPECT().LogFiles().Return([]string{ + "log1", + "log2", + }) + + config.EXPECT().ConfigFiles().Return([]string{ + "config1", + "config2", + }) + + logger.EXPECT().Debugln("Copying in log files...") + logger.EXPECT().Debugf("Copying %s...", "log1") + logger.EXPECT().Debugf("Copying %s...", "log2") + logger.EXPECT().Debugln("Copying in config files...") + logger.EXPECT().Debugf("Copying %s...", "config1") + logger.EXPECT().Debugf("Copying %s...", "config2") + logger.EXPECT().Debugln("Copying in additional files...") + }, + include: []string{}, + exclude: []string{}, + }, + { + name: "Generate support bundle with an extra file included", + mockSvc: func(logger *mocks.Logger, config *mocks.BundleConfig, ecc *mocks.CommandCreator, cmd *mocks.Command) { + logger.EXPECT().Debugf("Creating %s...", gomock.Any()) + logger.EXPECT().Debugln("Gathering platform data...") + + ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + cmd.EXPECT().Output().Return([]byte("1.2.3\n"), nil) + ecc.EXPECT().Create("uname", "-m").Return(cmd) + cmd.EXPECT().Output().Return([]byte("arch\n"), nil) + + config.EXPECT().LogFiles().Return([]string{ + "log1", + }) + + config.EXPECT().ConfigFiles().Return([]string{ + "config1", + }) + + logger.EXPECT().Debugln("Copying in log files...") + logger.EXPECT().Debugf("Copying %s...", "log1") + logger.EXPECT().Debugln("Copying in config files...") + logger.EXPECT().Debugf("Copying %s...", "config1") + logger.EXPECT().Debugln("Copying in additional files...") + logger.EXPECT().Debugf("Copying %s...", "extra1") + }, + include: []string{"extra1"}, + exclude: []string{}, + }, + { + name: "Generate support bundle with a log file excluded", + mockSvc: func(logger *mocks.Logger, config *mocks.BundleConfig, ecc *mocks.CommandCreator, cmd *mocks.Command) { + logger.EXPECT().Debugf("Creating %s...", gomock.Any()) + logger.EXPECT().Debugln("Gathering platform data...") + + ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + cmd.EXPECT().Output().Return([]byte("1.2.3\n"), nil) + ecc.EXPECT().Create("uname", "-m").Return(cmd) + cmd.EXPECT().Output().Return([]byte("arch\n"), nil) + + config.EXPECT().LogFiles().Return([]string{ + "log1", + }) + + config.EXPECT().ConfigFiles().Return([]string{ + "config1", + }) + + logger.EXPECT().Debugln("Copying in log files...") + logger.EXPECT().Infof("Excluding %s...", "log1") + logger.EXPECT().Debugln("Copying in config files...") + logger.EXPECT().Debugf("Copying %s...", "config1") + logger.EXPECT().Debugln("Copying in additional files...") + }, + include: []string{}, + exclude: []string{"log1"}, + }, + { + name: "Generate support bundle with a config file excluded", + mockSvc: func(logger *mocks.Logger, config *mocks.BundleConfig, ecc *mocks.CommandCreator, cmd *mocks.Command) { + logger.EXPECT().Debugf("Creating %s...", gomock.Any()) + logger.EXPECT().Debugln("Gathering platform data...") + + ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + cmd.EXPECT().Output().Return([]byte("1.2.3\n"), nil) + ecc.EXPECT().Create("uname", "-m").Return(cmd) + cmd.EXPECT().Output().Return([]byte("arch\n"), nil) + + config.EXPECT().LogFiles().Return([]string{ + "log1", + }) + + config.EXPECT().ConfigFiles().Return([]string{ + "config1", + }) + + logger.EXPECT().Debugln("Copying in log files...") + logger.EXPECT().Debugf("Copying %s...", "log1") + logger.EXPECT().Debugln("Copying in config files...") + logger.EXPECT().Infof("Excluding %s...", "config1") + logger.EXPECT().Debugln("Copying in additional files...") + }, + include: []string{}, + exclude: []string{"config1"}, + }, + { + name: "Generate support bundle with an included file excluded", + mockSvc: func(logger *mocks.Logger, config *mocks.BundleConfig, ecc *mocks.CommandCreator, cmd *mocks.Command) { + logger.EXPECT().Debugf("Creating %s...", gomock.Any()) + logger.EXPECT().Debugln("Gathering platform data...") + + ecc.EXPECT().Create("sw_vers", "-productVersion").Return(cmd) + cmd.EXPECT().Output().Return([]byte("1.2.3\n"), nil) + ecc.EXPECT().Create("uname", "-m").Return(cmd) + cmd.EXPECT().Output().Return([]byte("arch\n"), nil) + + config.EXPECT().LogFiles().Return([]string{ + "log1", + }) + + config.EXPECT().ConfigFiles().Return([]string{ + "config1", + }) + + logger.EXPECT().Debugln("Copying in log files...") + logger.EXPECT().Debugf("Copying %s...", "log1") + logger.EXPECT().Debugln("Copying in config files...") + logger.EXPECT().Debugf("Copying %s...", "config1") + logger.EXPECT().Debugln("Copying in additional files...") + logger.EXPECT().Infof("Excluding %s...", "extra1") + }, + include: []string{"extra1"}, + exclude: []string{"extra1"}, + }, + } + + 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) + config := mocks.NewBundleConfig(ctrl) + finch := fpath.Finch("mockfinch") + ecc := mocks.NewCommandCreator(ctrl) + cmd := mocks.NewCommand(ctrl) + + builder := &bundleBuilder{ + logger: logger, + fs: fs, + config: config, + finch: finch, + ecc: ecc, + } + + testFiles := []string{ + "log1", "log2", // "log" files + "config1", "config2", // "config" files + "extra1", // "additional" files + } + + for _, fileName := range testFiles { + f, err := fs.Create(fileName) + require.NoError(t, err) + + _, err = f.Write([]byte("file contents\n")) + require.NoError(t, err) + + err = f.Close() + require.NoError(t, err) + } + + tc.mockSvc(logger, config, ecc, cmd) + + zipFile, err := builder.GenerateSupportBundle(tc.include, tc.exclude) + assert.NoError(t, err) + + exists, err := afero.Exists(fs, zipFile) + assert.NoError(t, err) + assert.True(t, exists) + }) + } +} + +func TestSupport_writePlatformData(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + data *PlatformData + wantErr error + }{ + { + name: "successfully write platform data", + data: &PlatformData{ + Os: "12.5.3", + Arch: "x86_64", + Finch: "0.5.0", + }, + wantErr: nil, + }, + { + name: "incomplete platform data", + data: &PlatformData{ + Os: "12.5.3", + }, + wantErr: nil, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + fs := afero.NewMemMapFs() + + file, err := fs.Create("testFile") + require.NoError(t, err) + + writer := zip.NewWriter(file) + + err = writePlatformData(writer, tc.data) + assert.Equal(t, tc.wantErr, err) + + err = file.Close() + assert.NoError(t, err) + }) + } +} + +func TestSupport_bundleFileName(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) +} + +func TestSupport_fileShouldBeExcluded(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + file string + exclude []string + result bool + }{ + { + name: "full filename should be excluded", + file: "/finch/lima/data/serial.log", + exclude: []string{"/finch/lima/data/serial.log"}, + result: true, + }, + { + name: "basename should be excluded", + file: "/finch/lima/data/serial.log", + exclude: []string{"serial.log"}, + result: true, + }, + { + name: "empty exclude list", + file: "/finch/lima/data/serial.log", + exclude: []string{}, + result: false, + }, + { + name: "file not in exclude list", + file: "/finch/lima/data/serial.log", + exclude: []string{"other.file"}, + result: false, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result := fileShouldBeExcluded(tc.file, tc.exclude) + assert.Equal(t, tc.result, result) + }) + } +}