diff --git a/.mockery.yml b/.mockery.yml index a67c4311..22cced8c 100644 --- a/.mockery.yml +++ b/.mockery.yml @@ -26,3 +26,11 @@ packages: config: all: true interfaces: + github.com/codesphere-cloud/oms/internal/version: + config: + all: true + interfaces: + github.com/codesphere-cloud/oms/cli/cmd: + config: + all: true + interfaces: diff --git a/Makefile b/Makefile index ca5f2774..96d50f18 100644 --- a/Makefile +++ b/Makefile @@ -33,3 +33,8 @@ endif generate: install-build-deps mockery go generate ./... + +VERSION ?= "0.0.0" +release-local: + rm -rf dist + /bin/bash -c "goreleaser --skip=validate,announce,publish -f <(sed s/{{.Version}}/$(VERSION)/g < .goreleaser.yaml)" diff --git a/cli/cmd/download_package.go b/cli/cmd/download_package.go index f68ed7e8..4de58904 100644 --- a/cli/cmd/download_package.go +++ b/cli/cmd/download_package.go @@ -61,27 +61,21 @@ func AddDownloadPackageCmd(download *cobra.Command, opts GlobalOptions) { pkg.cmd.RunE = pkg.RunE } -func (c *DownloadPackageCmd) DownloadBuild(p portal.Portal, build portal.CodesphereBuild, filename string) error { - for _, art := range build.Artifacts { - if art.Filename == filename { - fullFilename := build.Version + "-" + art.Filename - out, err := c.FileWriter.Create(fullFilename) - if err != nil { - return fmt.Errorf("failed to create file %s: %w", fullFilename, err) - } - defer func() { _ = out.Close() }() - - buildWithArtifact := build - buildWithArtifact.Artifacts = []portal.Artifact{art} - - err = p.DownloadBuildArtifact(buildWithArtifact, out) - if err != nil { - return fmt.Errorf("failed to download build: %w", err) - } - return nil - } - +func (c *DownloadPackageCmd) DownloadBuild(p portal.Portal, build portal.Build, filename string) error { + download, err := build.GetBuildForDownload(filename) + if err != nil { + return fmt.Errorf("failed to find artifact in package: %w", err) } + fullFilename := build.Version + "-" + filename + out, err := c.FileWriter.Create(fullFilename) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", fullFilename, err) + } + defer func() { _ = out.Close() }() - return fmt.Errorf("can't find artifact %s in version %s", filename, build.Version) + err = p.DownloadBuildArtifact("codesphere", download, out) + if err != nil { + return fmt.Errorf("failed to download build: %w", err) + } + return nil } diff --git a/cli/cmd/download_package_test.go b/cli/cmd/download_package_test.go index 88210537..cb9bce2f 100644 --- a/cli/cmd/download_package_test.go +++ b/cli/cmd/download_package_test.go @@ -18,7 +18,7 @@ var _ = Describe("ListPackages", func() { c cmd.DownloadPackageCmd filename string version string - build portal.CodesphereBuild + build portal.Build mockPortal *portal.MockPortal mockFileWriter *util.MockFileWriter ) @@ -36,7 +36,7 @@ var _ = Describe("ListPackages", func() { }, FileWriter: mockFileWriter, } - build = portal.CodesphereBuild{ + build = portal.Build{ Version: version, Artifacts: []portal.Artifact{ {Filename: filename}, @@ -51,7 +51,7 @@ var _ = Describe("ListPackages", func() { Context("File exists", func() { It("Downloads the correct artifact to the correct output file", func() { - expectedBuildToDownload := portal.CodesphereBuild{ + expectedBuildToDownload := portal.Build{ Version: version, Artifacts: []portal.Artifact{ {Filename: filename}, @@ -60,7 +60,7 @@ var _ = Describe("ListPackages", func() { fakeFile := os.NewFile(uintptr(0), filename) mockFileWriter.EXPECT().Create(version+"-"+filename).Return(fakeFile, nil) - mockPortal.EXPECT().DownloadBuildArtifact(expectedBuildToDownload, mock.Anything).Return(nil) + mockPortal.EXPECT().DownloadBuildArtifact(portal.CodesphereProduct, expectedBuildToDownload, mock.Anything).Return(nil) err := c.DownloadBuild(mockPortal, build, filename) Expect(err).NotTo(HaveOccurred()) }) @@ -69,7 +69,7 @@ var _ = Describe("ListPackages", func() { Context("File doesn't exist in build", func() { It("Returns an error", func() { err := c.DownloadBuild(mockPortal, build, "installer-lite.tar.gz") - Expect(err).To(MatchError("can't find artifact installer-lite.tar.gz in version " + version)) + Expect(err).To(MatchError("failed to find artifact in package: artifact not found: installer-lite.tar.gz")) }) }) }) diff --git a/cli/cmd/list_packages.go b/cli/cmd/list_packages.go index 1f87e65e..4fa6e7fb 100644 --- a/cli/cmd/list_packages.go +++ b/cli/cmd/list_packages.go @@ -24,7 +24,7 @@ type ListBuildsOpts struct { func (c *ListBuildsCmd) RunE(_ *cobra.Command, args []string) error { p := portal.NewPortalClient() - packages, err := p.ListCodesphereBuilds() + packages, err := p.ListBuilds(portal.CodesphereProduct) if err != nil { return fmt.Errorf("failed to list codesphere packages: %w", err) } @@ -51,7 +51,7 @@ func AddListPackagesCmd(list *cobra.Command, opts GlobalOptions) { list.AddCommand(builds.cmd) } -func (c *ListBuildsCmd) PrintPackagesTable(packages portal.CodesphereBuilds) { +func (c *ListBuildsCmd) PrintPackagesTable(packages portal.Builds) { c.TableWriter.AppendHeader(table.Row{"Int", "Version", "Build Date", "Hash", "Artifacts"}) for _, build := range packages.Builds { diff --git a/cli/cmd/list_packages_test.go b/cli/cmd/list_packages_test.go index e340961e..352ee540 100644 --- a/cli/cmd/list_packages_test.go +++ b/cli/cmd/list_packages_test.go @@ -43,8 +43,8 @@ var _ = Describe("ListPackages", func() { table.Row{"", "1.42", buildDate, "externalBuild", "installer.tar, installer2.tar"}, ) c.PrintPackagesTable( - portal.CodesphereBuilds{ - Builds: []portal.CodesphereBuild{ + portal.Builds{ + Builds: []portal.Build{ { Hash: "externalBuild", Version: "1.42", @@ -76,8 +76,8 @@ var _ = Describe("ListPackages", func() { table.Row{"*", "master", buildDate, "internalBuild", "installer.tar, installer2.tar"}, ) c.PrintPackagesTable( - portal.CodesphereBuilds{ - Builds: []portal.CodesphereBuild{ + portal.Builds{ + Builds: []portal.Build{ { Hash: "externalBuild", Version: "1.42", diff --git a/cli/cmd/mocks.go b/cli/cmd/mocks.go new file mode 100644 index 00000000..0aab4e6b --- /dev/null +++ b/cli/cmd/mocks.go @@ -0,0 +1,82 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package cmd + +import ( + mock "github.com/stretchr/testify/mock" + "io" +) + +// NewMockUpdater creates a new instance of MockUpdater. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockUpdater(t interface { + mock.TestingT + Cleanup(func()) +}) *MockUpdater { + mock := &MockUpdater{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockUpdater is an autogenerated mock type for the Updater type +type MockUpdater struct { + mock.Mock +} + +type MockUpdater_Expecter struct { + mock *mock.Mock +} + +func (_m *MockUpdater) EXPECT() *MockUpdater_Expecter { + return &MockUpdater_Expecter{mock: &_m.Mock} +} + +// Apply provides a mock function for the type MockUpdater +func (_mock *MockUpdater) Apply(update io.Reader) error { + ret := _mock.Called(update) + + if len(ret) == 0 { + panic("no return value specified for Apply") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(io.Reader) error); ok { + r0 = returnFunc(update) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockUpdater_Apply_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Apply' +type MockUpdater_Apply_Call struct { + *mock.Call +} + +// Apply is a helper method to define mock.On call +// - update +func (_e *MockUpdater_Expecter) Apply(update interface{}) *MockUpdater_Apply_Call { + return &MockUpdater_Apply_Call{Call: _e.mock.On("Apply", update)} +} + +func (_c *MockUpdater_Apply_Call) Run(run func(update io.Reader)) *MockUpdater_Apply_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(io.Reader)) + }) + return _c +} + +func (_c *MockUpdater_Apply_Call) Return(err error) *MockUpdater_Apply_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockUpdater_Apply_Call) RunAndReturn(run func(update io.Reader) error) *MockUpdater_Apply_Call { + _c.Call.Return(run) + return _c +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 72836f25..def6fad1 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -27,6 +27,7 @@ func Execute() { like downloading new versions.`), } AddVersionCmd(rootCmd) + AddUpdateCmd(rootCmd) AddListCmd(rootCmd, opts) AddDownloadCmd(rootCmd, opts) diff --git a/cli/cmd/testdata/testcli.tar.gz b/cli/cmd/testdata/testcli.tar.gz new file mode 100644 index 00000000..b689152f Binary files /dev/null and b/cli/cmd/testdata/testcli.tar.gz differ diff --git a/cli/cmd/update.go b/cli/cmd/update.go new file mode 100644 index 00000000..0023ea19 --- /dev/null +++ b/cli/cmd/update.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "fmt" + "golang.org/x/sync/errgroup" + "io" + "strings" + + "github.com/blang/semver" + "github.com/inconshreveable/go-update" + "github.com/spf13/cobra" + + "github.com/codesphere-cloud/oms/internal/portal" + "github.com/codesphere-cloud/oms/internal/util" + "github.com/codesphere-cloud/oms/internal/version" +) + +type UpdateCmd struct { + cmd *cobra.Command + Version version.Version + Updater Updater +} + +func (c *UpdateCmd) RunE(_ *cobra.Command, args []string) error { + + p := portal.NewPortalClient() + + return c.SelfUpdate(p) +} + +func AddUpdateCmd(rootCmd *cobra.Command) { + update := UpdateCmd{ + cmd: &cobra.Command{ + Use: "update", + Short: "Update Codesphere OMS", + Long: `Updates the OMS to the latest release from OMS Portal.`, + }, + Version: &version.Build{}, + Updater: &SelfUpdater{}, + } + rootCmd.AddCommand(update.cmd) + update.cmd.RunE = update.RunE +} + +func (c *UpdateCmd) SelfUpdate(p portal.Portal) error { + currentVersion := semver.MustParse(c.Version.Version()) + + latest, err := p.GetLatestBuild(portal.OmsProduct) + if err != nil { + return fmt.Errorf("failed to OMS Portal for latest version: %w", err) + } + latestVersion := semver.MustParse(strings.TrimPrefix(latest.Version, "oms-v")) + + fmt.Printf("current version: %v\n", currentVersion) + fmt.Printf("latest version: %v\n", latestVersion) + if latestVersion.Equals(currentVersion) { + fmt.Println("Current OMS CLI is already the latest version", c.Version.Version()) + return nil + } + + // Need a build with a single artifact to download it + download, err := latest.GetBuildForDownload(fmt.Sprintf("%s_%s.tar.gz", c.Version.Os(), c.Version.Arch())) + if err != nil { + return fmt.Errorf("failed to find OMS CLI in package: %w", err) + } + + // Use a pipe to unzip the file while downloading without storing on the filesystem + reader, writer := io.Pipe() + defer func() { _ = reader.Close() }() + + eg := errgroup.Group{} + eg.Go(func() error { + defer func() { _ = writer.Close() }() + err = p.DownloadBuildArtifact(portal.OmsProduct, download, writer) + if err != nil { + return fmt.Errorf("failed to download latest OMS package: %w", err) + } + return nil + }) + + cliReader, err := util.StreamFileFromGzip(reader, "oms-cli") + if err != nil { + return fmt.Errorf("failed to extract binary from archive: %w", err) + } + + err = c.Updater.Apply(cliReader) + if err != nil { + return fmt.Errorf("failed to apply update: %w", err) + } + + _, _ = io.Copy(io.Discard, reader) + + // Wait for download to finish and handle any error from the go routine + err = eg.Wait() + if err != nil { + return err + } + + fmt.Println("Update finished successfully.") + return nil +} + +type Updater interface { + Apply(update io.Reader) error +} + +type SelfUpdater struct{} + +func (s *SelfUpdater) Apply(r io.Reader) error { + return update.Apply(r, update.Options{}) +} diff --git a/cli/cmd/update_test.go b/cli/cmd/update_test.go new file mode 100644 index 00000000..43e5cc85 --- /dev/null +++ b/cli/cmd/update_test.go @@ -0,0 +1,98 @@ +package cmd_test + +import ( + "embed" + "io" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" + + "github.com/codesphere-cloud/oms/cli/cmd" + "github.com/codesphere-cloud/oms/internal/portal" + "github.com/codesphere-cloud/oms/internal/version" +) + +// I didn't find a good way to do this in memory without just reversing the code under test. +// While this is not ideal, at least it doesn't read from file during the test run but during compilation. +// +//go:generate mkdir -p testdata +//go:generate sh -c "echo fake-cli > testdata/oms-cli" +//go:generate sh -c "cd testdata && tar cfz testcli.tar.gz oms-cli" +//go:embed testdata +var testdata embed.FS + +var _ = Describe("Update", func() { + + var ( + mockPortal *portal.MockPortal + mockVersion *version.MockVersion + mockUpdater *cmd.MockUpdater + latestBuild portal.Build + buildToDownload portal.Build + c cmd.UpdateCmd + ) + + BeforeEach(func() { + mockPortal = portal.NewMockPortal(GinkgoT()) + mockVersion = version.NewMockVersion(GinkgoT()) + mockUpdater = cmd.NewMockUpdater(GinkgoT()) + + latestBuild = portal.Build{ + Version: "0.0.42", + Artifacts: []portal.Artifact{ + {Filename: "fakeos_fakearch.tar.gz"}, + {Filename: "fakeos2_fakearch2.tar.gz"}, + {Filename: "fakeos3_fakearch3.tar.gz"}, + }, + } + buildToDownload = portal.Build{ + Version: "0.0.42", + Artifacts: []portal.Artifact{ + {Filename: "fakeos_fakearch.tar.gz"}, + }, + } + c = cmd.UpdateCmd{ + Version: mockVersion, + Updater: mockUpdater, + } + }) + + Describe("SelfUpdate", func() { + It("Extracts oms-cli from the downloaded archive", func() { + mockVersion.EXPECT().Arch().Return("fakearch") + mockVersion.EXPECT().Version().Return("0.0.0") + mockVersion.EXPECT().Os().Return("fakeos") + mockPortal.EXPECT().GetLatestBuild(portal.OmsProduct).Return(latestBuild, nil) + mockPortal.EXPECT().DownloadBuildArtifact(portal.OmsProduct, buildToDownload, mock.Anything).RunAndReturn( + func(product portal.Product, build portal.Build, file io.Writer) error { + embeddedFile, err := testdata.Open("testdata/testcli.tar.gz") + if err != nil { + Expect(err).NotTo(HaveOccurred()) + } + defer func() { _ = embeddedFile.Close() }() + + if _, err := io.Copy(file, embeddedFile); err != nil { + Expect(err).NotTo(HaveOccurred()) + } + return nil + }) + mockUpdater.EXPECT().Apply(mock.Anything).RunAndReturn(func(update io.Reader) error { + output, err := io.ReadAll(update) + Expect(err).NotTo(HaveOccurred()) + // file content written in go:generate + Expect(string(output)).To(Equal("fake-cli\n")) + return nil + }) + err := c.SelfUpdate(mockPortal) + Expect(err).NotTo(HaveOccurred()) + }) + + It("Detects when current verison is latest version", func() { + mockVersion.EXPECT().Version().Return(latestBuild.Version) + mockPortal.EXPECT().GetLatestBuild(portal.OmsProduct).Return(latestBuild, nil) + err := c.SelfUpdate(mockPortal) + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/cli/cmd/version.go b/cli/cmd/version.go index cc47e4e0..d5f54dfd 100644 --- a/cli/cmd/version.go +++ b/cli/cmd/version.go @@ -6,7 +6,7 @@ package cmd import ( "fmt" - "github.com/codesphere-cloud/oms/internal/version" + v "github.com/codesphere-cloud/oms/internal/version" "github.com/spf13/cobra" ) @@ -15,6 +15,7 @@ type VersionCmd struct { } func (c *VersionCmd) RunE(_ *cobra.Command, args []string) error { + version := v.Build{} fmt.Printf("OMS CLI version: %s\n", version.Version()) fmt.Printf("Commit: %s\n", version.Commit()) fmt.Printf("Build Date: %s\n", version.BuildDate()) diff --git a/go.mod b/go.mod index ca4d9a47..3bd17b92 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/onsi/gomega v1.37.0 github.com/spf13/cobra v1.9.1 github.com/stretchr/testify v1.10.0 + golang.org/x/sync v0.14.0 ) require ( diff --git a/go.sum b/go.sum index 831ebfcf..e6991eca 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,8 @@ go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= diff --git a/internal/portal/http.go b/internal/portal/http.go index be2fba66..09839181 100644 --- a/internal/portal/http.go +++ b/internal/portal/http.go @@ -3,6 +3,7 @@ package portal import ( "bytes" "encoding/json" + "errors" "fmt" "io" "log" @@ -15,7 +16,8 @@ import ( ) type Portal interface { - DownloadBuildArtifact(build CodesphereBuild, file io.Writer) error + DownloadBuildArtifact(product Product, build Build, file io.Writer) error + GetLatestBuild(product Product) (Build, error) } type PortalClient struct { @@ -34,6 +36,13 @@ func NewPortalClient() *PortalClient { } } +type Product string + +const ( + CodesphereProduct Product = "codesphere" + OmsProduct Product = "oms" +) + func (c *PortalClient) Get(path string, body []byte) (resp *http.Response, err error) { requestBody := bytes.NewBuffer(body) url, err := url.JoinPath(c.Env.GetOmsPortalApi(), path) @@ -95,8 +104,8 @@ func (c *PortalClient) GetBody(path string) (body []byte, status int, err error) return } -func (c *PortalClient) ListCodesphereBuilds() (availablePackages CodesphereBuilds, err error) { - res, _, err := c.GetBody("/packages/codesphere") +func (c *PortalClient) ListBuilds(product Product) (availablePackages Builds, err error) { + res, _, err := c.GetBody(fmt.Sprintf("/packages/%s", product)) if err != nil { err = fmt.Errorf("failed to list packages: %w", err) return @@ -112,10 +121,10 @@ func (c *PortalClient) ListCodesphereBuilds() (availablePackages CodesphereBuild return } -func (c *PortalClient) GetCodesphereBuildByVersion(version string) (CodesphereBuild, error) { - packages, err := c.ListCodesphereBuilds() +func (c *PortalClient) GetCodesphereBuildByVersion(version string) (Build, error) { + packages, err := c.ListBuilds(CodesphereProduct) if err != nil { - return CodesphereBuild{}, fmt.Errorf("failed to list Codesphere packages: %w", err) + return Build{}, fmt.Errorf("failed to list Codesphere packages: %w", err) } for _, build := range packages.Builds { @@ -124,10 +133,10 @@ func (c *PortalClient) GetCodesphereBuildByVersion(version string) (CodesphereBu } } - return CodesphereBuild{}, fmt.Errorf("version %s not found", version) + return Build{}, fmt.Errorf("version %s not found", version) } -func compareBuilds(l, r CodesphereBuild) int { +func compareBuilds(l, r Build) int { if l.Date.Before(r.Date) { return -1 } @@ -137,13 +146,13 @@ func compareBuilds(l, r CodesphereBuild) int { return 1 } -func (c *PortalClient) DownloadBuildArtifact(build CodesphereBuild, file io.Writer) error { +func (c *PortalClient) DownloadBuildArtifact(product Product, build Build, file io.Writer) error { reqBody, err := json.Marshal(build) if err != nil { return fmt.Errorf("failed to generate request body: %w", err) } - resp, err := c.Get("/packages/codesphere/download", reqBody) + resp, err := c.Get(fmt.Sprintf("/packages/%s/download", product), reqBody) if err != nil { return fmt.Errorf("GET request to download build failed: %w", err) } @@ -152,15 +161,29 @@ func (c *PortalClient) DownloadBuildArtifact(build CodesphereBuild, file io.Writ // Create a WriteCounter to wrap the output file and report progress. counter := NewWriteCounter(file) - bytesWritten, err := io.Copy(counter, resp.Body) + _, err = io.Copy(counter, resp.Body) if err != nil { return fmt.Errorf("failed to copy response body to file: %w", err) } - log.Printf("\nSuccessfully downloaded %d bytes.\n", bytesWritten) + fmt.Println("Download finished successfully.") return nil } +func (c *PortalClient) GetLatestBuild(product Product) (Build, error) { + packages, err := c.ListBuilds(product) + if err != nil { + return Build{}, fmt.Errorf("failed to list %s packages: %w", product, err) + } + + if len(packages.Builds) == 0 { + return Build{}, errors.New("no builds returned") + } + + // Builds are always ordered by date, newest build is latest version + return packages.Builds[len(packages.Builds)-1], nil +} + // WriteCounter is a custom io.Writer that counts bytes written and logs progress. type WriteCounter struct { Written int64 diff --git a/internal/portal/http_test.go b/internal/portal/http_test.go index 9cab6a63..e10d8d07 100644 --- a/internal/portal/http_test.go +++ b/internal/portal/http_test.go @@ -35,8 +35,10 @@ var _ = Describe("PortalClient", func() { apiUrl string getUrl url.URL getResponse []byte + product portal.Product ) BeforeEach(func() { + product = portal.CodesphereProduct mockEnv = env.NewMockEnv(GinkgoT()) mockHttpClient = portal.NewMockHttpClient(GinkgoT()) @@ -98,13 +100,13 @@ var _ = Describe("PortalClient", func() { }) }) Context("when the request suceeds", func() { - var expectedResult portal.CodesphereBuilds + var expectedResult portal.Builds BeforeEach(func() { firstBuild, _ := time.Parse("2006-01-02", "2025-04-02") lastBuild, _ := time.Parse("2006-01-02", "2025-05-01") - getPackagesResponse := portal.CodesphereBuilds{ - Builds: []portal.CodesphereBuild{ + getPackagesResponse := portal.Builds{ + Builds: []portal.Build{ { Hash: "lastBuild", Date: lastBuild, @@ -117,8 +119,8 @@ var _ = Describe("PortalClient", func() { } getResponse, _ = json.Marshal(getPackagesResponse) - expectedResult = portal.CodesphereBuilds{ - Builds: []portal.CodesphereBuild{ + expectedResult = portal.Builds{ + Builds: []portal.Build{ { Hash: "firstBuild", Date: firstBuild, @@ -132,7 +134,7 @@ var _ = Describe("PortalClient", func() { }) It("returns the builds ordered by date", func() { - packages, err := client.ListCodesphereBuilds() + packages, err := client.ListBuilds(portal.CodesphereProduct) Expect(err).NotTo(HaveOccurred()) Expect(packages).To(Equal(expectedResult)) Expect(getUrl.String()).To(Equal("fake-portal.com/packages/codesphere")) @@ -158,8 +160,8 @@ var _ = Describe("PortalClient", func() { firstBuild, _ = time.Parse("2006-01-02", "2025-04-02") lastBuild, _ = time.Parse("2006-01-02", "2025-05-01") - getPackagesResponse := portal.CodesphereBuilds{ - Builds: []portal.CodesphereBuild{ + getPackagesResponse := portal.Builds{ + Builds: []portal.Build{ { Hash: "lastBuild", Date: lastBuild, @@ -177,7 +179,7 @@ var _ = Describe("PortalClient", func() { Context("When the build is included", func() { It("returns the build", func() { - expectedResult := portal.CodesphereBuild{ + expectedResult := portal.Build{ Hash: "lastBuild", Date: lastBuild, Version: "1.42.0", @@ -191,7 +193,7 @@ var _ = Describe("PortalClient", func() { Context("When the build is not included", func() { It("returns an error and an empty build", func() { - expectedResult := portal.CodesphereBuild{} + expectedResult := portal.Build{} packages, err := client.GetCodesphereBuildByVersion("1.42.3") Expect(err).To(MatchError("version 1.42.3 not found")) Expect(packages).To(Equal(expectedResult)) @@ -202,7 +204,7 @@ var _ = Describe("PortalClient", func() { Describe("DownloadBuildArtifact", func() { var ( - build portal.CodesphereBuild + build portal.Build downloadResponse string ) BeforeEach(func() { @@ -210,7 +212,7 @@ var _ = Describe("PortalClient", func() { downloadResponse = "fake-file-contents" - build = portal.CodesphereBuild{ + build = portal.Build{ Date: buildDate, } @@ -226,10 +228,79 @@ var _ = Describe("PortalClient", func() { It("downloads the build", func() { fakeWriter := NewFakeWriter() - err := client.DownloadBuildArtifact(build, fakeWriter) + err := client.DownloadBuildArtifact(product, build, fakeWriter) Expect(err).NotTo(HaveOccurred()) Expect(fakeWriter.String()).To(Equal(downloadResponse)) Expect(getUrl.String()).To(Equal("fake-portal.com/packages/codesphere/download")) }) }) + + Describe("GetLatestOmsBuild", func() { + var ( + lastBuild, firstBuild time.Time + getPackagesResponse portal.Builds + ) + JustBeforeEach(func() { + getResponse, _ = json.Marshal(getPackagesResponse) + mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( + func(req *http.Request) (*http.Response, error) { + getUrl = *req.URL + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(bytes.NewReader(getResponse)), + }, nil + }) + }) + + Context("When the build is included", func() { + BeforeEach(func() { + firstBuild, _ = time.Parse("2006-01-02", "2025-04-02") + lastBuild, _ = time.Parse("2006-01-02", "2025-05-01") + + getPackagesResponse = portal.Builds{ + Builds: []portal.Build{ + { + Hash: "firstBuild", + Date: firstBuild, + Version: "1.42.0", + }, + { + Hash: "lastBuild", + Date: lastBuild, + Version: "1.42.1", + }, + }, + } + }) + It("returns the build", func() { + expectedResult := portal.Build{ + Hash: "lastBuild", + Date: lastBuild, + Version: "1.42.1", + } + packages, err := client.GetLatestBuild(portal.OmsProduct) + Expect(err).NotTo(HaveOccurred()) + Expect(packages).To(Equal(expectedResult)) + Expect(getUrl.String()).To(Equal("fake-portal.com/packages/oms")) + }) + }) + + Context("When no builds are returned", func() { + BeforeEach(func() { + firstBuild, _ = time.Parse("2006-01-02", "2025-04-02") + lastBuild, _ = time.Parse("2006-01-02", "2025-05-01") + + getPackagesResponse = portal.Builds{ + Builds: []portal.Build{}, + } + }) + It("returns an error and an empty build", func() { + expectedResult := portal.Build{} + packages, err := client.GetLatestBuild(portal.OmsProduct) + Expect(err).To(MatchError("no builds returned")) + Expect(packages).To(Equal(expectedResult)) + Expect(getUrl.String()).To(Equal("fake-portal.com/packages/oms")) + }) + }) + }) }) diff --git a/internal/portal/mocks.go b/internal/portal/mocks.go index aa1e1199..0b5d5761 100644 --- a/internal/portal/mocks.go +++ b/internal/portal/mocks.go @@ -38,16 +38,16 @@ func (_m *MockPortal) EXPECT() *MockPortal_Expecter { } // DownloadBuildArtifact provides a mock function for the type MockPortal -func (_mock *MockPortal) DownloadBuildArtifact(build CodesphereBuild, file io.Writer) error { - ret := _mock.Called(build, file) +func (_mock *MockPortal) DownloadBuildArtifact(product Product, build Build, file io.Writer) error { + ret := _mock.Called(product, build, file) if len(ret) == 0 { panic("no return value specified for DownloadBuildArtifact") } var r0 error - if returnFunc, ok := ret.Get(0).(func(CodesphereBuild, io.Writer) error); ok { - r0 = returnFunc(build, file) + if returnFunc, ok := ret.Get(0).(func(Product, Build, io.Writer) error); ok { + r0 = returnFunc(product, build, file) } else { r0 = ret.Error(0) } @@ -60,15 +60,16 @@ type MockPortal_DownloadBuildArtifact_Call struct { } // DownloadBuildArtifact is a helper method to define mock.On call +// - product // - build // - file -func (_e *MockPortal_Expecter) DownloadBuildArtifact(build interface{}, file interface{}) *MockPortal_DownloadBuildArtifact_Call { - return &MockPortal_DownloadBuildArtifact_Call{Call: _e.mock.On("DownloadBuildArtifact", 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)} } -func (_c *MockPortal_DownloadBuildArtifact_Call) Run(run func(build CodesphereBuild, file io.Writer)) *MockPortal_DownloadBuildArtifact_Call { +func (_c *MockPortal_DownloadBuildArtifact_Call) Run(run func(product Product, build Build, file io.Writer)) *MockPortal_DownloadBuildArtifact_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(CodesphereBuild), args[1].(io.Writer)) + run(args[0].(Product), args[1].(Build), args[2].(io.Writer)) }) return _c } @@ -78,7 +79,61 @@ func (_c *MockPortal_DownloadBuildArtifact_Call) Return(err error) *MockPortal_D return _c } -func (_c *MockPortal_DownloadBuildArtifact_Call) RunAndReturn(run func(build CodesphereBuild, file io.Writer) error) *MockPortal_DownloadBuildArtifact_Call { +func (_c *MockPortal_DownloadBuildArtifact_Call) RunAndReturn(run func(product Product, build Build, file io.Writer) error) *MockPortal_DownloadBuildArtifact_Call { + _c.Call.Return(run) + return _c +} + +// GetLatestBuild provides a mock function for the type MockPortal +func (_mock *MockPortal) GetLatestBuild(product Product) (Build, error) { + ret := _mock.Called(product) + + if len(ret) == 0 { + panic("no return value specified for GetLatestBuild") + } + + var r0 Build + var r1 error + if returnFunc, ok := ret.Get(0).(func(Product) (Build, error)); ok { + return returnFunc(product) + } + if returnFunc, ok := ret.Get(0).(func(Product) Build); ok { + r0 = returnFunc(product) + } else { + r0 = ret.Get(0).(Build) + } + if returnFunc, ok := ret.Get(1).(func(Product) error); ok { + r1 = returnFunc(product) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockPortal_GetLatestBuild_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLatestBuild' +type MockPortal_GetLatestBuild_Call struct { + *mock.Call +} + +// GetLatestBuild is a helper method to define mock.On call +// - product +func (_e *MockPortal_Expecter) GetLatestBuild(product interface{}) *MockPortal_GetLatestBuild_Call { + return &MockPortal_GetLatestBuild_Call{Call: _e.mock.On("GetLatestBuild", product)} +} + +func (_c *MockPortal_GetLatestBuild_Call) Run(run func(product Product)) *MockPortal_GetLatestBuild_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(Product)) + }) + return _c +} + +func (_c *MockPortal_GetLatestBuild_Call) Return(build Build, err error) *MockPortal_GetLatestBuild_Call { + _c.Call.Return(build, err) + return _c +} + +func (_c *MockPortal_GetLatestBuild_Call) RunAndReturn(run func(product Product) (Build, error)) *MockPortal_GetLatestBuild_Call { _c.Call.Return(run) return _c } diff --git a/internal/portal/package.go b/internal/portal/package.go index 8cf5dd1e..06f34223 100644 --- a/internal/portal/package.go +++ b/internal/portal/package.go @@ -1,14 +1,15 @@ package portal import ( + "fmt" "time" ) -type CodesphereBuilds struct { - Builds []CodesphereBuild `json:"builds"` +type Builds struct { + Builds []Build `json:"builds"` } -type CodesphereBuild struct { +type Build struct { Version string `json:"version"` Date time.Time `json:"date"` Hash string `json:"hash"` @@ -21,3 +22,21 @@ type Artifact struct { Filename string `json:"filename"` Name string `json:"name"` } + +func (b *Build) GetBuildForDownload(filename string) (Build, error) { + + for _, a := range b.Artifacts { + if a.Filename != filename { + continue + } + + // Generate identical build but with only the matching artifact + build := *b + build.Artifacts = []Artifact{ + a, + } + return build, nil + } + + return Build{}, fmt.Errorf("artifact not found: %s", filename) +} diff --git a/internal/portal/package_test.go b/internal/portal/package_test.go new file mode 100644 index 00000000..6d614759 --- /dev/null +++ b/internal/portal/package_test.go @@ -0,0 +1,32 @@ +package portal_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/portal" +) + +var _ = Describe("GetBuildForDownload", func() { + It("Extracts a build with a single matching artifact", func() { + + build := portal.Build{ + Artifacts: []portal.Artifact{ + {Filename: "a.txt"}, + {Filename: "b.txt"}, + {Filename: "c.txt"}, + }, + } + + expectedBuild := portal.Build{ + Artifacts: []portal.Artifact{ + {Filename: "b.txt"}, + }, + } + + res, err := build.GetBuildForDownload("b.txt") + Expect(err).NotTo(HaveOccurred()) + Expect(res).To(Equal(expectedBuild)) + }) + +}) diff --git a/internal/util/tar.go b/internal/util/tar.go new file mode 100644 index 00000000..768bc43c --- /dev/null +++ b/internal/util/tar.go @@ -0,0 +1,39 @@ +package util + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" +) + +// StreamFileFromGzip creates a new streamer for a specific file in a tar.gz +func StreamFileFromGzip(reader io.Reader, filename string) (io.Reader, error) { + uncompressedStream, err := gzip.NewReader(reader) + if err != nil { + return nil, fmt.Errorf("failed to create a gzip reader: %w", err) + } + + // Pass the decompressed stream to the tar reader. + tarStreamer, err := streamFileFromArchive(tar.NewReader(uncompressedStream), filename) + if tarStreamer == nil || err != nil { + return nil, err + } + + return tarStreamer, err +} + +func streamFileFromArchive(tarReader *tar.Reader, filename string) (*tar.Reader, error) { + for { + header, err := tarReader.Next() + if err == io.EOF { + return nil, fmt.Errorf("file %s not found in archive", filename) + } + if err != nil { + return nil, fmt.Errorf("failed reading tar archive: %w", err) + } + if header.FileInfo().Name() == filename { + return tarReader, nil + } + } +} diff --git a/internal/util/tar_test.go b/internal/util/tar_test.go new file mode 100644 index 00000000..8a112013 --- /dev/null +++ b/internal/util/tar_test.go @@ -0,0 +1,61 @@ +package util_test + +import ( + "io" + + "embed" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/util" +) + +// I didn't find a good way to do this in memory without just reversing the code under test. +// While this is not ideal, at least it doesn't read from file during the test run but during compilation. +// +//go:generate mkdir -p testdata/test +//go:generate sh -c "echo some text > testdata/file1.txt" +//go:generate sh -c "echo some more text > testdata/test/file2.txt" +//go:generate sh -c "cd testdata && tar cfz testdata1.tar.gz file1.txt file2.txt" +//go:embed testdata +var testdata1 embed.FS + +// this just reflects what's in the tar but doesn't influence the actual contents. +var fileContents = map[string]string{ + "file1.txt": "some text\n", + "test/file2.txt": "some more text\n", +} +var _ = Describe("Tar", func() { + var ( + archiveIn io.Reader + ) + BeforeEach(func() { + var err error + archiveIn, err = testdata1.Open("testdata/testdata1.tar.gz") + Expect(err).Error().NotTo(HaveOccurred()) + }) + + Describe("StreamFileFromGzip", func() { + It("streams a single file from a .tar.gz archive", func() { + out, err := util.StreamFileFromGzip(archiveIn, "file1.txt") + Expect(err).NotTo(HaveOccurred()) + outputFileContent, err := io.ReadAll(out) + Expect(err).NotTo(HaveOccurred()) + Expect(string(outputFileContent)).To(Equal(fileContents["file1.txt"])) + }) + + It("finds a file in a subdir of the .tar.gz archive", func() { + out, err := util.StreamFileFromGzip(archiveIn, "file2.txt") + Expect(err).NotTo(HaveOccurred()) + outputFileContent, err := io.ReadAll(out) + Expect(err).NotTo(HaveOccurred()) + Expect(string(outputFileContent)).To(Equal(fileContents["test/file2.txt"])) + }) + + It("Returns an error when the file doesn't exist", func() { + out, err := util.StreamFileFromGzip(archiveIn, "file3.txt") + Expect(out).To(BeNil()) + Expect(err).To(MatchError("file file3.txt not found in archive")) + }) + }) +}) diff --git a/internal/util/testdata/file1.txt b/internal/util/testdata/file1.txt new file mode 100644 index 00000000..7b57bd29 --- /dev/null +++ b/internal/util/testdata/file1.txt @@ -0,0 +1 @@ +some text diff --git a/internal/util/testdata/file2.txt b/internal/util/testdata/file2.txt new file mode 100644 index 00000000..8cecc93a --- /dev/null +++ b/internal/util/testdata/file2.txt @@ -0,0 +1 @@ +some more text diff --git a/internal/util/testdata/test/file2.txt b/internal/util/testdata/test/file2.txt new file mode 100644 index 00000000..8cecc93a --- /dev/null +++ b/internal/util/testdata/test/file2.txt @@ -0,0 +1 @@ +some more text diff --git a/internal/util/testdata/testdata1.tar.gz b/internal/util/testdata/testdata1.tar.gz new file mode 100644 index 00000000..aafecb3a Binary files /dev/null and b/internal/util/testdata/testdata1.tar.gz differ diff --git a/internal/util/util_suite_test.go b/internal/util/util_suite_test.go new file mode 100644 index 00000000..6d6903e9 --- /dev/null +++ b/internal/util/util_suite_test.go @@ -0,0 +1,13 @@ +package util_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestUtil(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Util Suite") +} diff --git a/internal/version/mocks.go b/internal/version/mocks.go new file mode 100644 index 00000000..1a375109 --- /dev/null +++ b/internal/version/mocks.go @@ -0,0 +1,256 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package version + +import ( + mock "github.com/stretchr/testify/mock" +) + +// NewMockVersion creates a new instance of MockVersion. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockVersion(t interface { + mock.TestingT + Cleanup(func()) +}) *MockVersion { + mock := &MockVersion{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockVersion is an autogenerated mock type for the Version type +type MockVersion struct { + mock.Mock +} + +type MockVersion_Expecter struct { + mock *mock.Mock +} + +func (_m *MockVersion) EXPECT() *MockVersion_Expecter { + return &MockVersion_Expecter{mock: &_m.Mock} +} + +// Arch provides a mock function for the type MockVersion +func (_mock *MockVersion) Arch() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Arch") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockVersion_Arch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Arch' +type MockVersion_Arch_Call struct { + *mock.Call +} + +// Arch is a helper method to define mock.On call +func (_e *MockVersion_Expecter) Arch() *MockVersion_Arch_Call { + return &MockVersion_Arch_Call{Call: _e.mock.On("Arch")} +} + +func (_c *MockVersion_Arch_Call) Run(run func()) *MockVersion_Arch_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockVersion_Arch_Call) Return(s string) *MockVersion_Arch_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockVersion_Arch_Call) RunAndReturn(run func() string) *MockVersion_Arch_Call { + _c.Call.Return(run) + return _c +} + +// BuildDate provides a mock function for the type MockVersion +func (_mock *MockVersion) BuildDate() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for BuildDate") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockVersion_BuildDate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BuildDate' +type MockVersion_BuildDate_Call struct { + *mock.Call +} + +// BuildDate is a helper method to define mock.On call +func (_e *MockVersion_Expecter) BuildDate() *MockVersion_BuildDate_Call { + return &MockVersion_BuildDate_Call{Call: _e.mock.On("BuildDate")} +} + +func (_c *MockVersion_BuildDate_Call) Run(run func()) *MockVersion_BuildDate_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockVersion_BuildDate_Call) Return(s string) *MockVersion_BuildDate_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockVersion_BuildDate_Call) RunAndReturn(run func() string) *MockVersion_BuildDate_Call { + _c.Call.Return(run) + return _c +} + +// Commit provides a mock function for the type MockVersion +func (_mock *MockVersion) Commit() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Commit") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockVersion_Commit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Commit' +type MockVersion_Commit_Call struct { + *mock.Call +} + +// Commit is a helper method to define mock.On call +func (_e *MockVersion_Expecter) Commit() *MockVersion_Commit_Call { + return &MockVersion_Commit_Call{Call: _e.mock.On("Commit")} +} + +func (_c *MockVersion_Commit_Call) Run(run func()) *MockVersion_Commit_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockVersion_Commit_Call) Return(s string) *MockVersion_Commit_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockVersion_Commit_Call) RunAndReturn(run func() string) *MockVersion_Commit_Call { + _c.Call.Return(run) + return _c +} + +// Os provides a mock function for the type MockVersion +func (_mock *MockVersion) Os() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Os") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockVersion_Os_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Os' +type MockVersion_Os_Call struct { + *mock.Call +} + +// Os is a helper method to define mock.On call +func (_e *MockVersion_Expecter) Os() *MockVersion_Os_Call { + return &MockVersion_Os_Call{Call: _e.mock.On("Os")} +} + +func (_c *MockVersion_Os_Call) Run(run func()) *MockVersion_Os_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockVersion_Os_Call) Return(s string) *MockVersion_Os_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockVersion_Os_Call) RunAndReturn(run func() string) *MockVersion_Os_Call { + _c.Call.Return(run) + return _c +} + +// Version provides a mock function for the type MockVersion +func (_mock *MockVersion) Version() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Version") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MockVersion_Version_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Version' +type MockVersion_Version_Call struct { + *mock.Call +} + +// Version is a helper method to define mock.On call +func (_e *MockVersion_Expecter) Version() *MockVersion_Version_Call { + return &MockVersion_Version_Call{Call: _e.mock.On("Version")} +} + +func (_c *MockVersion_Version_Call) Run(run func()) *MockVersion_Version_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockVersion_Version_Call) Return(s string) *MockVersion_Version_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MockVersion_Version_Call) RunAndReturn(run func() string) *MockVersion_Version_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/version/version.go b/internal/version/version.go index 08b3fefe..39023c4e 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -9,24 +9,32 @@ var ( arch string = "unknown" ) +type Version interface { + Version() string + Commit() string + BuildDate() string + Os() string + Arch() string +} + type Build struct{} -func Version() string { +func (b *Build) Version() string { return version } -func Commit() string { +func (b *Build) Commit() string { return commit } -func BuildDate() string { +func (b *Build) BuildDate() string { return date } -func Os() string { +func (b *Build) Os() string { return os } -func Arch() string { +func (b *Build) Arch() string { return arch }