diff --git a/cli/cmd/download_package.go b/cli/cmd/download_package.go index 7f5839f8..2a12e418 100644 --- a/cli/cmd/download_package.go +++ b/cli/cmd/download_package.go @@ -26,10 +26,11 @@ type DownloadPackageOpts struct { Version string Hash string Filename string + Quiet bool } func (c *DownloadPackageCmd) RunE(_ *cobra.Command, args []string) error { - if c.Opts.Hash != "" { + if c.Opts.Hash != "" { log.Printf("Downloading package '%s' with hash '%s'\n", c.Opts.Version, c.Opts.Hash) } else { log.Printf("Downloading package '%s'\n", c.Opts.Version) @@ -66,6 +67,7 @@ func AddDownloadPackageCmd(download *cobra.Command, opts GlobalOptions) { pkg.cmd.Flags().StringVarP(&pkg.Opts.Version, "version", "V", "", "Codesphere version to download") pkg.cmd.Flags().StringVarP(&pkg.Opts.Hash, "hash", "H", "", "Hash of the version to download if multiple builds exist for the same version") pkg.cmd.Flags().StringVarP(&pkg.Opts.Filename, "file", "f", "installer.tar.gz", "Specify artifact to download") + pkg.cmd.Flags().BoolVarP(&pkg.Opts.Quiet, "quiet", "q", false, "Suppress progress output during download") download.AddCommand(pkg.cmd) pkg.cmd.RunE = pkg.RunE @@ -83,7 +85,7 @@ func (c *DownloadPackageCmd) DownloadBuild(p portal.Portal, build portal.Build, } defer func() { _ = out.Close() }() - err = p.DownloadBuildArtifact("codesphere", download, out) + err = p.DownloadBuildArtifact("codesphere", download, out, c.Opts.Quiet) if err != nil { return fmt.Errorf("failed to download build: %w", err) } diff --git a/cli/cmd/download_package_test.go b/cli/cmd/download_package_test.go index 519a42ce..4d080b5c 100644 --- a/cli/cmd/download_package_test.go +++ b/cli/cmd/download_package_test.go @@ -36,6 +36,7 @@ var _ = Describe("ListPackages", func() { Opts: cmd.DownloadPackageOpts{ Version: version, Filename: filename, + Quiet: false, }, FileWriter: mockFileWriter, } @@ -63,7 +64,7 @@ var _ = Describe("ListPackages", func() { fakeFile := os.NewFile(uintptr(0), filename) mockFileWriter.EXPECT().Create(version+"-"+filename).Return(fakeFile, nil) - mockPortal.EXPECT().DownloadBuildArtifact(portal.CodesphereProduct, expectedBuildToDownload, mock.Anything).Return(nil) + mockPortal.EXPECT().DownloadBuildArtifact(portal.CodesphereProduct, expectedBuildToDownload, mock.Anything, false).Return(nil) err := c.DownloadBuild(mockPortal, build, filename) Expect(err).NotTo(HaveOccurred()) }) diff --git a/internal/portal/http.go b/internal/portal/http.go index e5affbcd..7ee65077 100644 --- a/internal/portal/http.go +++ b/internal/portal/http.go @@ -22,7 +22,7 @@ import ( type Portal interface { ListBuilds(product Product) (availablePackages Builds, err error) GetBuild(product Product, version string, hash string) (Build, error) - DownloadBuildArtifact(product Product, build Build, file io.Writer) error + DownloadBuildArtifact(product Product, build Build, file io.Writer, quiet bool) error RegisterAPIKey(owner string, organization string, role string, expiresAt time.Time) error RevokeAPIKey(key string) error UpdateAPIKey(key string, expiresAt time.Time) error @@ -176,7 +176,7 @@ func (c *PortalClient) GetBuild(product Product, version string, hash string) (B return matchingPackages[len(matchingPackages)-1], nil } -func (c *PortalClient) DownloadBuildArtifact(product Product, build Build, file io.Writer) error { +func (c *PortalClient) DownloadBuildArtifact(product Product, build Build, file io.Writer, quiet bool) error { reqBody, err := json.Marshal(build) if err != nil { return fmt.Errorf("failed to generate request body: %w", err) @@ -188,8 +188,12 @@ func (c *PortalClient) DownloadBuildArtifact(product Product, build Build, file } defer func() { _ = resp.Body.Close() }() - // Create a WriteCounter to wrap the output file and report progress. - counter := NewWriteCounter(file) + // Create a WriteCounter to wrap the output file and report progress, unless quiet is requested. + // Default behavior: report progress. Quiet callers should pass true for quiet. + counter := file + if !quiet { + counter = NewWriteCounter(file) + } _, err = io.Copy(counter, resp.Body) if err != nil { diff --git a/internal/portal/http_test.go b/internal/portal/http_test.go index a4981ba9..9aff801d 100644 --- a/internal/portal/http_test.go +++ b/internal/portal/http_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "io" + "log" "net/http" "net/url" "time" @@ -172,6 +173,7 @@ var _ = Describe("PortalClient", func() { build portal.Build downloadResponse string ) + BeforeEach(func() { buildDate, _ := time.Parse("2006-01-02", "2025-05-01") @@ -193,11 +195,35 @@ var _ = Describe("PortalClient", func() { It("downloads the build", func() { fakeWriter := NewFakeWriter() - err := client.DownloadBuildArtifact(product, build, fakeWriter) + err := client.DownloadBuildArtifact(product, build, fakeWriter, false) Expect(err).NotTo(HaveOccurred()) Expect(fakeWriter.String()).To(Equal(downloadResponse)) Expect(getUrl.String()).To(Equal("fake-portal.com/packages/codesphere/download")) }) + + It("emits progress logs when not quiet", func() { + var logBuf bytes.Buffer + prev := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(prev) + + fakeWriter := NewFakeWriter() + err := client.DownloadBuildArtifact(product, build, fakeWriter, false) + Expect(err).NotTo(HaveOccurred()) + Expect(logBuf.String()).To(ContainSubstring("Downloading...")) + }) + + It("does not emit progress logs when quiet", func() { + var logBuf bytes.Buffer + prev := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(prev) + + fakeWriter := NewFakeWriter() + err := client.DownloadBuildArtifact(product, build, fakeWriter, true) + Expect(err).NotTo(HaveOccurred()) + Expect(logBuf.String()).NotTo(ContainSubstring("Downloading...")) + }) }) Describe("GetLatestOmsBuild", func() { diff --git a/internal/portal/mocks.go b/internal/portal/mocks.go index 6b85a82b..b3087607 100644 --- a/internal/portal/mocks.go +++ b/internal/portal/mocks.go @@ -39,16 +39,16 @@ func (_m *MockPortal) EXPECT() *MockPortal_Expecter { } // DownloadBuildArtifact provides a mock function for the type MockPortal -func (_mock *MockPortal) DownloadBuildArtifact(product Product, build Build, file io.Writer) error { - ret := _mock.Called(product, build, file) +func (_mock *MockPortal) DownloadBuildArtifact(product Product, build Build, file io.Writer, quiet bool) error { + ret := _mock.Called(product, build, file, quiet) if len(ret) == 0 { panic("no return value specified for DownloadBuildArtifact") } var r0 error - if returnFunc, ok := ret.Get(0).(func(Product, Build, io.Writer) error); ok { - r0 = returnFunc(product, build, file) + if returnFunc, ok := ret.Get(0).(func(Product, Build, io.Writer, bool) error); ok { + r0 = returnFunc(product, build, file, quiet) } else { r0 = ret.Error(0) } @@ -64,13 +64,14 @@ type MockPortal_DownloadBuildArtifact_Call struct { // - product // - build // - file -func (_e *MockPortal_Expecter) DownloadBuildArtifact(product interface{}, build interface{}, file interface{}) *MockPortal_DownloadBuildArtifact_Call { - return &MockPortal_DownloadBuildArtifact_Call{Call: _e.mock.On("DownloadBuildArtifact", product, build, file)} +// - quiet +func (_e *MockPortal_Expecter) DownloadBuildArtifact(product interface{}, build interface{}, file interface{}, quiet interface{}) *MockPortal_DownloadBuildArtifact_Call { + return &MockPortal_DownloadBuildArtifact_Call{Call: _e.mock.On("DownloadBuildArtifact", product, build, file, quiet)} } -func (_c *MockPortal_DownloadBuildArtifact_Call) Run(run func(product Product, build Build, file io.Writer)) *MockPortal_DownloadBuildArtifact_Call { +func (_c *MockPortal_DownloadBuildArtifact_Call) Run(run func(product Product, build Build, file io.Writer, quiet bool)) *MockPortal_DownloadBuildArtifact_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(Product), args[1].(Build), args[2].(io.Writer)) + run(args[0].(Product), args[1].(Build), args[2].(io.Writer), args[3].(bool)) }) return _c } @@ -80,7 +81,7 @@ func (_c *MockPortal_DownloadBuildArtifact_Call) Return(err error) *MockPortal_D return _c } -func (_c *MockPortal_DownloadBuildArtifact_Call) RunAndReturn(run func(product Product, build Build, file io.Writer) error) *MockPortal_DownloadBuildArtifact_Call { +func (_c *MockPortal_DownloadBuildArtifact_Call) RunAndReturn(run func(product Product, build Build, file io.Writer, quiet bool) error) *MockPortal_DownloadBuildArtifact_Call { _c.Call.Return(run) return _c } diff --git a/internal/portal/write_counter.go b/internal/portal/write_counter.go index 51c56a8c..2a427182 100644 --- a/internal/portal/write_counter.go +++ b/internal/portal/write_counter.go @@ -21,8 +21,9 @@ type WriteCounter struct { // NewWriteCounter creates a new WriteCounter. func NewWriteCounter(writer io.Writer) *WriteCounter { return &WriteCounter{ - Writer: writer, - LastUpdate: time.Now(), // Initialize last update time + Writer: writer, + // Initialize to zero so the first Write triggers an immediate log + LastUpdate: time.Time{}, } } diff --git a/internal/portal/write_counter_test.go b/internal/portal/write_counter_test.go new file mode 100644 index 00000000..dad493b0 --- /dev/null +++ b/internal/portal/write_counter_test.go @@ -0,0 +1,37 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package portal_test + +import ( + "bytes" + "log" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/portal" +) + +var _ = Describe("WriteCounter", func() { + It("emits progress logs on write", func() { + // capture log output + var logBuf bytes.Buffer + prev := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(prev) + + var underlying bytes.Buffer + wc := portal.NewWriteCounter(&underlying) + + // force an update by setting LastUpdate sufficiently in the past + wc.LastUpdate = time.Now().Add(-time.Second) + + _, err := wc.Write([]byte("hello world")) + Expect(err).NotTo(HaveOccurred()) + + out := logBuf.String() + Expect(out).NotTo(BeEmpty()) + }) +})