diff --git a/cli/cmd/download.go b/cli/cmd/download.go index 20b530c1..5f0bc368 100644 --- a/cli/cmd/download.go +++ b/cli/cmd/download.go @@ -25,4 +25,5 @@ func AddDownloadCmd(rootCmd *cobra.Command, opts *GlobalOptions) { rootCmd.AddCommand(download.cmd) AddDownloadPackageCmd(download.cmd, opts) + AddDownloadK0sCmd(download.cmd, opts) } diff --git a/cli/cmd/download_k0s.go b/cli/cmd/download_k0s.go new file mode 100644 index 00000000..414684a7 --- /dev/null +++ b/cli/cmd/download_k0s.go @@ -0,0 +1,92 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + "log" + + packageio "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/spf13/cobra" + + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/portal" + "github.com/codesphere-cloud/oms/internal/util" +) + +// DownloadK0sCmd represents the k0s download command +type DownloadK0sCmd struct { + cmd *cobra.Command + Opts DownloadK0sOpts + Env env.Env + FileWriter util.FileIO +} + +type DownloadK0sOpts struct { + *GlobalOptions + Version string + Force bool + Quiet bool +} + +func (c *DownloadK0sCmd) RunE(_ *cobra.Command, args []string) error { + hw := portal.NewHttpWrapper() + env := c.Env + k0s := installer.NewK0s(hw, env, c.FileWriter) + + err := c.DownloadK0s(k0s) + if err != nil { + return fmt.Errorf("failed to download k0s: %w", err) + } + + return nil +} + +func AddDownloadK0sCmd(download *cobra.Command, opts *GlobalOptions) { + k0s := DownloadK0sCmd{ + cmd: &cobra.Command{ + Use: "k0s", + Short: "Download k0s Kubernetes distribution", + Long: packageio.Long(`Download a k0s binary directly to the OMS workdir. + Will download the latest version if no version is specified.`), + Example: formatExamplesWithBinary("download k0s", []packageio.Example{ + {Cmd: "", Desc: "Download k0s using the Go-native implementation"}, + {Cmd: "--version 1.22.0", Desc: "Download a specific version of k0s"}, + {Cmd: "--quiet", Desc: "Download k0s with minimal output"}, + {Cmd: "--force", Desc: "Force download even if k0s binary exists"}, + }, "oms-cli"), + }, + Opts: DownloadK0sOpts{GlobalOptions: opts}, + Env: env.NewEnv(), + FileWriter: util.NewFilesystemWriter(), + } + k0s.cmd.Flags().StringVarP(&k0s.Opts.Version, "version", "v", "", "Version of k0s to download") + k0s.cmd.Flags().BoolVarP(&k0s.Opts.Force, "force", "f", false, "Force download even if k0s binary exists") + k0s.cmd.Flags().BoolVarP(&k0s.Opts.Quiet, "quiet", "q", false, "Suppress progress output during download") + + download.AddCommand(k0s.cmd) + + k0s.cmd.RunE = k0s.RunE +} + +func (c *DownloadK0sCmd) DownloadK0s(k0s installer.K0sManager) error { + version := c.Opts.Version + var err error + if version == "" { + version, err = k0s.GetLatestVersion() + if err != nil { + return fmt.Errorf("failed to get latest k0s version: %w", err) + } + } + + k0sPath, err := k0s.Download(version, c.Opts.Force, c.Opts.Quiet) + if err != nil { + return fmt.Errorf("failed to download k0s: %w", err) + } + + log.Printf("k0s binary downloaded successfully at '%s'", k0sPath) + + return nil +} diff --git a/cli/cmd/download_k0s_test.go b/cli/cmd/download_k0s_test.go new file mode 100644 index 00000000..fb4dfc7d --- /dev/null +++ b/cli/cmd/download_k0s_test.go @@ -0,0 +1,97 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/cli/cmd" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/util" +) + +var _ = Describe("DownloadK0sCmd", func() { + var ( + c cmd.DownloadK0sCmd + opts *cmd.DownloadK0sOpts + globalOpts *cmd.GlobalOptions + mockEnv *env.MockEnv + mockFileWriter *util.MockFileIO + ) + + BeforeEach(func() { + mockEnv = env.NewMockEnv(GinkgoT()) + mockFileWriter = util.NewMockFileIO(GinkgoT()) + globalOpts = &cmd.GlobalOptions{} + opts = &cmd.DownloadK0sOpts{ + GlobalOptions: globalOpts, + Version: "", + Force: false, + Quiet: false, + } + c = cmd.DownloadK0sCmd{ + Opts: *opts, + Env: mockEnv, + FileWriter: mockFileWriter, + } + }) + + AfterEach(func() { + mockEnv.AssertExpectations(GinkgoT()) + mockFileWriter.AssertExpectations(GinkgoT()) + }) + + Context("DownloadK0s method", func() { + It("fails when k0s manager fails to get latest version", func() { + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) + + c.Opts.Version = "" // Test auto-version detection + mockK0sManager.EXPECT().GetLatestVersion().Return("", errors.New("network error")) + + err := c.DownloadK0s(mockK0sManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get latest k0s version")) + Expect(err.Error()).To(ContainSubstring("network error")) + }) + + It("fails when k0s manager fails to download", func() { + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) + + c.Opts.Version = "v1.29.1+k0s.0" + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", false, false).Return("", errors.New("download failed")) + + err := c.DownloadK0s(mockK0sManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download k0s")) + Expect(err.Error()).To(ContainSubstring("download failed")) + }) + + It("succeeds when version is specified and download works", func() { + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) + + c.Opts.Version = "v1.29.1+k0s.0" + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", false, false).Return("/test/workdir/k0s", nil) + + err := c.DownloadK0s(mockK0sManager) + Expect(err).ToNot(HaveOccurred()) + }) + + It("succeeds when version is auto-detected and download works", func() { + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) + + c.Opts.Version = "" // Test auto-version detection + c.Opts.Force = true + c.Opts.Quiet = true + mockK0sManager.EXPECT().GetLatestVersion().Return("v1.29.1+k0s.0", nil) + mockK0sManager.EXPECT().Download("v1.29.1+k0s.0", true, true).Return("/test/workdir/k0s", nil) + + err := c.DownloadK0s(mockK0sManager) + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/cli/cmd/install.go b/cli/cmd/install.go index 7402281d..07f72c31 100644 --- a/cli/cmd/install.go +++ b/cli/cmd/install.go @@ -22,5 +22,7 @@ func AddInstallCmd(rootCmd *cobra.Command, opts *GlobalOptions) { }, } rootCmd.AddCommand(install.cmd) + AddInstallCodesphereCmd(install.cmd, opts) + AddInstallK0sCmd(install.cmd, opts) } diff --git a/cli/cmd/install_k0s.go b/cli/cmd/install_k0s.go new file mode 100644 index 00000000..a5139330 --- /dev/null +++ b/cli/cmd/install_k0s.go @@ -0,0 +1,100 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + packageio "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/spf13/cobra" + + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/portal" + "github.com/codesphere-cloud/oms/internal/util" +) + +// InstallK0sCmd represents the k0s download command +type InstallK0sCmd struct { + cmd *cobra.Command + Opts InstallK0sOpts + Env env.Env + FileWriter util.FileIO +} + +type InstallK0sOpts struct { + *GlobalOptions + Version string + Package string + Config string + Force bool +} + +func (c *InstallK0sCmd) RunE(_ *cobra.Command, args []string) error { + hw := portal.NewHttpWrapper() + env := c.Env + pm := installer.NewPackage(env.GetOmsWorkdir(), c.Opts.Package) + k0s := installer.NewK0s(hw, env, c.FileWriter) + + err := c.InstallK0s(pm, k0s) + if err != nil { + return fmt.Errorf("failed to install k0s: %w", err) + } + + return nil +} + +func AddInstallK0sCmd(install *cobra.Command, opts *GlobalOptions) { + k0s := InstallK0sCmd{ + cmd: &cobra.Command{ + Use: "k0s", + Short: "Install k0s Kubernetes distribution", + Long: packageio.Long(`Install k0s either from the package or by downloading it. + This will either download the k0s binary directly to the OMS workdir, if not already present, and install it + or load the k0s binary from the provided package file and install it. + If no version is specified, the latest version will be downloaded. + If no install config is provided, k0s will be installed with the '--single' flag.`), + Example: formatExamplesWithBinary("install k0s", []packageio.Example{ + {Cmd: "", Desc: "Install k0s using the Go-native implementation"}, + {Cmd: "--version ", Desc: "Version of k0s to install"}, + {Cmd: "--package ", Desc: "Package file (e.g. codesphere-v1.2.3-installer.tar.gz) to load k0s from"}, + {Cmd: "--k0s-config ", Desc: "Path to k0s configuration file, if not set k0s will be installed with the '--single' flag"}, + {Cmd: "--force", Desc: "Force new download and installation even if k0s binary exists or is already installed"}, + }, "oms-cli"), + }, + Opts: InstallK0sOpts{GlobalOptions: opts}, + Env: env.NewEnv(), + FileWriter: util.NewFilesystemWriter(), + } + k0s.cmd.Flags().StringVarP(&k0s.Opts.Version, "version", "v", "", "Version of k0s to install") + k0s.cmd.Flags().StringVarP(&k0s.Opts.Package, "package", "p", "", "Package file (e.g. codesphere-v1.2.3-installer.tar.gz) to load k0s from") + k0s.cmd.Flags().StringVar(&k0s.Opts.Config, "k0s-config", "", "Path to k0s configuration file") + k0s.cmd.Flags().BoolVarP(&k0s.Opts.Force, "force", "f", false, "Force new download and installation") + + install.AddCommand(k0s.cmd) + + k0s.cmd.RunE = k0s.RunE +} + +const defaultK0sPath = "kubernetes/files/k0s" + +func (c *InstallK0sCmd) InstallK0s(pm installer.PackageManager, k0s installer.K0sManager) error { + // Default dependency path for k0s binary within package + k0sPath := pm.GetDependencyPath(defaultK0sPath) + + var err error + if c.Opts.Package == "" { + k0sPath, err = k0s.Download(c.Opts.Version, c.Opts.Force, false) + if err != nil { + return fmt.Errorf("failed to download k0s: %w", err) + } + } + + err = k0s.Install(c.Opts.Config, k0sPath, c.Opts.Force) + if err != nil { + return fmt.Errorf("failed to install k0s: %w", err) + } + + return nil +} diff --git a/cli/cmd/install_k0s_test.go b/cli/cmd/install_k0s_test.go new file mode 100644 index 00000000..8c59b542 --- /dev/null +++ b/cli/cmd/install_k0s_test.go @@ -0,0 +1,109 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/cli/cmd" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/util" +) + +var _ = Describe("InstallK0sCmd", func() { + var ( + c cmd.InstallK0sCmd + opts *cmd.InstallK0sOpts + globalOpts *cmd.GlobalOptions + mockEnv *env.MockEnv + mockFileWriter *util.MockFileIO + ) + + BeforeEach(func() { + mockEnv = env.NewMockEnv(GinkgoT()) + mockFileWriter = util.NewMockFileIO(GinkgoT()) + globalOpts = &cmd.GlobalOptions{} + opts = &cmd.InstallK0sOpts{ + GlobalOptions: globalOpts, + Version: "", + Package: "", + Config: "", + Force: false, + } + c = cmd.InstallK0sCmd{ + Opts: *opts, + Env: mockEnv, + FileWriter: mockFileWriter, + } + }) + + AfterEach(func() { + mockEnv.AssertExpectations(GinkgoT()) + mockFileWriter.AssertExpectations(GinkgoT()) + }) + + Context("InstallK0s method", func() { + It("fails when package is not specified and k0s download fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) + + c.Opts.Package = "" // No package specified, should download + mockPackageManager.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/workdir/test-package/deps/kubernetes/files/k0s") + mockK0sManager.EXPECT().Download("", false, false).Return("", errors.New("download failed")) + + err := c.InstallK0s(mockPackageManager, mockK0sManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download k0s")) + Expect(err.Error()).To(ContainSubstring("download failed")) + }) + + It("fails when k0s install fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) + + c.Opts.Package = "" // No package specified, should download + c.Opts.Config = "/path/to/config.yaml" + c.Opts.Force = true + mockPackageManager.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/workdir/test-package/deps/kubernetes/files/k0s") + mockK0sManager.EXPECT().Download("", true, false).Return("/test/workdir/k0s", nil) + mockK0sManager.EXPECT().Install("/path/to/config.yaml", "/test/workdir/k0s", true).Return(errors.New("install failed")) + + err := c.InstallK0s(mockPackageManager, mockK0sManager) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install k0s")) + Expect(err.Error()).To(ContainSubstring("install failed")) + }) + + It("succeeds when package is not specified and k0s download and install work", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) + + c.Opts.Package = "" // No package specified, should download + c.Opts.Config = "" // No config, will use single mode + mockPackageManager.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/workdir/test-package/deps/kubernetes/files/k0s") + mockK0sManager.EXPECT().Download("", false, false).Return("/test/workdir/k0s", nil) + mockK0sManager.EXPECT().Install("", "/test/workdir/k0s", false).Return(nil) + + err := c.InstallK0s(mockPackageManager, mockK0sManager) + Expect(err).ToNot(HaveOccurred()) + }) + + It("succeeds when package is specified and k0s install works", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) + + c.Opts.Package = "test-package.tar.gz" // Package specified, should use k0s from package + c.Opts.Config = "/path/to/config.yaml" + mockPackageManager.EXPECT().GetDependencyPath("kubernetes/files/k0s").Return("/test/workdir/test-package/deps/kubernetes/files/k0s") + mockK0sManager.EXPECT().Install("/path/to/config.yaml", "/test/workdir/test-package/deps/kubernetes/files/k0s", false).Return(nil) + + err := c.InstallK0s(mockPackageManager, mockK0sManager) + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/docs/oms-cli_download.md b/docs/oms-cli_download.md index fff85d97..236f45ff 100644 --- a/docs/oms-cli_download.md +++ b/docs/oms-cli_download.md @@ -16,5 +16,6 @@ e.g. available Codesphere packages ### SEE ALSO * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) +* [oms-cli download k0s](oms-cli_download_k0s.md) - Download k0s Kubernetes distribution * [oms-cli download package](oms-cli_download_package.md) - Download a codesphere package diff --git a/docs/oms-cli_download_k0s.md b/docs/oms-cli_download_k0s.md new file mode 100644 index 00000000..2473948c --- /dev/null +++ b/docs/oms-cli_download_k0s.md @@ -0,0 +1,43 @@ +## oms-cli download k0s + +Download k0s Kubernetes distribution + +### Synopsis + +Download a k0s binary directly to the OMS workdir. +Will download the latest version if no version is specified. + +``` +oms-cli download k0s [flags] +``` + +### Examples + +``` +# Download k0s using the Go-native implementation +$ oms-cli download k0s + +# Download a specific version of k0s +$ oms-cli download k0s --version 1.22.0 + +# Download k0s with minimal output +$ oms-cli download k0s --quiet + +# Force download even if k0s binary exists +$ oms-cli download k0s --force + +``` + +### Options + +``` + -f, --force Force download even if k0s binary exists + -h, --help help for k0s + -q, --quiet Suppress progress output during download + -v, --version string Version of k0s to download +``` + +### SEE ALSO + +* [oms-cli download](oms-cli_download.md) - Download resources available through OMS + diff --git a/docs/oms-cli_install.md b/docs/oms-cli_install.md index f63765b8..89ee5f30 100644 --- a/docs/oms-cli_install.md +++ b/docs/oms-cli_install.md @@ -16,4 +16,5 @@ Coming soon: Install Codesphere and other components like Ceph and PostgreSQL. * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) * [oms-cli install codesphere](oms-cli_install_codesphere.md) - Install a Codesphere instance +* [oms-cli install k0s](oms-cli_install_k0s.md) - Install k0s Kubernetes distribution diff --git a/docs/oms-cli_install_k0s.md b/docs/oms-cli_install_k0s.md new file mode 100644 index 00000000..e9c7fa06 --- /dev/null +++ b/docs/oms-cli_install_k0s.md @@ -0,0 +1,50 @@ +## oms-cli install k0s + +Install k0s Kubernetes distribution + +### Synopsis + +Install k0s either from the package or by downloading it. +This will either download the k0s binary directly to the OMS workdir, if not already present, and install it +or load the k0s binary from the provided package file and install it. +If no version is specified, the latest version will be downloaded. +If no install config is provided, k0s will be installed with the '--single' flag. + +``` +oms-cli install k0s [flags] +``` + +### Examples + +``` +# Install k0s using the Go-native implementation +$ oms-cli install k0s + +# Version of k0s to install +$ oms-cli install k0s --version + +# Package file (e.g. codesphere-v1.2.3-installer.tar.gz) to load k0s from +$ oms-cli install k0s --package + +# Path to k0s configuration file, if not set k0s will be installed with the '--single' flag +$ oms-cli install k0s --k0s-config + +# Force new download and installation even if k0s binary exists or is already installed +$ oms-cli install k0s --force + +``` + +### Options + +``` + -f, --force Force new download and installation + -h, --help help for k0s + --k0s-config string Path to k0s configuration file + -p, --package string Package file (e.g. codesphere-v1.2.3-installer.tar.gz) to load k0s from + -v, --version string Version of k0s to install +``` + +### SEE ALSO + +* [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components + diff --git a/go.mod b/go.mod index 997097ce..1855b4eb 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/rhysd/go-github-selfupdate v1.2.3 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -36,7 +37,6 @@ require ( github.com/tcnksm/go-gitconfig v0.1.2 // indirect github.com/ulikunitz/xz v0.5.15 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.42.0 // indirect golang.org/x/mod v0.28.0 // indirect golang.org/x/net v0.44.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect diff --git a/go.sum b/go.sum index 076aa78a..ccc37944 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codesphere-cloud/cs-go v0.13.0 h1:UlClugSlTtFeEpEVZSbQdegblVZnrKlYyjX+51PYQ1w= -github.com/codesphere-cloud/cs-go v0.13.0/go.mod h1:Wj8rWS0VcXEBS6+58PwggDe6ZTz06gpJbbioLZc+uHo= github.com/codesphere-cloud/cs-go v0.14.0 h1:JeCYb56aMGO0RvtaY/4s7qmtBBY0uZ7QHKh/3EUUHug= github.com/codesphere-cloud/cs-go v0.14.0/go.mod h1:6nDPrm0EsLc3K5XDhVKhhxT/S0G/hKCbYrMvq+cIbm0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -43,10 +41,6 @@ github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7V github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc= -github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= -github.com/jedib0t/go-pretty/v6 v6.6.9 h1:PQecJLK3L8ODuVyMe2223b61oRJjrKnmXAncbWTv9MY= -github.com/jedib0t/go-pretty/v6 v6.6.9/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/jedib0t/go-pretty/v6 v6.7.1 h1:bHDSsj93NuJ563hHuM7ohk/wpX7BmRFNIsVv1ssI2/M= github.com/jedib0t/go-pretty/v6 v6.7.1/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -128,6 +122,8 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go new file mode 100644 index 00000000..1c350b37 --- /dev/null +++ b/internal/installer/k0s.go @@ -0,0 +1,130 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer + +import ( + "fmt" + "log" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/portal" + "github.com/codesphere-cloud/oms/internal/util" +) + +type K0sManager interface { + GetLatestVersion() (string, error) + Download(version string, force bool, quiet bool) (string, error) + Install(configPath string, k0sPath string, force bool) error +} + +type K0s struct { + Env env.Env + Http portal.Http + FileWriter util.FileIO + Goos string + Goarch string +} + +func NewK0s(hw portal.Http, env env.Env, fw util.FileIO) K0sManager { + return &K0s{ + Env: env, + Http: hw, + FileWriter: fw, + Goos: runtime.GOOS, + Goarch: runtime.GOARCH, + } +} + +func (k *K0s) GetLatestVersion() (string, error) { + versionBytes, err := k.Http.Get("https://docs.k0sproject.io/stable.txt") + if err != nil { + return "", fmt.Errorf("failed to fetch version info: %w", err) + } + + version := strings.TrimSpace(string(versionBytes)) + if version == "" { + return "", fmt.Errorf("version info is empty, cannot proceed with download") + } + + return version, nil +} + +// Download downloads the k0s binary for the specified version and saves it to the OMS workdir. +func (k *K0s) Download(version string, force bool, quiet bool) (string, error) { + if k.Goos != "linux" || k.Goarch != "amd64" { + return "", fmt.Errorf("codesphere installation is only supported on Linux amd64. Current platform: %s/%s", k.Goos, k.Goarch) + } + + // Check if k0s binary already exists and create destination file + workdir := k.Env.GetOmsWorkdir() + k0sPath := filepath.Join(workdir, "k0s") + if k.FileWriter.Exists(k0sPath) && !force { + return "", fmt.Errorf("k0s binary already exists at %s. Use --force to overwrite", k0sPath) + } + + file, err := k.FileWriter.Create(k0sPath) + if err != nil { + return "", fmt.Errorf("failed to create k0s binary file: %w", err) + } + defer util.CloseFileIgnoreError(file) + + // Download using the portal Http wrapper with WriteCounter + log.Printf("Downloading k0s version %s", version) + + downloadURL := fmt.Sprintf("https://github.com/k0sproject/k0s/releases/download/%s/k0s-%s-%s", version, version, k.Goarch) + err = k.Http.Download(downloadURL, file, quiet) + if err != nil { + return "", fmt.Errorf("failed to download k0s binary: %w", err) + } + + // Make the binary executable + err = os.Chmod(k0sPath, 0755) + if err != nil { + return "", fmt.Errorf("failed to make k0s binary executable: %w", err) + } + + log.Printf("k0s binary downloaded and made executable at '%s'", k0sPath) + + return k0sPath, nil +} + +func (k *K0s) Install(configPath string, k0sPath string, force bool) error { + if k.Goos != "linux" || k.Goarch != "amd64" { + return fmt.Errorf("k0s installation is only supported on Linux amd64. Current platform: %s/%s", k.Goos, k.Goarch) + } + + if !k.FileWriter.Exists(k0sPath) { + return fmt.Errorf("k0s binary does not exist in '%s', please download first", k0sPath) + } + + args := []string{k0sPath, "install", "controller"} + if configPath != "" { + args = append(args, "--config", configPath) + } else { + args = append(args, "--single") + } + + if force { + args = append(args, "--force") + } + + err := util.RunCommand("sudo", args, "") + if err != nil { + return fmt.Errorf("failed to install k0s: %w", err) + } + + if configPath != "" { + log.Println("k0s installed successfully with provided configuration.") + } else { + log.Println("k0s installed successfully in single-node mode.") + } + log.Printf("You can start it using 'sudo %v start'", k0sPath) + log.Printf("You can check the status using 'sudo %v status'", k0sPath) + + return nil +} diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go new file mode 100644 index 00000000..83b9109f --- /dev/null +++ b/internal/installer/k0s_test.go @@ -0,0 +1,324 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package installer_test + +import ( + "errors" + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer" + "github.com/codesphere-cloud/oms/internal/portal" + "github.com/codesphere-cloud/oms/internal/util" +) + +var _ = Describe("K0s", func() { + var ( + k0s installer.K0sManager + k0sImpl *installer.K0s + mockEnv *env.MockEnv + mockHttp *portal.MockHttp + mockFileWriter *util.MockFileIO + tempDir string + workDir string + k0sPath string + ) + + BeforeEach(func() { + mockEnv = env.NewMockEnv(GinkgoT()) + mockHttp = portal.NewMockHttp(GinkgoT()) + mockFileWriter = util.NewMockFileIO(GinkgoT()) + + tempDir = GinkgoT().TempDir() + workDir = filepath.Join(tempDir, "oms-workdir") + k0sPath = filepath.Join(workDir, "k0s") + + k0s = installer.NewK0s(mockHttp, mockEnv, mockFileWriter) + k0sImpl = k0s.(*installer.K0s) + }) + + Describe("NewK0s", func() { + It("creates a new K0s with correct parameters", func() { + newK0s := installer.NewK0s(mockHttp, mockEnv, mockFileWriter) + Expect(newK0s).ToNot(BeNil()) + + // Type assertion to access fields + k0sStruct := newK0s.(*installer.K0s) + Expect(k0sStruct.Http).To(Equal(mockHttp)) + Expect(k0sStruct.Env).To(Equal(mockEnv)) + Expect(k0sStruct.FileWriter).To(Equal(mockFileWriter)) + Expect(k0sStruct.Goos).ToNot(BeEmpty()) + Expect(k0sStruct.Goarch).ToNot(BeEmpty()) + }) + + It("implements K0sManager interface", func() { + var manager = installer.NewK0s(mockHttp, mockEnv, mockFileWriter) + Expect(manager).ToNot(BeNil()) + }) + }) + + Describe("GetLatestVersion", func() { + Context("when version fetch succeeds", func() { + It("returns the latest version", func() { + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return([]byte("v1.29.1+k0s.0"), nil) + + version, err := k0s.GetLatestVersion() + Expect(err).ToNot(HaveOccurred()) + Expect(version).To(Equal("v1.29.1+k0s.0")) + }) + }) + + Context("when version fetch fails", func() { + It("returns an error", func() { + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(nil, errors.New("network error")) + + _, err := k0s.GetLatestVersion() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to fetch version info")) + Expect(err.Error()).To(ContainSubstring("network error")) + }) + }) + + Context("when version is empty", func() { + It("returns an error", func() { + emptyVersionBytes := []byte(" \n ") + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(emptyVersionBytes, nil) + + _, err := k0s.GetLatestVersion() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("version info is empty")) + }) + }) + + Context("when version has whitespace", func() { + It("trims whitespace correctly", func() { + versionWithWhitespace := []byte(" v1.29.1+k0s.0 \n") + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(versionWithWhitespace, nil) + + version, err := k0s.GetLatestVersion() + Expect(err).ToNot(HaveOccurred()) + Expect(version).To(Equal("v1.29.1+k0s.0")) + }) + }) + }) + + Describe("Download", func() { + Context("Platform support", func() { + It("should fail on non-Linux platforms", func() { + k0sImpl.Goos = "windows" + k0sImpl.Goarch = "amd64" + + _, err := k0s.Download("v1.29.1+k0s.0", false, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) + Expect(err.Error()).To(ContainSubstring("windows/amd64")) + }) + + It("should fail on non-amd64 architectures", func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "arm64" + + _, err := k0s.Download("v1.29.1+k0s.0", false, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) + Expect(err.Error()).To(ContainSubstring("linux/arm64")) + }) + }) + + Context("Version fetching", func() { + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + }) + + It("should handle version parameter correctly", func() { + mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) + mockFileWriter.EXPECT().Exists(k0sPath).Return(false) + + // Create the workdir first + err := os.MkdirAll(workDir, 0755) + Expect(err).ToNot(HaveOccurred()) + + // Create a real file for the test + realFile, err := os.Create(k0sPath) + Expect(err).ToNot(HaveOccurred()) + defer util.CloseFileIgnoreError(realFile) + + mockFileWriter.EXPECT().Create(k0sPath).Return(realFile, nil) + mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", realFile, false).Return(nil) + + path, err := k0s.Download("v1.29.1+k0s.0", false, false) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(k0sPath)) + }) + }) + + Context("File existence checks", func() { + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) + }) + + It("should fail when k0s binary exists and force is false", func() { + mockFileWriter.EXPECT().Exists(k0sPath).Return(true) + + _, err := k0s.Download("v1.29.1+k0s.0", false, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("k0s binary already exists")) + Expect(err.Error()).To(ContainSubstring("Use --force to overwrite")) + }) + + It("should proceed when k0s binary exists and force is true", func() { + mockFileWriter.EXPECT().Exists(k0sPath).Return(true) + + // Create the workdir first + err := os.MkdirAll(workDir, 0755) + Expect(err).ToNot(HaveOccurred()) + + // Create a real file for the test + realFile, err := os.Create(k0sPath) + Expect(err).ToNot(HaveOccurred()) + defer util.CloseFileIgnoreError(realFile) + + mockFileWriter.EXPECT().Create(k0sPath).Return(realFile, nil) + mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", realFile, false).Return(nil) + + path, err := k0s.Download("v1.29.1+k0s.0", true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(k0sPath)) + }) + }) + + Context("File operations", func() { + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) + mockFileWriter.EXPECT().Exists(k0sPath).Return(false) + }) + + It("should fail when file creation fails", func() { + mockFileWriter.EXPECT().Create(k0sPath).Return(nil, errors.New("permission denied")) + + _, err := k0s.Download("v1.29.1+k0s.0", false, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create k0s binary file")) + Expect(err.Error()).To(ContainSubstring("permission denied")) + }) + + It("should fail when download fails", func() { + // Create a mock file for the test + mockFile, err := os.CreateTemp("", "k0s-test") + Expect(err).ToNot(HaveOccurred()) + defer func() { + _ = os.Remove(mockFile.Name()) + }() + defer util.CloseFileIgnoreError(mockFile) + + mockFileWriter.EXPECT().Create(k0sPath).Return(mockFile, nil) + mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", mockFile, false).Return(errors.New("download failed")) + + _, err = k0s.Download("v1.29.1+k0s.0", false, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download k0s binary")) + Expect(err.Error()).To(ContainSubstring("download failed")) + }) + + It("should succeed with default options", func() { + // Create a real file in temp directory for os.Chmod to work + err := os.MkdirAll(workDir, 0755) + Expect(err).ToNot(HaveOccurred()) + + realFile, err := os.Create(k0sPath) + Expect(err).ToNot(HaveOccurred()) + defer util.CloseFileIgnoreError(realFile) + + mockFileWriter.EXPECT().Create(k0sPath).Return(realFile, nil) + mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", realFile, false).Return(nil) + + path, err := k0s.Download("v1.29.1+k0s.0", false, false) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(k0sPath)) + + // Verify file was made executable + info, err := os.Stat(k0sPath) + Expect(err).ToNot(HaveOccurred()) + Expect(info.Mode() & 0755).To(Equal(os.FileMode(0755))) + }) + }) + + Context("URL construction", func() { + BeforeEach(func() { + k0sImpl.Goos = "linux" + mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) + mockFileWriter.EXPECT().Exists(k0sPath).Return(false) + }) + + It("should construct correct download URL for amd64", func() { + k0sImpl.Goarch = "amd64" + + // Create the workdir first + err := os.MkdirAll(workDir, 0755) + Expect(err).ToNot(HaveOccurred()) + + // Create a real file for the test + realFile, err := os.Create(k0sPath) + Expect(err).ToNot(HaveOccurred()) + defer util.CloseFileIgnoreError(realFile) + + mockFileWriter.EXPECT().Create(k0sPath).Return(realFile, nil) + mockHttp.EXPECT().Download("https://github.com/k0sproject/k0s/releases/download/v1.29.1+k0s.0/k0s-v1.29.1+k0s.0-amd64", realFile, false).Return(nil) + + path, err := k0s.Download("v1.29.1+k0s.0", false, false) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(k0sPath)) + }) + }) + }) + + Describe("Install", func() { + Context("Platform support", func() { + It("should fail on non-Linux platforms", func() { + k0sImpl.Goos = "windows" + k0sImpl.Goarch = "amd64" + + err := k0s.Install("", k0sPath, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("k0s installation is only supported on Linux amd64")) + Expect(err.Error()).To(ContainSubstring("windows/amd64")) + }) + + It("should fail on non-amd64 architectures", func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "arm64" + + err := k0s.Install("", k0sPath, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("k0s installation is only supported on Linux amd64")) + Expect(err.Error()).To(ContainSubstring("linux/arm64")) + }) + }) + + Context("Binary existence checks", func() { + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + }) + + It("should fail when k0s binary doesn't exist", func() { + mockFileWriter.EXPECT().Exists(k0sPath).Return(false) + + err := k0s.Install("", k0sPath, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("k0s binary does not exist")) + Expect(err.Error()).To(ContainSubstring("please download first")) + }) + }) + }) +}) diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index a9adf6e7..ccb2b6a9 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -571,6 +571,189 @@ func (_c *MockInstallConfigManager_WriteVault_Call) RunAndReturn(run func(vaultP return _c } +// NewMockK0sManager creates a new instance of MockK0sManager. 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 NewMockK0sManager(t interface { + mock.TestingT + Cleanup(func()) +}) *MockK0sManager { + mock := &MockK0sManager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockK0sManager is an autogenerated mock type for the K0sManager type +type MockK0sManager struct { + mock.Mock +} + +type MockK0sManager_Expecter struct { + mock *mock.Mock +} + +func (_m *MockK0sManager) EXPECT() *MockK0sManager_Expecter { + return &MockK0sManager_Expecter{mock: &_m.Mock} +} + +// Download provides a mock function for the type MockK0sManager +func (_mock *MockK0sManager) Download(version string, force bool, quiet bool) (string, error) { + ret := _mock.Called(version, force, quiet) + + if len(ret) == 0 { + panic("no return value specified for Download") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, bool, bool) (string, error)); ok { + return returnFunc(version, force, quiet) + } + if returnFunc, ok := ret.Get(0).(func(string, bool, bool) string); ok { + r0 = returnFunc(version, force, quiet) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(string, bool, bool) error); ok { + r1 = returnFunc(version, force, quiet) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockK0sManager_Download_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Download' +type MockK0sManager_Download_Call struct { + *mock.Call +} + +// Download is a helper method to define mock.On call +// - version +// - force +// - quiet +func (_e *MockK0sManager_Expecter) Download(version interface{}, force interface{}, quiet interface{}) *MockK0sManager_Download_Call { + return &MockK0sManager_Download_Call{Call: _e.mock.On("Download", version, force, quiet)} +} + +func (_c *MockK0sManager_Download_Call) Run(run func(version string, force bool, quiet bool)) *MockK0sManager_Download_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(bool), args[2].(bool)) + }) + return _c +} + +func (_c *MockK0sManager_Download_Call) Return(s string, err error) *MockK0sManager_Download_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockK0sManager_Download_Call) RunAndReturn(run func(version string, force bool, quiet bool) (string, error)) *MockK0sManager_Download_Call { + _c.Call.Return(run) + return _c +} + +// GetLatestVersion provides a mock function for the type MockK0sManager +func (_mock *MockK0sManager) GetLatestVersion() (string, error) { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetLatestVersion") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func() (string, error)); ok { + return returnFunc() + } + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func() error); ok { + r1 = returnFunc() + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockK0sManager_GetLatestVersion_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLatestVersion' +type MockK0sManager_GetLatestVersion_Call struct { + *mock.Call +} + +// GetLatestVersion is a helper method to define mock.On call +func (_e *MockK0sManager_Expecter) GetLatestVersion() *MockK0sManager_GetLatestVersion_Call { + return &MockK0sManager_GetLatestVersion_Call{Call: _e.mock.On("GetLatestVersion")} +} + +func (_c *MockK0sManager_GetLatestVersion_Call) Run(run func()) *MockK0sManager_GetLatestVersion_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockK0sManager_GetLatestVersion_Call) Return(s string, err error) *MockK0sManager_GetLatestVersion_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockK0sManager_GetLatestVersion_Call) RunAndReturn(run func() (string, error)) *MockK0sManager_GetLatestVersion_Call { + _c.Call.Return(run) + return _c +} + +// Install provides a mock function for the type MockK0sManager +func (_mock *MockK0sManager) Install(configPath string, k0sPath string, force bool) error { + ret := _mock.Called(configPath, k0sPath, force) + + if len(ret) == 0 { + panic("no return value specified for Install") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, string, bool) error); ok { + r0 = returnFunc(configPath, k0sPath, force) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockK0sManager_Install_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Install' +type MockK0sManager_Install_Call struct { + *mock.Call +} + +// Install is a helper method to define mock.On call +// - configPath +// - k0sPath +// - force +func (_e *MockK0sManager_Expecter) Install(configPath interface{}, k0sPath interface{}, force interface{}) *MockK0sManager_Install_Call { + return &MockK0sManager_Install_Call{Call: _e.mock.On("Install", configPath, k0sPath, force)} +} + +func (_c *MockK0sManager_Install_Call) Run(run func(configPath string, k0sPath string, force bool)) *MockK0sManager_Install_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string), args[2].(bool)) + }) + return _c +} + +func (_c *MockK0sManager_Install_Call) Return(err error) *MockK0sManager_Install_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockK0sManager_Install_Call) RunAndReturn(run func(configPath string, k0sPath string, force bool) error) *MockK0sManager_Install_Call { + _c.Call.Return(run) + return _c +} + // NewMockPackageManager creates a new instance of MockPackageManager. 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 NewMockPackageManager(t interface { diff --git a/internal/installer/package.go b/internal/installer/package.go index bd671214..89eac240 100644 --- a/internal/installer/package.go +++ b/internal/installer/package.go @@ -16,6 +16,7 @@ import ( ) const depsDir = "deps" +const depsTar = "deps.tar.gz" type PackageManager interface { FileIO() util.FileIO @@ -35,7 +36,7 @@ type Package struct { fileIO util.FileIO } -func NewPackage(omsWorkdir, filename string) *Package { +func NewPackage(omsWorkdir, filename string) PackageManager { return &Package{ Filename: filename, OmsWorkdir: omsWorkdir, @@ -95,6 +96,15 @@ func (p *Package) Extract(force bool) error { return fmt.Errorf("failed to extract package %s to %s: %w", p.Filename, workDir, err) } + depsArchivePath := path.Join(workDir, depsTar) + if p.fileIO.Exists(depsArchivePath) { + depsTargetDir := path.Join(workDir, depsDir) + err = util.ExtractTarGz(p.fileIO, depsArchivePath, depsTargetDir) + if err != nil { + return fmt.Errorf("failed to extract deps.tar.gz to %s: %w", depsTargetDir, err) + } + } + return nil } diff --git a/internal/installer/package_test.go b/internal/installer/package_test.go index e199be90..daeef343 100644 --- a/internal/installer/package_test.go +++ b/internal/installer/package_test.go @@ -28,12 +28,12 @@ var _ = Describe("Package", func() { tempDir = GinkgoT().TempDir() omsWorkdir = filepath.Join(tempDir, "oms-workdir") filename = "test-package.tar.gz" - pkg = installer.NewPackage(omsWorkdir, filename) + pkg = installer.NewPackage(omsWorkdir, filename).(*installer.Package) }) Describe("NewPackage", func() { It("creates a new Package with correct parameters", func() { - newPkg := installer.NewPackage("/test/workdir", "package.tar.gz") + newPkg := installer.NewPackage("/test/workdir", "package.tar.gz").(*installer.Package) Expect(newPkg).ToNot(BeNil()) Expect(newPkg.OmsWorkdir).To(Equal("/test/workdir")) Expect(newPkg.Filename).To(Equal("package.tar.gz")) @@ -61,18 +61,6 @@ var _ = Describe("Package", func() { expected := filepath.Join(omsWorkdir, "my-package") Expect(pkg.GetWorkDir()).To(Equal(expected)) }) - - It("handles filename without .tar.gz extension", func() { - pkg.Filename = "my-package" - expected := filepath.Join(omsWorkdir, "my-package") - Expect(pkg.GetWorkDir()).To(Equal(expected)) - }) - - It("handles complex filenames", func() { - pkg.Filename = "complex-package-v1.2.3.tar.gz" - expected := filepath.Join(omsWorkdir, "complex-package-v1.2.3") - Expect(pkg.GetWorkDir()).To(Equal(expected)) - }) }) Describe("GetDependencyPath", func() { @@ -89,13 +77,6 @@ var _ = Describe("Package", func() { expected := filepath.Join(workDir, "deps", filename) Expect(pkg.GetDependencyPath(filename)).To(Equal(expected)) }) - - It("handles empty filename", func() { - filename := "" - workDir := pkg.GetWorkDir() - expected := filepath.Join(workDir, "deps", filename) - Expect(pkg.GetDependencyPath(filename)).To(Equal(expected)) - }) }) Describe("Extract", func() { @@ -245,145 +226,6 @@ var _ = Describe("Package", func() { }) }) }) - - Describe("PackageManager interface", func() { - It("implements PackageManager interface", func() { - var packageManager installer.PackageManager = pkg - Expect(packageManager).ToNot(BeNil()) - }) - - It("has all required methods", func() { - var packageManager installer.PackageManager = pkg - - // Test that methods exist by calling them - fileIO := packageManager.FileIO() - Expect(fileIO).ToNot(BeNil()) - - workDir := packageManager.GetWorkDir() - Expect(workDir).ToNot(BeEmpty()) - - depPath := packageManager.GetDependencyPath("test.txt") - Expect(depPath).ToNot(BeEmpty()) - - // Extract methods would need actual files to test properly - // These are tested in the method-specific sections above - }) - }) - - Describe("Error handling and edge cases", func() { - Context("Extract with various scenarios", func() { - It("handles empty filename gracefully", func() { - pkg.Filename = "" - _ = pkg.Extract(false) - // Note: Empty filename may not always cause an error at Extract level - // The error might occur later during actual file operations - // This test verifies the behavior is predictable - }) - - It("handles empty workdir", func() { - pkg.OmsWorkdir = "" - packagePath := filepath.Join(tempDir, filename) - err := createTestPackage(packagePath, PackageFiles{ - MainFiles: map[string]string{ - "test-file.txt": "test content", - }, - }) - Expect(err).ToNot(HaveOccurred()) - pkg.Filename = packagePath - - // Empty workdir should cause an error when trying to create directories - err = pkg.Extract(false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to ensure workdir exists")) - }) - }) - - Context("ExtractDependency with various scenarios", func() { - It("handles empty dependency filename", func() { - packagePath := filepath.Join(tempDir, filename) - err := createTestPackage(packagePath, PackageFiles{ - MainFiles: map[string]string{ - "main-file.txt": "main package content", - }, - DepsFiles: map[string]string{ - "test-dep.txt": "dependency content", - }, - }) - Expect(err).ToNot(HaveOccurred()) - pkg.Filename = packagePath - - // Empty dependency filename may succeed (extracts everything) or fail - // depending on the underlying tar extraction implementation - _ = pkg.ExtractDependency("", false) - // This test verifies the behavior is predictable - }) - }) - - Context("Path handling edge cases", func() { - It("handles special characters in filenames", func() { - pkg.Filename = "test-package-with-special-chars_v1.0.tar.gz" - expected := filepath.Join(omsWorkdir, "test-package-with-special-chars_v1.0") - Expect(pkg.GetWorkDir()).To(Equal(expected)) - }) - - It("handles multiple .tar.gz occurrences in filename", func() { - pkg.Filename = "package.tar.gz.backup.tar.gz" - expected := filepath.Join(omsWorkdir, "package.backup") - Expect(pkg.GetWorkDir()).To(Equal(expected)) - }) - }) - }) - - Describe("Integration scenarios", func() { - Context("full workflow simulation", func() { - var packagePath string - - BeforeEach(func() { - packagePath = filepath.Join(tempDir, "complete-package.tar.gz") - err := createTestPackage(packagePath, PackageFiles{ - MainFiles: map[string]string{ - "main-content.txt": "complex main package content", - }, - DepsFiles: map[string]string{ - "dep1.txt": "dependency 1 content", - "dep2.txt": "dependency 2 content", - "subdep/dep3.txt": "sub dependency 3 content", - }, - }) - Expect(err).ToNot(HaveOccurred()) - pkg.Filename = packagePath - }) - - It("can extract package and multiple dependencies successfully", func() { - // Extract main package - err := pkg.Extract(false) - Expect(err).ToNot(HaveOccurred()) - - // Verify main package content - workDir := pkg.GetWorkDir() - Expect(workDir).To(BeADirectory()) - mainFile := filepath.Join(workDir, "main-content.txt") - Expect(mainFile).To(BeAnExistingFile()) - - // Extract multiple dependencies - dependencies := []string{"dep1.txt", "dep2.txt", "subdep/dep3.txt"} - for _, dep := range dependencies { - err = pkg.ExtractDependency(dep, false) - Expect(err).ToNot(HaveOccurred()) - - depPath := pkg.GetDependencyPath(dep) - Expect(depPath).To(BeAnExistingFile()) - } - - // Verify all paths are correct - for _, dep := range dependencies { - depPath := pkg.GetDependencyPath(dep) - expectedPath := filepath.Join(workDir, "deps", dep) - Expect(depPath).To(Equal(expectedPath)) - } - }) - }) - }) }) // Tests for ExtractOciImageIndex (moved from config_test.go) @@ -395,7 +237,7 @@ var _ = Describe("Package ExtractOciImageIndex", func() { BeforeEach(func() { tempDir = GinkgoT().TempDir() - pkg = installer.NewPackage(tempDir, "test-package.tar.gz") + pkg = installer.NewPackage(tempDir, "test-package.tar.gz").(*installer.Package) }) Describe("ExtractOciImageIndex", func() { @@ -496,24 +338,6 @@ var _ = Describe("Package ExtractOciImageIndex", func() { }) }) }) - - Context("additional edge cases", func() { - It("handles empty image file path", func() { - _, err := pkg.ExtractOciImageIndex("") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to extract index.json")) - }) - - It("handles directory instead of file", func() { - dirPath := filepath.Join(tempDir, "not-a-file") - err := os.Mkdir(dirPath, 0755) - Expect(err).ToNot(HaveOccurred()) - - _, err = pkg.ExtractOciImageIndex(dirPath) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to extract index.json")) - }) - }) }) }) @@ -527,7 +351,7 @@ var _ = Describe("Package GetBaseimageName", func() { BeforeEach(func() { tempDir = GinkgoT().TempDir() omsWorkdir := filepath.Join(tempDir, "oms-workdir") - pkg = installer.NewPackage(omsWorkdir, "test-package.tar.gz") + pkg = installer.NewPackage(omsWorkdir, "test-package.tar.gz").(*installer.Package) }) Describe("GetBaseimageName", func() { @@ -548,7 +372,7 @@ var _ = Describe("Package GetBaseimageName", func() { }) Context("when bom.json exists but is invalid", func() { - BeforeEach(func() { + It("returns an error", func() { // Create invalid bom.json workDir := pkg.GetWorkDir() err := os.MkdirAll(filepath.Join(workDir, "deps"), 0755) @@ -557,17 +381,15 @@ var _ = Describe("Package GetBaseimageName", func() { bomPath := pkg.GetDependencyPath("bom.json") err = os.WriteFile(bomPath, []byte("invalid json"), 0644) Expect(err).NotTo(HaveOccurred()) - }) - It("returns an error", func() { - _, err := pkg.GetBaseimageName("workspace-agent-24.04") + _, err = pkg.GetBaseimageName("workspace-agent-24.04") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to load bom.json")) }) }) Context("when bom.json exists but codesphere component is missing", func() { - BeforeEach(func() { + It("returns an error", func() { // Create bom.json without codesphere component workDir := pkg.GetWorkDir() err := os.MkdirAll(filepath.Join(workDir, "deps"), 0755) @@ -583,17 +405,15 @@ var _ = Describe("Package GetBaseimageName", func() { bomPath := pkg.GetDependencyPath("bom.json") err = os.WriteFile(bomPath, []byte(bomContent), 0644) Expect(err).NotTo(HaveOccurred()) - }) - It("returns an error", func() { - _, err := pkg.GetBaseimageName("workspace-agent-24.04") + _, err = pkg.GetBaseimageName("workspace-agent-24.04") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to get codesphere container images from bom.json")) }) }) Context("when baseimage is not found in bom.json", func() { - BeforeEach(func() { + It("returns an error", func() { // Create bom.json with codesphere component but without the requested baseimage workDir := pkg.GetWorkDir() err := os.MkdirAll(filepath.Join(workDir, "deps"), 0755) @@ -611,10 +431,8 @@ var _ = Describe("Package GetBaseimageName", func() { bomPath := pkg.GetDependencyPath("bom.json") err = os.WriteFile(bomPath, []byte(bomContent), 0644) Expect(err).NotTo(HaveOccurred()) - }) - It("returns an error", func() { - _, err := pkg.GetBaseimageName("workspace-agent-24.04") + _, err = pkg.GetBaseimageName("workspace-agent-24.04") Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("baseimage workspace-agent-24.04 not found in bom.json")) }) @@ -667,7 +485,7 @@ var _ = Describe("Package GetBaseimagePath", func() { BeforeEach(func() { tempDir = GinkgoT().TempDir() omsWorkdir := filepath.Join(tempDir, "oms-workdir") - pkg = installer.NewPackage(omsWorkdir, "test-package.tar.gz") + pkg = installer.NewPackage(omsWorkdir, "test-package.tar.gz").(*installer.Package) }) Describe("GetBaseimagePath", func() { @@ -746,7 +564,7 @@ var _ = Describe("Package GetCodesphereVersion", func() { BeforeEach(func() { tempDir = GinkgoT().TempDir() omsWorkdir := filepath.Join(tempDir, "oms-workdir") - pkg = installer.NewPackage(omsWorkdir, "test-package.tar.gz") + pkg = installer.NewPackage(omsWorkdir, "test-package.tar.gz").(*installer.Package) }) Describe("GetCodesphereVersion", func() { @@ -759,7 +577,7 @@ var _ = Describe("Package GetCodesphereVersion", func() { }) Context("when bom.json exists but is invalid", func() { - BeforeEach(func() { + It("returns an error", func() { // Create invalid bom.json workDir := pkg.GetWorkDir() err := os.MkdirAll(filepath.Join(workDir, "deps"), 0755) @@ -768,17 +586,15 @@ var _ = Describe("Package GetCodesphereVersion", func() { bomPath := pkg.GetDependencyPath("bom.json") err = os.WriteFile(bomPath, []byte("invalid json"), 0644) Expect(err).NotTo(HaveOccurred()) - }) - It("returns an error", func() { - _, err := pkg.GetCodesphereVersion() + _, err = pkg.GetCodesphereVersion() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to load bom.json")) }) }) Context("when bom.json exists but codesphere component is missing", func() { - BeforeEach(func() { + It("returns an error", func() { // Create bom.json without codesphere component workDir := pkg.GetWorkDir() err := os.MkdirAll(filepath.Join(workDir, "deps"), 0755) @@ -794,10 +610,8 @@ var _ = Describe("Package GetCodesphereVersion", func() { bomPath := pkg.GetDependencyPath("bom.json") err = os.WriteFile(bomPath, []byte(bomContent), 0644) Expect(err).NotTo(HaveOccurred()) - }) - It("returns an error", func() { - _, err := pkg.GetCodesphereVersion() + _, err = pkg.GetCodesphereVersion() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to get codesphere container images from bom.json")) }) @@ -833,7 +647,7 @@ var _ = Describe("Package GetCodesphereVersion", func() { }) Context("when container images exist but have invalid format", func() { - BeforeEach(func() { + It("returns an error", func() { // Create bom.json with images that have invalid format (no colon) workDir := pkg.GetWorkDir() err := os.MkdirAll(filepath.Join(workDir, "deps"), 0755) @@ -851,17 +665,15 @@ var _ = Describe("Package GetCodesphereVersion", func() { bomPath := pkg.GetDependencyPath("bom.json") err = os.WriteFile(bomPath, []byte(bomContent), 0644) Expect(err).NotTo(HaveOccurred()) - }) - It("returns an error", func() { - _, err := pkg.GetCodesphereVersion() + _, err = pkg.GetCodesphereVersion() Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("invalid image name format")) }) }) Context("when valid codesphere versions exist", func() { - BeforeEach(func() { + It("returns a valid codesphere version", func() { // Create bom.json with multiple different versions (should pick the first one found) workDir := pkg.GetWorkDir() err := os.MkdirAll(filepath.Join(workDir, "deps"), 0755) @@ -880,9 +692,7 @@ var _ = Describe("Package GetCodesphereVersion", func() { bomPath := pkg.GetDependencyPath("bom.json") err = os.WriteFile(bomPath, []byte(bomContent), 0644) Expect(err).NotTo(HaveOccurred()) - }) - It("returns a valid codesphere version", func() { version, err := pkg.GetCodesphereVersion() Expect(err).NotTo(HaveOccurred()) // Should return one of the codesphere versions (depends on map iteration order) diff --git a/internal/portal/http.go b/internal/portal/http.go index 02423a29..4f91a573 100644 --- a/internal/portal/http.go +++ b/internal/portal/http.go @@ -4,211 +4,77 @@ package portal import ( - "bytes" - "encoding/json" - "errors" "fmt" "io" "log" "net/http" - "net/url" - "slices" - "strings" - "time" - - "github.com/codesphere-cloud/oms/internal/env" ) -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, startByte int, quiet bool) error - RegisterAPIKey(owner string, organization string, role string, expiresAt time.Time) (*ApiKey, error) - RevokeAPIKey(key string) error - UpdateAPIKey(key string, expiresAt time.Time) error - ListAPIKeys() ([]ApiKey, error) +type Http interface { + Request(url string, method string, body io.Reader) (responseBody []byte, err error) + Get(url string) (responseBody []byte, err error) + Download(url string, file io.Writer, quiet bool) error } -type PortalClient struct { - Env env.Env +type HttpWrapper struct { HttpClient HttpClient } -type HttpClient interface { - Do(*http.Request) (*http.Response, error) -} - -func NewPortalClient() *PortalClient { - return &PortalClient{ - Env: env.NewEnv(), +func NewHttpWrapper() *HttpWrapper { + return &HttpWrapper{ HttpClient: http.DefaultClient, } } -type Product string - -const ( - CodesphereProduct Product = "codesphere" - OmsProduct Product = "oms" -) - -func (c *PortalClient) AuthorizedHttpRequest(req *http.Request) (resp *http.Response, err error) { - apiKey, err := c.Env.GetOmsPortalApiKey() - if err != nil { - err = fmt.Errorf("failed to get API Key: %w", err) - return - } - - req.Header.Set("X-API-Key", apiKey) - - resp, err = c.HttpClient.Do(req) - if err != nil { - err = fmt.Errorf("failed to send request: %w", err) - return - } - - if resp.StatusCode == http.StatusUnauthorized { - log.Println("You need a valid OMS API Key, please reach out to the Codesphere support at support@codesphere.com to request a new API Key.") - log.Println("If you already have an API Key, make sure to set it using the environment variable OMS_PORTAL_API_KEY") - } - var respBody []byte - if resp.StatusCode >= 300 { - if resp.Body != nil { - respBody, _ = io.ReadAll(resp.Body) - } - err = fmt.Errorf("unexpected response status: %d - %s, %s", resp.StatusCode, http.StatusText(resp.StatusCode), string(respBody)) - return - } - - return -} - -func (c *PortalClient) HttpRequest(method string, path string, body []byte) (resp *http.Response, err error) { - requestBody := bytes.NewBuffer(body) - url, err := url.JoinPath(c.Env.GetOmsPortalApi(), path) - if err != nil { - err = fmt.Errorf("failed to get generate URL: %w", err) - return - } - - req, err := http.NewRequest(method, url, requestBody) +func (c *HttpWrapper) Request(url string, method string, body io.Reader) (responseBody []byte, err error) { + req, err := http.NewRequest(method, url, body) if err != nil { log.Fatalf("Error creating request: %v", err) return } - if len(body) > 0 { - req.Header.Set("Content-Type", "application/json") - } - return c.AuthorizedHttpRequest(req) -} - -func (c *PortalClient) GetBody(path string) (body []byte, status int, err error) { - resp, err := c.HttpRequest(http.MethodGet, path, []byte{}) - if err != nil || resp == nil { - err = fmt.Errorf("GET failed: %w", err) - return - } - defer func() { _ = resp.Body.Close() }() - status = resp.StatusCode - body, err = io.ReadAll(resp.Body) + resp, err := c.HttpClient.Do(req) if err != nil { - err = fmt.Errorf("failed to read response body: %w", err) - return + return []byte{}, fmt.Errorf("failed to send request: %w", err) } + defer func() { + _ = resp.Body.Close() + }() - return -} - -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 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return []byte{}, fmt.Errorf("failed request with status: %d", resp.StatusCode) } - err = json.Unmarshal(res, &availablePackages) + respBody, err := io.ReadAll(resp.Body) if err != nil { - err = fmt.Errorf("failed to parse list packages response: %w", err) - return + return []byte{}, fmt.Errorf("failed to read response body: %w", err) } - compareBuilds := func(l, r Build) int { - if l.Date.Before(r.Date) { - return -1 - } - if l.Date.Equal(r.Date) && l.Internal == r.Internal { - return 0 - } - return 1 - } - slices.SortFunc(availablePackages.Builds, compareBuilds) - - return + return respBody, nil } -func (c *PortalClient) GetBuild(product Product, version string, hash string) (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") - } - - if version == "" || version == "latest" { - // Builds are always ordered by date, newest build is latest version - return packages.Builds[len(packages.Builds)-1], nil - } - - matchingPackages := []Build{} - for _, build := range packages.Builds { - if build.Version == version { - if len(hash) == 0 || strings.HasPrefix(hash, build.Hash) { - matchingPackages = append(matchingPackages, build) - } - } - } - - if len(matchingPackages) == 0 { - return Build{}, fmt.Errorf("version '%s' with hash '%s' not found", version, hash) - } - - // Builds are always ordered by date, return newest build - return matchingPackages[len(matchingPackages)-1], nil +func (c *HttpWrapper) Get(url string) (responseBody []byte, err error) { + return c.Request(url, http.MethodGet, nil) } -func (c *PortalClient) DownloadBuildArtifact(product Product, build Build, file io.Writer, startByte int, quiet bool) error { - reqBody, err := json.Marshal(build) +func (c *HttpWrapper) Download(url string, file io.Writer, quiet bool) error { + req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { - return fmt.Errorf("failed to generate request body: %w", err) + return fmt.Errorf("failed to create request: %w", err) } - url, err := url.JoinPath(c.Env.GetOmsPortalApi(), fmt.Sprintf("/packages/%s/download", product)) - if err != nil { - return fmt.Errorf("failed to get generate URL: %w", err) - } - bodyReader := bytes.NewBuffer(reqBody) - req, err := http.NewRequest(http.MethodGet, url, bodyReader) + resp, err := c.HttpClient.Do(req) if err != nil { - return fmt.Errorf("failed to create GET request to download build: %w", err) - } - if startByte > 0 { - log.Printf("Resuming download of existing file at byte %d\n", startByte) - req.Header.Set("Range", fmt.Sprintf("bytes=%d-", startByte)) + return fmt.Errorf("failed to send request: %w", err) } + defer func() { + _ = resp.Body.Close() + }() - // Download the file from startByte to allow resuming - req.Header.Set("Content-Type", "application/json") - resp, err := c.AuthorizedHttpRequest(req) - if err != nil { - return fmt.Errorf("GET request to download build failed: %w", err) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("failed to get body: %d", resp.StatusCode) } - defer func() { _ = resp.Body.Close() }() - // 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) @@ -222,97 +88,3 @@ func (c *PortalClient) DownloadBuildArtifact(product Product, build Build, file log.Println("Download finished successfully.") return nil } - -func (c *PortalClient) RegisterAPIKey(owner string, organization string, role string, expiresAt time.Time) (*ApiKey, error) { - req := struct { - Owner string `json:"owner"` - Organization string `json:"organization"` - Role string `json:"role"` - ExpiresAt time.Time `json:"expires_at"` - }{ - Owner: owner, - Organization: organization, - Role: role, - ExpiresAt: expiresAt, - } - - reqBody, err := json.Marshal(req) - if err != nil { - return nil, fmt.Errorf("failed to generate request body: %w", err) - } - - resp, err := c.HttpRequest(http.MethodPost, "/key/register", reqBody) - if err != nil { - return nil, fmt.Errorf("POST request to register API key failed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - newKey := &ApiKey{} - err = json.NewDecoder(resp.Body).Decode(newKey) - if err != nil { - return nil, fmt.Errorf("failed to decode response body: %w", err) - } - - return newKey, nil -} - -func (c *PortalClient) RevokeAPIKey(keyId string) error { - req := struct { - KeyID string `json:"keyId"` - }{ - KeyID: keyId, - } - - reqBody, err := json.Marshal(req) - if err != nil { - return fmt.Errorf("failed to generate request body: %w", err) - } - - resp, err := c.HttpRequest(http.MethodPost, "/key/revoke", reqBody) - if err != nil { - return fmt.Errorf("POST request to revoke API key failed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - log.Println("API key revoked successfully!") - - return nil -} - -func (c *PortalClient) UpdateAPIKey(key string, expiresAt time.Time) error { - req := struct { - Key string `json:"keyId"` - ExpiresAt time.Time `json:"expiresAt"` - }{ - Key: key, - ExpiresAt: expiresAt, - } - - reqBody, err := json.Marshal(req) - if err != nil { - return fmt.Errorf("failed to generate request body: %w", err) - } - - resp, err := c.HttpRequest(http.MethodPost, "/key/update", reqBody) - if err != nil { - return fmt.Errorf("POST request to update API key failed: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - log.Println("API key updated successfully") - return nil -} - -func (c *PortalClient) ListAPIKeys() ([]ApiKey, error) { - res, _, err := c.GetBody("/keys") - if err != nil { - return nil, fmt.Errorf("failed to list api keys: %w", err) - } - - var keys []ApiKey - if err := json.Unmarshal(res, &keys); err != nil { - return nil, fmt.Errorf("failed to parse api keys response: %w", err) - } - - return keys, nil -} diff --git a/internal/portal/http_test.go b/internal/portal/http_test.go index a3bf3604..a060397e 100644 --- a/internal/portal/http_test.go +++ b/internal/portal/http_test.go @@ -5,15 +5,12 @@ package portal_test import ( "bytes" - "encoding/json" "errors" "io" "log" "net/http" - "net/url" - "time" + "strings" - "github.com/codesphere-cloud/oms/internal/env" "github.com/codesphere-cloud/oms/internal/portal" "github.com/stretchr/testify/mock" @@ -21,354 +18,365 @@ import ( . "github.com/onsi/gomega" ) -type FakeWriter struct { - bytes.Buffer -} - -var _ io.Writer = (*FakeWriter)(nil) - -func NewFakeWriter() *FakeWriter { - return &FakeWriter{} -} - -var _ = Describe("PortalClient", func() { +var _ = Describe("HttpWrapper", func() { var ( - client portal.PortalClient - mockEnv *env.MockEnv + httpWrapper *portal.HttpWrapper mockHttpClient *portal.MockHttpClient - status int - apiUrl string - getUrl url.URL - headers http.Header - getResponse []byte - product portal.Product - apiKey string - apiKeyErr error + testUrl string + testMethod string + testBody io.Reader + response *http.Response + responseBody []byte + responseError error ) - BeforeEach(func() { - apiKey = "fake-api-key" - apiKeyErr = nil - product = portal.CodesphereProduct - mockEnv = env.NewMockEnv(GinkgoT()) + BeforeEach(func() { mockHttpClient = portal.NewMockHttpClient(GinkgoT()) - - client = portal.PortalClient{ - Env: mockEnv, + httpWrapper = &portal.HttpWrapper{ HttpClient: mockHttpClient, } - status = http.StatusOK - apiUrl = "fake-portal.com" - }) - JustBeforeEach(func() { - mockEnv.EXPECT().GetOmsPortalApi().Return(apiUrl) - mockEnv.EXPECT().GetOmsPortalApiKey().Return(apiKey, apiKeyErr).Maybe() + testUrl = "https://test.example.com/api/endpoint" + testMethod = "GET" + testBody = nil + responseBody = []byte("test response body") + responseError = nil }) + AfterEach(func() { - mockEnv.AssertExpectations(GinkgoT()) mockHttpClient.AssertExpectations(GinkgoT()) }) - Describe("GetBody", func() { - JustBeforeEach(func() { - 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 - }).Maybe() + Describe("NewHttpWrapper", func() { + It("creates a new HttpWrapper with default client", func() { + wrapper := portal.NewHttpWrapper() + Expect(wrapper).ToNot(BeNil()) + Expect(wrapper.HttpClient).ToNot(BeNil()) }) + }) - Context("when path starts with a /", func() { - It("Executes a request against the right URL", func() { - _, status, err := client.GetBody("/api/fake") - Expect(status).To(Equal(status)) - Expect(err).NotTo(HaveOccurred()) - Expect(getUrl.String()).To(Equal("fake-portal.com/api/fake")) + Describe("Request", func() { + Context("when making a successful GET request", func() { + JustBeforeEach(func() { + response = &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(responseBody)), + } + + mockHttpClient.EXPECT().Do(mock.MatchedBy(func(req *http.Request) bool { + return req.URL.String() == testUrl && req.Method == testMethod + })).Return(response, responseError) }) - }) - Context("when path does not with a /", func() { - It("Executes a request against the right URL", func() { - _, status, err := client.GetBody("api/fake") - Expect(status).To(Equal(status)) - Expect(err).NotTo(HaveOccurred()) - Expect(getUrl.String()).To(Equal("fake-portal.com/api/fake")) + It("returns the response body", func() { + result, err := httpWrapper.Request(testUrl, testMethod, testBody) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(responseBody)) }) }) - Context("when OMS_PORTAL_API_KEY is unset", func() { + Context("when making a POST request with body", func() { BeforeEach(func() { - apiKey = "" - apiKeyErr = errors.New("fake-error") + testMethod = "POST" + testBody = strings.NewReader("test post data") }) - It("Returns an error", func() { - _, status, err := client.GetBody("/api/fake") - Expect(status).To(Equal(status)) - Expect(err).NotTo(BeNil()) - Expect(err.Error()).To(MatchRegexp(".*fake-error")) - Expect(getUrl.String()).To(Equal("fake-portal.com/api/fake")) + JustBeforeEach(func() { + response = &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(responseBody)), + } + + mockHttpClient.EXPECT().Do(mock.MatchedBy(func(req *http.Request) bool { + return req.URL.String() == testUrl && req.Method == testMethod + })).Return(response, responseError) }) - }) - }) - Describe("ListCodespherePackages", func() { - JustBeforeEach(func() { - 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 - }) + It("returns the response body", func() { + result, err := httpWrapper.Request(testUrl, testMethod, testBody) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(responseBody)) + }) }) - Context("when the request suceeds", func() { - var expectedResult portal.Builds + + Context("when the HTTP client returns an error", 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: "lastBuild", - Date: lastBuild, - }, - { - Hash: "firstBuild", - Date: firstBuild, - }, - }, - } - getResponse, _ = json.Marshal(getPackagesResponse) - - expectedResult = portal.Builds{ - Builds: []portal.Build{ - { - Hash: "firstBuild", - Date: firstBuild, - }, - { - Hash: "lastBuild", - Date: lastBuild, - }, - }, + responseError = errors.New("network connection failed") + }) + + JustBeforeEach(func() { + response = &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(responseBody)), } + + mockHttpClient.EXPECT().Do(mock.MatchedBy(func(req *http.Request) bool { + return req.URL.String() == testUrl && req.Method == testMethod + })).Return(response, responseError) }) - It("returns the builds ordered by date", func() { - 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")) + It("returns an error", func() { + result, err := httpWrapper.Request(testUrl, testMethod, testBody) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to send request")) + Expect(err.Error()).To(ContainSubstring("network connection failed")) + Expect(result).To(Equal([]byte{})) }) }) - }) - Describe("DownloadBuildArtifact", func() { - var ( - build portal.Build - downloadResponse string - ) + Context("when the response status code indicates an error", func() { + JustBeforeEach(func() { + response = &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewReader(responseBody)), + } - BeforeEach(func() { - buildDate, _ := time.Parse("2006-01-02", "2025-05-01") - - downloadResponse = "fake-file-contents" - - build = portal.Build{ - Date: buildDate, - } - - mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( - func(req *http.Request) (*http.Response, error) { - getUrl = *req.URL - headers = req.Header - return &http.Response{ - StatusCode: status, - Body: io.NopCloser(bytes.NewReader([]byte(downloadResponse))), - }, nil - }) - }) + mockHttpClient.EXPECT().Do(mock.MatchedBy(func(req *http.Request) bool { + return req.URL.String() == testUrl && req.Method == testMethod + })).Return(response, responseError) + }) - It("downloads the build", func() { - fakeWriter := NewFakeWriter() - err := client.DownloadBuildArtifact(product, build, fakeWriter, 0, false) - Expect(err).NotTo(HaveOccurred()) - Expect(fakeWriter.String()).To(Equal(downloadResponse)) - Expect(getUrl.String()).To(Equal("fake-portal.com/packages/codesphere/download")) + It("returns an error", func() { + result, err := httpWrapper.Request(testUrl, testMethod, testBody) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed request with status: 400")) + Expect(result).To(Equal([]byte{})) + }) }) - It("resumes the build", func() { - fakeWriter := NewFakeWriter() - err := client.DownloadBuildArtifact(product, build, fakeWriter, 42, false) - Expect(err).NotTo(HaveOccurred()) - Expect(headers.Get("Range")).To(Equal("bytes=42-")) - Expect(fakeWriter.String()).To(Equal(downloadResponse)) - Expect(getUrl.String()).To(Equal("fake-portal.com/packages/codesphere/download")) + Context("when reading the response body fails", func() { + JustBeforeEach(func() { + response = &http.Response{ + StatusCode: http.StatusOK, + Body: &FailingReader{}, + } + + mockHttpClient.EXPECT().Do(mock.MatchedBy(func(req *http.Request) bool { + return req.URL.String() == testUrl && req.Method == testMethod + })).Return(response, responseError) + }) + + It("returns an error", func() { + result, err := httpWrapper.Request(testUrl, testMethod, testBody) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to read response body")) + Expect(result).To(Equal([]byte{})) + }) }) + }) - It("emits progress logs when not quiet", func() { - var logBuf bytes.Buffer - prev := log.Writer() - log.SetOutput(&logBuf) - defer log.SetOutput(prev) + Describe("Get", func() { + Context("when making a successful request", func() { + JustBeforeEach(func() { + response = &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(responseBody)), + } + + mockHttpClient.EXPECT().Do(mock.MatchedBy(func(req *http.Request) bool { + return req.URL.String() == testUrl && req.Method == "GET" + })).Return(response, responseError) + }) - fakeWriter := NewFakeWriter() - err := client.DownloadBuildArtifact(product, build, fakeWriter, 0, false) - Expect(err).NotTo(HaveOccurred()) - Expect(logBuf.String()).To(ContainSubstring("Downloading...")) + It("returns the response body", func() { + result, err := httpWrapper.Get(testUrl) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(responseBody)) + }) }) - It("does not emit progress logs when quiet", func() { - var logBuf bytes.Buffer - prev := log.Writer() - log.SetOutput(&logBuf) - defer log.SetOutput(prev) + Context("when the request fails", func() { + BeforeEach(func() { + responseError = errors.New("DNS resolution failed") + }) + + JustBeforeEach(func() { + response = &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(responseBody)), + } - fakeWriter := NewFakeWriter() - err := client.DownloadBuildArtifact(product, build, fakeWriter, 0, true) - Expect(err).NotTo(HaveOccurred()) - Expect(logBuf.String()).NotTo(ContainSubstring("Downloading...")) + mockHttpClient.EXPECT().Do(mock.MatchedBy(func(req *http.Request) bool { + return req.URL.String() == testUrl && req.Method == "GET" + })).Return(response, responseError) + }) + + It("returns an error", func() { + result, err := httpWrapper.Get(testUrl) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to send request")) + Expect(err.Error()).To(ContainSubstring("DNS resolution failed")) + Expect(result).To(Equal([]byte{})) + }) }) }) - Describe("GetLatestOmsBuild", func() { + Describe("Download", func() { var ( - lastBuild, firstBuild time.Time - getPackagesResponse portal.Builds + testWriter *TestWriter + downloadContent string + quiet bool ) - 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 - }) + + BeforeEach(func() { + testWriter = NewTestWriter() + downloadContent = "file content to download" + quiet = false }) - 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", - }, - }, + Context("when downloading successfully", func() { + JustBeforeEach(func() { + response = &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(downloadContent)), } + + mockHttpClient.EXPECT().Do(mock.MatchedBy(func(req *http.Request) bool { + return req.URL.String() == testUrl && req.Method == "GET" + })).Return(response, responseError) }) - It("returns the build", func() { - expectedResult := portal.Build{ - Hash: "lastBuild", - Date: lastBuild, - Version: "1.42.1", - } - packages, err := client.GetBuild(portal.OmsProduct, "", "") - Expect(err).NotTo(HaveOccurred()) - Expect(packages).To(Equal(expectedResult)) - Expect(getUrl.String()).To(Equal("fake-portal.com/packages/oms")) + + It("downloads content and shows progress", func() { + // Capture log output to verify progress is shown + var logBuf bytes.Buffer + prev := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(prev) + + err := httpWrapper.Download(testUrl, testWriter, quiet) + Expect(err).ToNot(HaveOccurred()) + Expect(testWriter.String()).To(Equal(downloadContent)) + Expect(logBuf.String()).To(ContainSubstring("Downloading...")) + Expect(logBuf.String()).To(ContainSubstring("Download finished successfully")) + }) + + It("downloads content without showing progress", func() { + quiet = true // Set quiet to true to suppress progress output + + var logBuf bytes.Buffer + prev := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(prev) + + err := httpWrapper.Download(testUrl, testWriter, quiet) + Expect(err).ToNot(HaveOccurred()) + Expect(testWriter.String()).To(Equal(downloadContent)) + Expect(logBuf.String()).To(Not(ContainSubstring("Downloading..."))) + Expect(logBuf.String()).To(ContainSubstring("Download finished successfully")) }) }) - Context("When the build with version is included", func() { + Context("when the HTTP client returns an error", 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", - }, - }, - } + responseError = errors.New("connection timeout") }) - It("returns the build", func() { - expectedResult := portal.Build{ - Hash: "lastBuild", - Date: lastBuild, - Version: "1.42.1", + + JustBeforeEach(func() { + response = &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(downloadContent)), } - packages, err := client.GetBuild(portal.OmsProduct, "1.42.1", "") - Expect(err).NotTo(HaveOccurred()) - Expect(packages).To(Equal(expectedResult)) - Expect(getUrl.String()).To(Equal("fake-portal.com/packages/oms")) + + mockHttpClient.EXPECT().Do(mock.MatchedBy(func(req *http.Request) bool { + return req.URL.String() == testUrl && req.Method == "GET" + })).Return(response, responseError) + }) + + It("returns an error", func() { + err := httpWrapper.Download(testUrl, testWriter, quiet) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to send request")) + Expect(err.Error()).To(ContainSubstring("connection timeout")) + Expect(testWriter.String()).To(BeEmpty()) }) }) - Context("When the build with version and hash 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", - }, - }, + Context("when the server returns an error status", func() { + JustBeforeEach(func() { + response = &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader("Access denied")), } + + mockHttpClient.EXPECT().Do(mock.MatchedBy(func(req *http.Request) bool { + return req.URL.String() == testUrl && req.Method == "GET" + })).Return(response, responseError) }) - It("returns the build", func() { - expectedResult := portal.Build{ - Hash: "lastBuild", - Date: lastBuild, - Version: "1.42.1", - } - packages, err := client.GetBuild(portal.OmsProduct, "1.42.1", "lastBuild") - Expect(err).NotTo(HaveOccurred()) - Expect(packages).To(Equal(expectedResult)) - Expect(getUrl.String()).To(Equal("fake-portal.com/packages/oms")) + + It("returns an error", func() { + err := httpWrapper.Download(testUrl, testWriter, quiet) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to get body: 403")) + Expect(testWriter.String()).To(BeEmpty()) }) }) - 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") + Context("when copying the response body fails", func() { + JustBeforeEach(func() { + response = &http.Response{ + StatusCode: http.StatusOK, + Body: &FailingReader{}, + } + + mockHttpClient.EXPECT().Do(mock.MatchedBy(func(req *http.Request) bool { + return req.URL.String() == testUrl && req.Method == "GET" + })).Return(response, responseError) + }) + + It("returns an error", func() { + err := httpWrapper.Download(testUrl, testWriter, quiet) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to copy response body to file")) + Expect(err.Error()).To(ContainSubstring("simulated read error")) + }) + }) - getPackagesResponse = portal.Builds{ - Builds: []portal.Build{}, + Context("when the writer fails", func() { + JustBeforeEach(func() { + response = &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(downloadContent)), } + + mockHttpClient.EXPECT().Do(mock.MatchedBy(func(req *http.Request) bool { + return req.URL.String() == testUrl && req.Method == "GET" + })).Return(response, responseError) }) - It("returns an error and an empty build", func() { - expectedResult := portal.Build{} - packages, err := client.GetBuild(portal.OmsProduct, "", "") - Expect(err).To(MatchError("no builds returned")) - Expect(packages).To(Equal(expectedResult)) - Expect(getUrl.String()).To(Equal("fake-portal.com/packages/oms")) + + It("handles write errors gracefully", func() { + // Use a failing writer + failingWriter := &FailingWriter{} + + err := httpWrapper.Download(testUrl, failingWriter, quiet) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to copy response body to file")) }) }) }) }) + +// Helper types for testing +type TestWriter struct { + bytes.Buffer +} + +var _ io.Writer = (*TestWriter)(nil) + +func NewTestWriter() *TestWriter { + return &TestWriter{} +} + +type FailingReader struct{} + +func (fr *FailingReader) Read(p []byte) (n int, err error) { + return 0, errors.New("simulated read error") +} + +func (fr *FailingReader) Close() error { + return nil +} + +type FailingWriter struct{} + +func (fw *FailingWriter) Write(p []byte) (n int, err error) { + return 0, errors.New("simulated write error") +} diff --git a/internal/portal/mocks.go b/internal/portal/mocks.go index 1cd02094..7ec25e51 100644 --- a/internal/portal/mocks.go +++ b/internal/portal/mocks.go @@ -11,6 +11,194 @@ import ( "time" ) +// NewMockHttp creates a new instance of MockHttp. 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 NewMockHttp(t interface { + mock.TestingT + Cleanup(func()) +}) *MockHttp { + mock := &MockHttp{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockHttp is an autogenerated mock type for the Http type +type MockHttp struct { + mock.Mock +} + +type MockHttp_Expecter struct { + mock *mock.Mock +} + +func (_m *MockHttp) EXPECT() *MockHttp_Expecter { + return &MockHttp_Expecter{mock: &_m.Mock} +} + +// Download provides a mock function for the type MockHttp +func (_mock *MockHttp) Download(url string, file io.Writer, quiet bool) error { + ret := _mock.Called(url, file, quiet) + + if len(ret) == 0 { + panic("no return value specified for Download") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, io.Writer, bool) error); ok { + r0 = returnFunc(url, file, quiet) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockHttp_Download_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Download' +type MockHttp_Download_Call struct { + *mock.Call +} + +// Download is a helper method to define mock.On call +// - url +// - file +// - quiet +func (_e *MockHttp_Expecter) Download(url interface{}, file interface{}, quiet interface{}) *MockHttp_Download_Call { + return &MockHttp_Download_Call{Call: _e.mock.On("Download", url, file, quiet)} +} + +func (_c *MockHttp_Download_Call) Run(run func(url string, file io.Writer, quiet bool)) *MockHttp_Download_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(io.Writer), args[2].(bool)) + }) + return _c +} + +func (_c *MockHttp_Download_Call) Return(err error) *MockHttp_Download_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockHttp_Download_Call) RunAndReturn(run func(url string, file io.Writer, quiet bool) error) *MockHttp_Download_Call { + _c.Call.Return(run) + return _c +} + +// Get provides a mock function for the type MockHttp +func (_mock *MockHttp) Get(url string) ([]byte, error) { + ret := _mock.Called(url) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 []byte + var r1 error + if returnFunc, ok := ret.Get(0).(func(string) ([]byte, error)); ok { + return returnFunc(url) + } + if returnFunc, ok := ret.Get(0).(func(string) []byte); ok { + r0 = returnFunc(url) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + if returnFunc, ok := ret.Get(1).(func(string) error); ok { + r1 = returnFunc(url) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockHttp_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get' +type MockHttp_Get_Call struct { + *mock.Call +} + +// Get is a helper method to define mock.On call +// - url +func (_e *MockHttp_Expecter) Get(url interface{}) *MockHttp_Get_Call { + return &MockHttp_Get_Call{Call: _e.mock.On("Get", url)} +} + +func (_c *MockHttp_Get_Call) Run(run func(url string)) *MockHttp_Get_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockHttp_Get_Call) Return(responseBody []byte, err error) *MockHttp_Get_Call { + _c.Call.Return(responseBody, err) + return _c +} + +func (_c *MockHttp_Get_Call) RunAndReturn(run func(url string) ([]byte, error)) *MockHttp_Get_Call { + _c.Call.Return(run) + return _c +} + +// Request provides a mock function for the type MockHttp +func (_mock *MockHttp) Request(url string, method string, body io.Reader) ([]byte, error) { + ret := _mock.Called(url, method, body) + + if len(ret) == 0 { + panic("no return value specified for Request") + } + + var r0 []byte + var r1 error + if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) ([]byte, error)); ok { + return returnFunc(url, method, body) + } + if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) []byte); ok { + r0 = returnFunc(url, method, body) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + if returnFunc, ok := ret.Get(1).(func(string, string, io.Reader) error); ok { + r1 = returnFunc(url, method, body) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockHttp_Request_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Request' +type MockHttp_Request_Call struct { + *mock.Call +} + +// Request is a helper method to define mock.On call +// - url +// - method +// - body +func (_e *MockHttp_Expecter) Request(url interface{}, method interface{}, body interface{}) *MockHttp_Request_Call { + return &MockHttp_Request_Call{Call: _e.mock.On("Request", url, method, body)} +} + +func (_c *MockHttp_Request_Call) Run(run func(url string, method string, body io.Reader)) *MockHttp_Request_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string), args[2].(io.Reader)) + }) + return _c +} + +func (_c *MockHttp_Request_Call) Return(responseBody []byte, err error) *MockHttp_Request_Call { + _c.Call.Return(responseBody, err) + return _c +} + +func (_c *MockHttp_Request_Call) RunAndReturn(run func(url string, method string, body io.Reader) ([]byte, error)) *MockHttp_Request_Call { + _c.Call.Return(run) + return _c +} + // NewMockPortal creates a new instance of MockPortal. 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 NewMockPortal(t interface { diff --git a/internal/portal/portal.go b/internal/portal/portal.go new file mode 100644 index 00000000..13a86f53 --- /dev/null +++ b/internal/portal/portal.go @@ -0,0 +1,328 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package portal + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/codesphere-cloud/oms/internal/env" +) + +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, startByte int, quiet bool) error + RegisterAPIKey(owner string, organization string, role string, expiresAt time.Time) (*ApiKey, error) + RevokeAPIKey(key string) error + UpdateAPIKey(key string, expiresAt time.Time) error + ListAPIKeys() ([]ApiKey, error) +} + +type PortalClient struct { + Env env.Env + HttpClient HttpClient +} + +type HttpClient interface { + Do(*http.Request) (*http.Response, error) +} + +func NewPortalClient() *PortalClient { + return &PortalClient{ + Env: env.NewEnv(), + HttpClient: http.DefaultClient, + } +} + +type Product string + +const ( + CodesphereProduct Product = "codesphere" + OmsProduct Product = "oms" +) + +// AuthorizedHttpRequest sends a HTTP request with the necessary authorization headers. +func (c *PortalClient) AuthorizedHttpRequest(req *http.Request) (resp *http.Response, err error) { + apiKey, err := c.Env.GetOmsPortalApiKey() + if err != nil { + err = fmt.Errorf("failed to get API Key: %w", err) + return + } + + req.Header.Set("X-API-Key", apiKey) + + resp, err = c.HttpClient.Do(req) + if err != nil { + err = fmt.Errorf("failed to send request: %w", err) + return + } + + if resp.StatusCode == http.StatusUnauthorized { + log.Println("You need a valid OMS API Key, please reach out to the Codesphere support at support@codesphere.com to request a new API Key.") + log.Println("If you already have an API Key, make sure to set it using the environment variable OMS_PORTAL_API_KEY") + } + var respBody []byte + if resp.StatusCode >= 300 { + if resp.Body != nil { + respBody, _ = io.ReadAll(resp.Body) + } + err = fmt.Errorf("unexpected response status: %d - %s, %s", resp.StatusCode, http.StatusText(resp.StatusCode), string(respBody)) + return + } + + return +} + +// HttpRequest sends an unauthorized HTTP request to the portal API with the specified method, path, and body. +func (c *PortalClient) HttpRequest(method string, path string, body []byte) (resp *http.Response, err error) { + requestBody := bytes.NewBuffer(body) + url, err := url.JoinPath(c.Env.GetOmsPortalApi(), path) + if err != nil { + err = fmt.Errorf("failed to get generate URL: %w", err) + return + } + + req, err := http.NewRequest(method, url, requestBody) + if err != nil { + log.Fatalf("Error creating request: %v", err) + return + } + if len(body) > 0 { + req.Header.Set("Content-Type", "application/json") + } + return c.AuthorizedHttpRequest(req) +} + +// GetBody sends a GET request to the specified path and returns the response body and status code. +func (c *PortalClient) GetBody(path string) (body []byte, status int, err error) { + resp, err := c.HttpRequest(http.MethodGet, path, []byte{}) + if err != nil || resp == nil { + err = fmt.Errorf("GET failed: %w", err) + return + } + defer func() { _ = resp.Body.Close() }() + status = resp.StatusCode + + body, err = io.ReadAll(resp.Body) + if err != nil { + err = fmt.Errorf("failed to read response body: %w", err) + return + } + + return +} + +// ListBuilds retrieves the list of available builds for the specified product. +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 + } + + err = json.Unmarshal(res, &availablePackages) + if err != nil { + err = fmt.Errorf("failed to parse list packages response: %w", err) + return + } + + compareBuilds := func(l, r Build) int { + if l.Date.Before(r.Date) { + return -1 + } + if l.Date.Equal(r.Date) && l.Internal == r.Internal { + return 0 + } + return 1 + } + slices.SortFunc(availablePackages.Builds, compareBuilds) + + return +} + +// GetBuild retrieves a specific build for the given product, version, and hash. +func (c *PortalClient) GetBuild(product Product, version string, hash string) (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") + } + + if version == "" || version == "latest" { + // Builds are always ordered by date, newest build is latest version + return packages.Builds[len(packages.Builds)-1], nil + } + + matchingPackages := []Build{} + for _, build := range packages.Builds { + if build.Version == version { + if len(hash) == 0 || strings.HasPrefix(hash, build.Hash) { + matchingPackages = append(matchingPackages, build) + } + } + } + + if len(matchingPackages) == 0 { + return Build{}, fmt.Errorf("version '%s' with hash '%s' not found", version, hash) + } + + // Builds are always ordered by date, return newest build + return matchingPackages[len(matchingPackages)-1], nil +} + +// DownloadBuildArtifact downloads the build artifact for the specified product and build. +func (c *PortalClient) DownloadBuildArtifact(product Product, build Build, file io.Writer, startByte int, quiet bool) error { + reqBody, err := json.Marshal(build) + if err != nil { + return fmt.Errorf("failed to generate request body: %w", err) + } + + url, err := url.JoinPath(c.Env.GetOmsPortalApi(), fmt.Sprintf("/packages/%s/download", product)) + if err != nil { + return fmt.Errorf("failed to get generate URL: %w", err) + } + bodyReader := bytes.NewBuffer(reqBody) + req, err := http.NewRequest(http.MethodGet, url, bodyReader) + if err != nil { + return fmt.Errorf("failed to create GET request to download build: %w", err) + } + if startByte > 0 { + log.Printf("Resuming download of existing file at byte %d\n", startByte) + req.Header.Set("Range", fmt.Sprintf("bytes=%d-", startByte)) + } + + // Download the file from startByte to allow resuming + req.Header.Set("Content-Type", "application/json") + resp, err := c.AuthorizedHttpRequest(req) + if err != nil { + return fmt.Errorf("GET request to download build failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // 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 { + return fmt.Errorf("failed to copy response body to file: %w", err) + } + + log.Println("Download finished successfully.") + return nil +} + +// RegisterAPIKey registers a new API key with the specified parameters. +func (c *PortalClient) RegisterAPIKey(owner string, organization string, role string, expiresAt time.Time) (*ApiKey, error) { + req := struct { + Owner string `json:"owner"` + Organization string `json:"organization"` + Role string `json:"role"` + ExpiresAt time.Time `json:"expires_at"` + }{ + Owner: owner, + Organization: organization, + Role: role, + ExpiresAt: expiresAt, + } + + reqBody, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to generate request body: %w", err) + } + + resp, err := c.HttpRequest(http.MethodPost, "/key/register", reqBody) + if err != nil { + return nil, fmt.Errorf("POST request to register API key failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + newKey := &ApiKey{} + err = json.NewDecoder(resp.Body).Decode(newKey) + if err != nil { + return nil, fmt.Errorf("failed to decode response body: %w", err) + } + + return newKey, nil +} + +// RevokeAPIKey revokes the API key with the specified key ID. +func (c *PortalClient) RevokeAPIKey(keyId string) error { + req := struct { + KeyID string `json:"keyId"` + }{ + KeyID: keyId, + } + + reqBody, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to generate request body: %w", err) + } + + resp, err := c.HttpRequest(http.MethodPost, "/key/revoke", reqBody) + if err != nil { + return fmt.Errorf("POST request to revoke API key failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + log.Println("API key revoked successfully!") + + return nil +} + +// UpdateAPIKey updates the expiration date of the specified API key. +func (c *PortalClient) UpdateAPIKey(key string, expiresAt time.Time) error { + req := struct { + Key string `json:"keyId"` + ExpiresAt time.Time `json:"expiresAt"` + }{ + Key: key, + ExpiresAt: expiresAt, + } + + reqBody, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to generate request body: %w", err) + } + + resp, err := c.HttpRequest(http.MethodPost, "/key/update", reqBody) + if err != nil { + return fmt.Errorf("POST request to update API key failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + log.Println("API key updated successfully") + return nil +} + +// ListAPIKeys retrieves the list of API keys. +func (c *PortalClient) ListAPIKeys() ([]ApiKey, error) { + res, _, err := c.GetBody("/keys") + if err != nil { + return nil, fmt.Errorf("failed to list api keys: %w", err) + } + + var keys []ApiKey + if err := json.Unmarshal(res, &keys); err != nil { + return nil, fmt.Errorf("failed to parse api keys response: %w", err) + } + + return keys, nil +} diff --git a/internal/portal/portal_test.go b/internal/portal/portal_test.go new file mode 100644 index 00000000..a3bf3604 --- /dev/null +++ b/internal/portal/portal_test.go @@ -0,0 +1,374 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package portal_test + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/url" + "time" + + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/portal" + "github.com/stretchr/testify/mock" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type FakeWriter struct { + bytes.Buffer +} + +var _ io.Writer = (*FakeWriter)(nil) + +func NewFakeWriter() *FakeWriter { + return &FakeWriter{} +} + +var _ = Describe("PortalClient", func() { + var ( + client portal.PortalClient + mockEnv *env.MockEnv + mockHttpClient *portal.MockHttpClient + status int + apiUrl string + getUrl url.URL + headers http.Header + getResponse []byte + product portal.Product + apiKey string + apiKeyErr error + ) + BeforeEach(func() { + apiKey = "fake-api-key" + apiKeyErr = nil + + product = portal.CodesphereProduct + mockEnv = env.NewMockEnv(GinkgoT()) + mockHttpClient = portal.NewMockHttpClient(GinkgoT()) + + client = portal.PortalClient{ + Env: mockEnv, + HttpClient: mockHttpClient, + } + status = http.StatusOK + apiUrl = "fake-portal.com" + }) + JustBeforeEach(func() { + mockEnv.EXPECT().GetOmsPortalApi().Return(apiUrl) + mockEnv.EXPECT().GetOmsPortalApiKey().Return(apiKey, apiKeyErr).Maybe() + }) + AfterEach(func() { + mockEnv.AssertExpectations(GinkgoT()) + mockHttpClient.AssertExpectations(GinkgoT()) + }) + + Describe("GetBody", func() { + JustBeforeEach(func() { + 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 + }).Maybe() + }) + + Context("when path starts with a /", func() { + It("Executes a request against the right URL", func() { + _, status, err := client.GetBody("/api/fake") + Expect(status).To(Equal(status)) + Expect(err).NotTo(HaveOccurred()) + Expect(getUrl.String()).To(Equal("fake-portal.com/api/fake")) + }) + }) + + Context("when path does not with a /", func() { + It("Executes a request against the right URL", func() { + _, status, err := client.GetBody("api/fake") + Expect(status).To(Equal(status)) + Expect(err).NotTo(HaveOccurred()) + Expect(getUrl.String()).To(Equal("fake-portal.com/api/fake")) + }) + }) + + Context("when OMS_PORTAL_API_KEY is unset", func() { + BeforeEach(func() { + apiKey = "" + apiKeyErr = errors.New("fake-error") + }) + + It("Returns an error", func() { + _, status, err := client.GetBody("/api/fake") + Expect(status).To(Equal(status)) + Expect(err).NotTo(BeNil()) + Expect(err.Error()).To(MatchRegexp(".*fake-error")) + Expect(getUrl.String()).To(Equal("fake-portal.com/api/fake")) + }) + }) + }) + + Describe("ListCodespherePackages", func() { + JustBeforeEach(func() { + 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 request suceeds", func() { + 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.Builds{ + Builds: []portal.Build{ + { + Hash: "lastBuild", + Date: lastBuild, + }, + { + Hash: "firstBuild", + Date: firstBuild, + }, + }, + } + getResponse, _ = json.Marshal(getPackagesResponse) + + expectedResult = portal.Builds{ + Builds: []portal.Build{ + { + Hash: "firstBuild", + Date: firstBuild, + }, + { + Hash: "lastBuild", + Date: lastBuild, + }, + }, + } + }) + + It("returns the builds ordered by date", func() { + 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")) + }) + }) + }) + + Describe("DownloadBuildArtifact", func() { + var ( + build portal.Build + downloadResponse string + ) + + BeforeEach(func() { + buildDate, _ := time.Parse("2006-01-02", "2025-05-01") + + downloadResponse = "fake-file-contents" + + build = portal.Build{ + Date: buildDate, + } + + mockHttpClient.EXPECT().Do(mock.Anything).RunAndReturn( + func(req *http.Request) (*http.Response, error) { + getUrl = *req.URL + headers = req.Header + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(bytes.NewReader([]byte(downloadResponse))), + }, nil + }) + }) + + It("downloads the build", func() { + fakeWriter := NewFakeWriter() + err := client.DownloadBuildArtifact(product, build, fakeWriter, 0, false) + Expect(err).NotTo(HaveOccurred()) + Expect(fakeWriter.String()).To(Equal(downloadResponse)) + Expect(getUrl.String()).To(Equal("fake-portal.com/packages/codesphere/download")) + }) + + It("resumes the build", func() { + fakeWriter := NewFakeWriter() + err := client.DownloadBuildArtifact(product, build, fakeWriter, 42, false) + Expect(err).NotTo(HaveOccurred()) + Expect(headers.Get("Range")).To(Equal("bytes=42-")) + 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, 0, 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, 0, true) + Expect(err).NotTo(HaveOccurred()) + Expect(logBuf.String()).NotTo(ContainSubstring("Downloading...")) + }) + }) + + 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.GetBuild(portal.OmsProduct, "", "") + Expect(err).NotTo(HaveOccurred()) + Expect(packages).To(Equal(expectedResult)) + Expect(getUrl.String()).To(Equal("fake-portal.com/packages/oms")) + }) + }) + + Context("When the build with version 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.GetBuild(portal.OmsProduct, "1.42.1", "") + Expect(err).NotTo(HaveOccurred()) + Expect(packages).To(Equal(expectedResult)) + Expect(getUrl.String()).To(Equal("fake-portal.com/packages/oms")) + }) + }) + + Context("When the build with version and hash 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.GetBuild(portal.OmsProduct, "1.42.1", "lastBuild") + 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.GetBuild(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/util/command.go b/internal/util/command.go new file mode 100644 index 00000000..6b55d6a6 --- /dev/null +++ b/internal/util/command.go @@ -0,0 +1,29 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "context" + "fmt" + "os" + "os/exec" +) + +func RunCommand(command string, args []string, cmdDir string) error { + cmd := exec.CommandContext(context.Background(), command, args...) + + if cmdDir != "" { + cmd.Dir = cmdDir + } + + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err := cmd.Run() + if err != nil { + return fmt.Errorf("command failed with exit status %w", err) + } + + return nil +}