From 14bc6e3620724ba2889410d6a90994be2c9f860b Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Tue, 4 Nov 2025 17:27:48 +0100 Subject: [PATCH 01/20] feat: add download and install k0s command --- cli/cmd/download.go | 1 + cli/cmd/download_k0s.go | 69 ++++ cli/cmd/download_k0s_test.go | 119 +++++++ cli/cmd/install.go | 2 + cli/cmd/install_k0s.go | 76 ++++ cli/cmd/install_k0s_test.go | 162 +++++++++ internal/installer/config_test.go | 12 - internal/installer/k0s.go | 126 +++++++ internal/installer/k0s_test.go | 337 ++++++++++++++++++ internal/installer/mocks.go | 163 +++++++++ internal/portal/http.go | 267 ++------------ internal/portal/http_test.go | 565 +++++++++++++++--------------- internal/portal/mocks.go | 188 ++++++++++ internal/portal/portal.go | 299 ++++++++++++++++ internal/portal/portal_test.go | 363 +++++++++++++++++++ internal/util/command.go | 26 ++ 16 files changed, 2250 insertions(+), 525 deletions(-) create mode 100644 cli/cmd/download_k0s.go create mode 100644 cli/cmd/download_k0s_test.go create mode 100644 cli/cmd/install_k0s.go create mode 100644 cli/cmd/install_k0s_test.go create mode 100644 internal/installer/k0s.go create mode 100644 internal/installer/k0s_test.go create mode 100644 internal/portal/portal.go create mode 100644 internal/portal/portal_test.go create mode 100644 internal/util/command.go 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..513426bb --- /dev/null +++ b/cli/cmd/download_k0s.go @@ -0,0 +1,69 @@ +// 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" +) + +// DownloadK0sCmd represents the k0s download command +type DownloadK0sCmd struct { + cmd *cobra.Command + Opts DownloadK0sOpts + Env env.Env + FileWriter util.FileIO +} + +type DownloadK0sOpts struct { + *GlobalOptions + 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 := k0s.Download(c.Opts.Force, c.Opts.Quiet) + 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 k0s, a zero friction Kubernetes distribution, + using a Go-native implementation. This will download the k0s + binary directly to the OMS workdir.`), + Example: formatExamplesWithBinary("download k0s", []packageio.Example{ + {Cmd: "", Desc: "Download k0s using the Go-native implementation"}, + {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().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 +} diff --git a/cli/cmd/download_k0s_test.go b/cli/cmd/download_k0s_test.go new file mode 100644 index 00000000..37fb2208 --- /dev/null +++ b/cli/cmd/download_k0s_test.go @@ -0,0 +1,119 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "errors" + "runtime" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + + "github.com/codesphere-cloud/oms/cli/cmd" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/util" +) + +var _ = Describe("DownloadK0sCmd", func() { + var ( + downloadK0sCmd *cmd.DownloadK0sCmd + mockEnv *env.MockEnv + mockFileWriter *util.MockFileIO + ) + + BeforeEach(func() { + mockEnv = env.NewMockEnv(GinkgoT()) + mockFileWriter = util.NewMockFileIO(GinkgoT()) + + downloadK0sCmd = &cmd.DownloadK0sCmd{ + Opts: cmd.DownloadK0sOpts{ + GlobalOptions: &cmd.GlobalOptions{}, + Force: false, + Quiet: false, + }, + Env: mockEnv, + FileWriter: mockFileWriter, + } + }) + + AfterEach(func() { + mockEnv.AssertExpectations(GinkgoT()) + mockFileWriter.AssertExpectations(GinkgoT()) + }) + + Context("RunE", func() { + It("should successfully handle k0s download integration", func() { + // Add mock expectations for the download functionality, intentionally causing create to fail + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() + mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(false).Maybe() + mockFileWriter.EXPECT().Create("/test/workdir/k0s").Return(nil, errors.New("mock create error")).Maybe() + + err := downloadK0sCmd.RunE(nil, nil) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download k0s")) + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + // Should fail with platform error on non-Linux amd64 platforms + Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) + } else { + // On Linux amd64, it should fail on network/version fetch since we don't have real network access + Expect(err.Error()).To(ContainSubstring("mock create error")) + } + }) + }) +}) + +var _ = Describe("AddDownloadK0sCmd", func() { + var ( + parentCmd *cobra.Command + globalOpts *cmd.GlobalOptions + ) + + BeforeEach(func() { + parentCmd = &cobra.Command{Use: "download"} + globalOpts = &cmd.GlobalOptions{} + }) + + It("adds the k0s command with correct properties and flags", func() { + cmd.AddDownloadK0sCmd(parentCmd, globalOpts) + + var k0sCmd *cobra.Command + for _, c := range parentCmd.Commands() { + if c.Use == "k0s" { + k0sCmd = c + break + } + } + + Expect(k0sCmd).NotTo(BeNil()) + Expect(k0sCmd.Use).To(Equal("k0s")) + Expect(k0sCmd.Short).To(Equal("Download k0s Kubernetes distribution")) + Expect(k0sCmd.Long).To(ContainSubstring("Download k0s, a zero friction Kubernetes distribution")) + Expect(k0sCmd.Long).To(ContainSubstring("using a Go-native implementation")) + Expect(k0sCmd.RunE).NotTo(BeNil()) + + Expect(k0sCmd.Parent()).To(Equal(parentCmd)) + Expect(parentCmd.Commands()).To(ContainElement(k0sCmd)) + + // Check flags + forceFlag := k0sCmd.Flags().Lookup("force") + Expect(forceFlag).NotTo(BeNil()) + Expect(forceFlag.Shorthand).To(Equal("f")) + Expect(forceFlag.DefValue).To(Equal("false")) + Expect(forceFlag.Usage).To(Equal("Force download even if k0s binary exists")) + + quietFlag := k0sCmd.Flags().Lookup("quiet") + Expect(quietFlag).NotTo(BeNil()) + Expect(quietFlag.Shorthand).To(Equal("q")) + Expect(quietFlag.DefValue).To(Equal("false")) + Expect(quietFlag.Usage).To(Equal("Suppress progress output during download")) + + // Check examples + Expect(k0sCmd.Example).NotTo(BeEmpty()) + Expect(k0sCmd.Example).To(ContainSubstring("oms-cli download k0s")) + Expect(k0sCmd.Example).To(ContainSubstring("--quiet")) + Expect(k0sCmd.Example).To(ContainSubstring("--force")) + }) +}) 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..27129554 --- /dev/null +++ b/cli/cmd/install_k0s.go @@ -0,0 +1,76 @@ +// 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 + Config string + Force bool +} + +func (c *InstallK0sCmd) RunE(_ *cobra.Command, args []string) error { + hw := portal.NewHttpWrapper() + env := c.Env + k0s := installer.NewK0s(hw, env, c.FileWriter) + + if !k0s.BinaryExists() || c.Opts.Force { + err := k0s.Download(c.Opts.Force, false) + if err != nil { + return fmt.Errorf("failed to download k0s: %w", err) + } + } + + err := k0s.Install(c.Opts.Config, c.Opts.Force) + 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, a zero friction Kubernetes distribution, + using a Go-native implementation. This will download the k0s + binary directly to the OMS workdir, if not already present, and install it.`), + Example: formatExamplesWithBinary("install k0s", []packageio.Example{ + {Cmd: "", Desc: "Install k0s using the Go-native implementation"}, + {Cmd: "--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.Config, "config", "c", "", "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 +} diff --git a/cli/cmd/install_k0s_test.go b/cli/cmd/install_k0s_test.go new file mode 100644 index 00000000..8761c377 --- /dev/null +++ b/cli/cmd/install_k0s_test.go @@ -0,0 +1,162 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "errors" + "runtime" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + + "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 ( + installK0sCmd *cmd.InstallK0sCmd + mockEnv *env.MockEnv + mockFileWriter *util.MockFileIO + mockK0sManager *installer.MockK0sManager + ) + + BeforeEach(func() { + mockEnv = env.NewMockEnv(GinkgoT()) + mockFileWriter = util.NewMockFileIO(GinkgoT()) + mockK0sManager = installer.NewMockK0sManager(GinkgoT()) + + installK0sCmd = &cmd.InstallK0sCmd{ + Opts: cmd.InstallK0sOpts{ + GlobalOptions: &cmd.GlobalOptions{}, + Config: "", + Force: false, + }, + Env: mockEnv, + FileWriter: mockFileWriter, + } + }) + + AfterEach(func() { + mockEnv.AssertExpectations(GinkgoT()) + mockFileWriter.AssertExpectations(GinkgoT()) + if mockK0sManager != nil { + mockK0sManager.AssertExpectations(GinkgoT()) + } + }) + + Context("RunE", func() { + It("should successfully handle k0s install integration", func() { + // Add mock expectations for the new BinaryExists and download functionality, intentionally causing create to fail + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() + mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(false).Maybe() + mockFileWriter.EXPECT().Create("/test/workdir/k0s").Return(nil, errors.New("mock create error")).Maybe() + + err := installK0sCmd.RunE(nil, nil) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download k0s")) + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + // Should fail with platform error on non-Linux amd64 platforms + Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) + } else { + // On Linux amd64, it should fail on file creation or network/version fetch since we don't have real network access + Expect(err.Error()).To(ContainSubstring("mock create error")) + } + }) + + It("should download k0s when binary doesn't exist", func() { + // Add mock expectations for the download functionality, intentionally causing create to fail + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() + mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(false).Maybe() + mockFileWriter.EXPECT().Create("/test/workdir/k0s").Return(nil, errors.New("mock create error")).Maybe() + + err := installK0sCmd.RunE(nil, nil) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download k0s")) + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + // Should fail with platform error on non-Linux amd64 platforms + Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) + } else { + // On Linux amd64, it should fail on file creation or network/version fetch since we don't have real network access + Expect(err.Error()).To(ContainSubstring("mock create error")) + } + }) + + It("should skip download when binary exists and force is false", func() { + // Set up the test so that binary exists + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() + mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(true).Maybe() + + err := installK0sCmd.RunE(nil, nil) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install k0s")) + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + // Should fail with platform error on non-Linux amd64 platforms + Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) + } else { + // On Linux amd64, it should fail on file creation or network/version fetch since we don't have real network access + Expect(err.Error()).To(ContainSubstring("no such file or directory")) + } + }) + }) +}) + +var _ = Describe("AddInstallK0sCmd", func() { + var ( + parentCmd *cobra.Command + globalOpts *cmd.GlobalOptions + ) + + BeforeEach(func() { + parentCmd = &cobra.Command{Use: "install"} + globalOpts = &cmd.GlobalOptions{} + }) + + It("adds the k0s command with correct properties and flags", func() { + cmd.AddInstallK0sCmd(parentCmd, globalOpts) + + var k0sCmd *cobra.Command + for _, c := range parentCmd.Commands() { + if c.Use == "k0s" { + k0sCmd = c + break + } + } + + Expect(k0sCmd).NotTo(BeNil()) + Expect(k0sCmd.Use).To(Equal("k0s")) + Expect(k0sCmd.Short).To(Equal("Install k0s Kubernetes distribution")) + Expect(k0sCmd.Long).To(ContainSubstring("Install k0s, a zero friction Kubernetes distribution")) + Expect(k0sCmd.RunE).NotTo(BeNil()) + + Expect(k0sCmd.Parent()).To(Equal(parentCmd)) + Expect(parentCmd.Commands()).To(ContainElement(k0sCmd)) + + // Check flags + configFlag := k0sCmd.Flags().Lookup("config") + Expect(configFlag).NotTo(BeNil()) + Expect(configFlag.Shorthand).To(Equal("c")) + Expect(configFlag.DefValue).To(Equal("")) + Expect(configFlag.Usage).To(Equal("Path to k0s configuration file")) + + forceFlag := k0sCmd.Flags().Lookup("force") + Expect(forceFlag).NotTo(BeNil()) + Expect(forceFlag.Shorthand).To(Equal("f")) + Expect(forceFlag.DefValue).To(Equal("false")) + Expect(forceFlag.Usage).To(Equal("Force new download and installation")) + + // Check examples + Expect(k0sCmd).NotTo(BeNil()) + Expect(k0sCmd.Example).NotTo(BeEmpty()) + Expect(k0sCmd.Example).To(ContainSubstring("oms-cli install k0s")) + Expect(k0sCmd.Example).To(ContainSubstring("--config")) + Expect(k0sCmd.Example).To(ContainSubstring("--force")) + }) +}) diff --git a/internal/installer/config_test.go b/internal/installer/config_test.go index 5f09f46e..96f43df6 100644 --- a/internal/installer/config_test.go +++ b/internal/installer/config_test.go @@ -169,18 +169,6 @@ registry: Expect(err).ToNot(HaveOccurred()) }) }) - - Context("ExtractOciImageIndex with various scenarios", func() { - It("handles empty image file path", func() { - // Test moved to package_test.go - Skip("ExtractOciImageIndex tests moved to package_test.go") - }) - - It("handles directory instead of file", func() { - // Test moved to package_test.go - Skip("ExtractOciImageIndex tests moved to package_test.go") - }) - }) }) Describe("Integration scenarios", func() { diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go new file mode 100644 index 00000000..ae1f50da --- /dev/null +++ b/internal/installer/k0s.go @@ -0,0 +1,126 @@ +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 { + BinaryExists() bool + Download(force bool, quiet bool) error + Install(configPath 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) BinaryExists() bool { + workdir := k.Env.GetOmsWorkdir() + k0sPath := filepath.Join(workdir, "k0s") + return k.FileWriter.Exists(k0sPath) +} + +func (k *K0s) Download(force bool, quiet bool) 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) + } + + // Get the latest k0s version + 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") + } + + // Check if k0s binary already exists and create destination file + workdir := k.Env.GetOmsWorkdir() + k0sPath := filepath.Join(workdir, "k0s") + if k.BinaryExists() && !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 file.Close() + + // 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 nil +} + +func (k *K0s) Install(configPath string, force bool) 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) + } + + workdir := k.Env.GetOmsWorkdir() + k0sPath := filepath.Join(workdir, "k0s") + if !k.BinaryExists() { + return fmt.Errorf("k0s binary does not exist in '%s', please download first", k0sPath) + } + + args := []string{"./k0s", "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, workdir) + if err != nil { + return fmt.Errorf("failed to install k0s: %w", err) + } + + log.Println("k0s installed successfully in single-node mode.") + log.Println("You can start it using 'sudo ./k0s start'") + + return nil +} diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go new file mode 100644 index 00000000..cc055603 --- /dev/null +++ b/internal/installer/k0s_test.go @@ -0,0 +1,337 @@ +// 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()) + }) + }) + + Describe("Download", func() { + Context("Platform support", func() { + It("should fail on non-Linux platforms", func() { + k0sImpl.Goos = "windows" + k0sImpl.Goarch = "amd64" + + err := k0s.Download(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(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 fail when version fetch fails", func() { + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(nil, errors.New("network error")) + + err := k0s.Download(false, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to fetch version info")) + Expect(err.Error()).To(ContainSubstring("network error")) + }) + + It("should fail when version is empty", func() { + emptyVersionBytes := []byte(" \n ") + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(emptyVersionBytes, nil) + + err := k0s.Download(false, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("version info is empty")) + }) + + It("should handle version with whitespace correctly", func() { + versionWithWhitespace := []byte(" v1.29.1+k0s.0 \n") + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(versionWithWhitespace, nil) + 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 realFile.Close() + + 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) + + err = k0s.Download(false, false) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("File existence checks", func() { + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return([]byte("v1.29.1+k0s.0"), nil) + 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(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 realFile.Close() + + 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) + + err = k0s.Download(true, false) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("File operations", func() { + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return([]byte("v1.29.1+k0s.0"), nil) + 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(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 os.Remove(mockFile.Name()) + defer mockFile.Close() + + 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(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 realFile.Close() + + 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) + + err = k0s.Download(false, false) + Expect(err).ToNot(HaveOccurred()) + + // 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" + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return([]byte("v1.29.1+k0s.0"), nil) + + // 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 realFile.Close() + + 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) + + err = k0s.Download(false, false) + Expect(err).ToNot(HaveOccurred()) + }) + }) + }) + + Describe("Install", func() { + Context("Platform support", func() { + It("should fail on non-Linux platforms", func() { + k0sImpl.Goos = "windows" + k0sImpl.Goarch = "amd64" + + err := k0s.Install("", 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.Install("", 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("Binary existence checks", func() { + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) + }) + + It("should fail when k0s binary doesn't exist", func() { + mockFileWriter.EXPECT().Exists(k0sPath).Return(false) + + err := k0s.Install("", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("k0s binary does not exist")) + Expect(err.Error()).To(ContainSubstring("please download first")) + }) + + It("should proceed when k0s binary exists", func() { + mockFileWriter.EXPECT().Exists(k0sPath).Return(true) + + // This will fail with exec error since we can't actually run k0s in tests + // but it will pass the existence check + err := k0s.Install("", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install k0s")) + }) + }) + + Context("Installation modes", func() { + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) + mockFileWriter.EXPECT().Exists(k0sPath).Return(true) + }) + + It("should install in single-node mode when no config path is provided", func() { + // This will fail with exec error but we can verify the command would be called + // The command should be: ./k0s install controller --single + err := k0s.Install("", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install k0s")) + }) + + It("should install with custom config when config path is provided", func() { + configPath := "/path/to/k0s.yaml" + // This will fail with exec error but we can verify the command would be called + // The command should be: ./k0s install controller --config /path/to/k0s.yaml + err := k0s.Install(configPath, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install k0s")) + }) + + It("should install with custom config and force flag", func() { + configPath := "/path/to/k0s.yaml" + // This will fail with exec error but we can verify the command would be called + // The command should be: ./k0s install controller --config /path/to/k0s.yaml --force + err := k0s.Install(configPath, true) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install k0s")) + }) + }) + }) +}) diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 9779117e..0057cfbd 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -91,6 +91,169 @@ func (_c *MockConfigManager_ParseConfigYaml_Call) RunAndReturn(run func(configPa 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} +} + +// BinaryExists provides a mock function for the type MockK0sManager +func (_mock *MockK0sManager) BinaryExists() bool { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for BinaryExists") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func() bool); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockK0sManager_BinaryExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BinaryExists' +type MockK0sManager_BinaryExists_Call struct { + *mock.Call +} + +// BinaryExists is a helper method to define mock.On call +func (_e *MockK0sManager_Expecter) BinaryExists() *MockK0sManager_BinaryExists_Call { + return &MockK0sManager_BinaryExists_Call{Call: _e.mock.On("BinaryExists")} +} + +func (_c *MockK0sManager_BinaryExists_Call) Run(run func()) *MockK0sManager_BinaryExists_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockK0sManager_BinaryExists_Call) Return(b bool) *MockK0sManager_BinaryExists_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockK0sManager_BinaryExists_Call) RunAndReturn(run func() bool) *MockK0sManager_BinaryExists_Call { + _c.Call.Return(run) + return _c +} + +// Download provides a mock function for the type MockK0sManager +func (_mock *MockK0sManager) Download(force bool, quiet bool) error { + ret := _mock.Called(force, quiet) + + if len(ret) == 0 { + panic("no return value specified for Download") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(bool, bool) error); ok { + r0 = returnFunc(force, quiet) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// 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 +// - force +// - quiet +func (_e *MockK0sManager_Expecter) Download(force interface{}, quiet interface{}) *MockK0sManager_Download_Call { + return &MockK0sManager_Download_Call{Call: _e.mock.On("Download", force, quiet)} +} + +func (_c *MockK0sManager_Download_Call) Run(run func(force bool, quiet bool)) *MockK0sManager_Download_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(bool), args[1].(bool)) + }) + return _c +} + +func (_c *MockK0sManager_Download_Call) Return(err error) *MockK0sManager_Download_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockK0sManager_Download_Call) RunAndReturn(run func(force bool, quiet bool) error) *MockK0sManager_Download_Call { + _c.Call.Return(run) + return _c +} + +// Install provides a mock function for the type MockK0sManager +func (_mock *MockK0sManager) Install(configPath string, force bool) error { + ret := _mock.Called(configPath, force) + + if len(ret) == 0 { + panic("no return value specified for Install") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = returnFunc(configPath, 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 +// - force +func (_e *MockK0sManager_Expecter) Install(configPath interface{}, force interface{}) *MockK0sManager_Install_Call { + return &MockK0sManager_Install_Call{Call: _e.mock.On("Install", configPath, force)} +} + +func (_c *MockK0sManager_Install_Call) Run(run func(configPath string, force bool)) *MockK0sManager_Install_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(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, 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/portal/http.go b/internal/portal/http.go index ddd7497a..935e6c9a 100644 --- a/internal/portal/http.go +++ b/internal/portal/http.go @@ -4,192 +4,73 @@ 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, 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) 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 - } - apiKey, err := c.Env.GetOmsPortalApiKey() - if err != nil { - err = fmt.Errorf("failed to get API Key: %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") - } - - req.Header.Set("X-API-Key", apiKey) - - resp, err = c.HttpClient.Do(req) + resp, err := c.HttpClient.Do(req) if err != nil { - err = fmt.Errorf("failed to send request: %w", err) - return + return []byte{}, fmt.Errorf("failed to send request: %w", err) } + defer resp.Body.Close() - 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") + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return []byte{}, fmt.Errorf("failed request with status: %d", resp.StatusCode) } - 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) 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) + respBody, err := io.ReadAll(resp.Body) if err != nil { - err = fmt.Errorf("failed to read response body: %w", err) - return + return []byte{}, fmt.Errorf("failed to read response body: %w", err) } - return + return respBody, nil } -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 +func (c *HttpWrapper) Get(url string) (responseBody []byte, err error) { + return c.Request(url, http.MethodGet, nil) } -func (c *PortalClient) GetBuild(product Product, version string, hash string) (Build, error) { - packages, err := c.ListBuilds(product) +func (c *HttpWrapper) Download(url string, file io.Writer, quiet bool) error { + req, err := http.NewRequest(http.MethodGet, url, nil) 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") + return fmt.Errorf("failed to create request: %w", err) } - 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 *PortalClient) DownloadBuildArtifact(product Product, build Build, file io.Writer, quiet bool) error { - reqBody, err := json.Marshal(build) + resp, err := c.HttpClient.Do(req) if err != nil { - return fmt.Errorf("failed to generate request body: %w", err) + return fmt.Errorf("failed to send request: %w", err) } + defer resp.Body.Close() - resp, err := c.HttpRequest(http.MethodGet, fmt.Sprintf("/packages/%s/download", product), reqBody) - 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) @@ -203,97 +84,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 9aff801d..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,343 +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 - 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 - 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("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("downloads the build", func() { - fakeWriter := NewFakeWriter() - err := client.DownloadBuildArtifact(product, build, fakeWriter, false) - Expect(err).NotTo(HaveOccurred()) - Expect(fakeWriter.String()).To(Equal(downloadResponse)) - Expect(getUrl.String()).To(Equal("fake-portal.com/packages/codesphere/download")) + 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, 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, 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 857676d5..91f7a2b7 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..ddd7497a --- /dev/null +++ b/internal/portal/portal.go @@ -0,0 +1,299 @@ +// 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, 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" +) + +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 + } + apiKey, err := c.Env.GetOmsPortalApiKey() + if err != nil { + err = fmt.Errorf("failed to get API Key: %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") + } + + 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) 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 +} + +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 +} + +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 *PortalClient) DownloadBuildArtifact(product Product, build Build, file io.Writer, quiet bool) error { + reqBody, err := json.Marshal(build) + if err != nil { + return fmt.Errorf("failed to generate request body: %w", err) + } + + resp, err := c.HttpRequest(http.MethodGet, fmt.Sprintf("/packages/%s/download", product), reqBody) + 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 +} + +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/portal_test.go b/internal/portal/portal_test.go new file mode 100644 index 00000000..9aff801d --- /dev/null +++ b/internal/portal/portal_test.go @@ -0,0 +1,363 @@ +// 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 + 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 + 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, false) + Expect(err).NotTo(HaveOccurred()) + Expect(fakeWriter.String()).To(Equal(downloadResponse)) + Expect(getUrl.String()).To(Equal("fake-portal.com/packages/codesphere/download")) + }) + + It("emits progress logs when not quiet", func() { + var logBuf bytes.Buffer + prev := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(prev) + + fakeWriter := NewFakeWriter() + err := client.DownloadBuildArtifact(product, build, fakeWriter, false) + Expect(err).NotTo(HaveOccurred()) + Expect(logBuf.String()).To(ContainSubstring("Downloading...")) + }) + + It("does not emit progress logs when quiet", func() { + var logBuf bytes.Buffer + prev := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(prev) + + fakeWriter := NewFakeWriter() + err := client.DownloadBuildArtifact(product, build, fakeWriter, true) + Expect(err).NotTo(HaveOccurred()) + Expect(logBuf.String()).NotTo(ContainSubstring("Downloading...")) + }) + }) + + Describe("GetLatestOmsBuild", func() { + 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..06290b8e --- /dev/null +++ b/internal/util/command.go @@ -0,0 +1,26 @@ +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 +} From 5cb3517298f6616d4a2360a8e8bd46786f0e0028 Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Wed, 5 Nov 2025 16:30:49 +0100 Subject: [PATCH 02/20] update: update logs with oms workdir for k0s start command --- internal/installer/k0s.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index ae1f50da..d49157cf 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -120,7 +120,8 @@ func (k *K0s) Install(configPath string, force bool) error { } log.Println("k0s installed successfully in single-node mode.") - log.Println("You can start it using 'sudo ./k0s start'") + log.Printf("You can start it using 'sudo %v/k0s start'", workdir) + log.Printf("You can check the status using 'sudo %v/k0s status'", workdir) return nil } From b41ce6cbed9c50eb7b1d8135debbc2732923eca1 Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Wed, 5 Nov 2025 16:52:50 +0100 Subject: [PATCH 03/20] fix: fix lint errors --- internal/installer/k0s.go | 2 +- internal/installer/k0s_test.go | 8 ++++---- internal/portal/http.go | 8 ++++++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index d49157cf..123c2dad 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -70,7 +70,7 @@ func (k *K0s) Download(force bool, quiet bool) error { if err != nil { return fmt.Errorf("failed to create k0s binary file: %w", err) } - defer file.Close() + defer util.CloseFileIgnoreError(file) // Download using the portal Http wrapper with WriteCounter log.Printf("Downloading k0s version %s", version) diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go index cc055603..ad543656 100644 --- a/internal/installer/k0s_test.go +++ b/internal/installer/k0s_test.go @@ -117,7 +117,7 @@ var _ = Describe("K0s", func() { // Create a real file for the test realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) - defer realFile.Close() + 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) @@ -154,7 +154,7 @@ var _ = Describe("K0s", func() { // Create a real file for the test realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) - defer realFile.Close() + 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) @@ -205,7 +205,7 @@ var _ = Describe("K0s", func() { realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) - defer realFile.Close() + 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) @@ -238,7 +238,7 @@ var _ = Describe("K0s", func() { // Create a real file for the test realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) - defer realFile.Close() + 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) diff --git a/internal/portal/http.go b/internal/portal/http.go index 935e6c9a..4f91a573 100644 --- a/internal/portal/http.go +++ b/internal/portal/http.go @@ -37,7 +37,9 @@ func (c *HttpWrapper) Request(url string, method string, body io.Reader) (respon if err != nil { return []byte{}, fmt.Errorf("failed to send request: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return []byte{}, fmt.Errorf("failed request with status: %d", resp.StatusCode) @@ -65,7 +67,9 @@ func (c *HttpWrapper) Download(url string, file io.Writer, quiet bool) error { if err != nil { return fmt.Errorf("failed to send request: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("failed to get body: %d", resp.StatusCode) From 2e333c6be271a686840876e42c0988a81ff4350d Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Wed, 5 Nov 2025 16:55:01 +0100 Subject: [PATCH 04/20] fix: fix lint errors --- internal/installer/k0s_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go index ad543656..7cc8d149 100644 --- a/internal/installer/k0s_test.go +++ b/internal/installer/k0s_test.go @@ -186,8 +186,10 @@ var _ = Describe("K0s", func() { // Create a mock file for the test mockFile, err := os.CreateTemp("", "k0s-test") Expect(err).ToNot(HaveOccurred()) - defer os.Remove(mockFile.Name()) - defer mockFile.Close() + 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")) From 66a7ed3388669da778f3f8e7d1c63d60460a3b9a Mon Sep 17 00:00:00 2001 From: siherrmann <25087590+siherrmann@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:58:24 +0000 Subject: [PATCH 05/20] chore(docs): Auto-update docs and licenses Signed-off-by: siherrmann <25087590+siherrmann@users.noreply.github.com> --- docs/README.md | 2 +- docs/oms-cli.md | 2 +- docs/oms-cli_beta.md | 2 +- docs/oms-cli_beta_extend.md | 2 +- docs/oms-cli_beta_extend_baseimage.md | 2 +- docs/oms-cli_build.md | 2 +- docs/oms-cli_build_image.md | 2 +- docs/oms-cli_build_images.md | 2 +- docs/oms-cli_download.md | 3 +- docs/oms-cli_download_k0s.md | 41 +++++++++++++++++++++++++++ docs/oms-cli_download_package.md | 2 +- docs/oms-cli_install.md | 3 +- docs/oms-cli_install_codesphere.md | 2 +- docs/oms-cli_install_k0s.md | 41 +++++++++++++++++++++++++++ docs/oms-cli_licenses.md | 2 +- docs/oms-cli_list.md | 2 +- docs/oms-cli_list_api-keys.md | 2 +- docs/oms-cli_list_packages.md | 2 +- docs/oms-cli_register.md | 2 +- docs/oms-cli_revoke.md | 2 +- docs/oms-cli_revoke_api-key.md | 2 +- docs/oms-cli_update.md | 2 +- docs/oms-cli_update_api-key.md | 2 +- docs/oms-cli_update_dockerfile.md | 2 +- docs/oms-cli_update_oms.md | 2 +- docs/oms-cli_update_package.md | 2 +- docs/oms-cli_version.md | 2 +- internal/installer/k0s.go | 3 ++ internal/util/command.go | 3 ++ 29 files changed, 115 insertions(+), 25 deletions(-) create mode 100644 docs/oms-cli_download_k0s.md create mode 100644 docs/oms-cli_install_k0s.md diff --git a/docs/README.md b/docs/README.md index c1b30490..689000b1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,4 +28,4 @@ like downloading new versions. * [oms-cli update](oms-cli_update.md) - Update OMS related resources * [oms-cli version](oms-cli_version.md) - Print version -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli.md b/docs/oms-cli.md index c1b30490..689000b1 100644 --- a/docs/oms-cli.md +++ b/docs/oms-cli.md @@ -28,4 +28,4 @@ like downloading new versions. * [oms-cli update](oms-cli_update.md) - Update OMS related resources * [oms-cli version](oms-cli_version.md) - Print version -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_beta.md b/docs/oms-cli_beta.md index 1cbccfc1..5449a11e 100644 --- a/docs/oms-cli_beta.md +++ b/docs/oms-cli_beta.md @@ -18,4 +18,4 @@ Be aware that that usage and behavior may change as the features are developed. * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) * [oms-cli beta extend](oms-cli_beta_extend.md) - Extend Codesphere ressources such as base images. -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_beta_extend.md b/docs/oms-cli_beta_extend.md index 567ebef3..4022a7a5 100644 --- a/docs/oms-cli_beta_extend.md +++ b/docs/oms-cli_beta_extend.md @@ -17,4 +17,4 @@ Extend Codesphere ressources such as base images to customize them for your need * [oms-cli beta](oms-cli_beta.md) - Commands for early testing * [oms-cli beta extend baseimage](oms-cli_beta_extend_baseimage.md) - Extend Codesphere's workspace base image for customization -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_beta_extend_baseimage.md b/docs/oms-cli_beta_extend_baseimage.md index 2b2569e0..10be7546 100644 --- a/docs/oms-cli_beta_extend_baseimage.md +++ b/docs/oms-cli_beta_extend_baseimage.md @@ -28,4 +28,4 @@ oms-cli beta extend baseimage [flags] * [oms-cli beta extend](oms-cli_beta_extend.md) - Extend Codesphere ressources such as base images. -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_build.md b/docs/oms-cli_build.md index 8f200629..881d263d 100644 --- a/docs/oms-cli_build.md +++ b/docs/oms-cli_build.md @@ -18,4 +18,4 @@ Build and push container images to a registry using the provided configuration. * [oms-cli build image](oms-cli_build_image.md) - Build and push Docker image using Dockerfile and Codesphere package version * [oms-cli build images](oms-cli_build_images.md) - Build and push container images -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_build_image.md b/docs/oms-cli_build_image.md index 0964fae6..37a3e284 100644 --- a/docs/oms-cli_build_image.md +++ b/docs/oms-cli_build_image.md @@ -31,4 +31,4 @@ $ oms-cli build image --dockerfile baseimage/Dockerfile --package codesphere-v1. * [oms-cli build](oms-cli_build.md) - Build and push images to a registry -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_build_images.md b/docs/oms-cli_build_images.md index db98c6de..e2309618 100644 --- a/docs/oms-cli_build_images.md +++ b/docs/oms-cli_build_images.md @@ -22,4 +22,4 @@ oms-cli build images [flags] * [oms-cli build](oms-cli_build.md) - Build and push images to a registry -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_download.md b/docs/oms-cli_download.md index c34ab2e9..c5d8efb8 100644 --- a/docs/oms-cli_download.md +++ b/docs/oms-cli_download.md @@ -16,6 +16,7 @@ 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 -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_download_k0s.md b/docs/oms-cli_download_k0s.md new file mode 100644 index 00000000..57105d47 --- /dev/null +++ b/docs/oms-cli_download_k0s.md @@ -0,0 +1,41 @@ +## oms-cli download k0s + +Download k0s Kubernetes distribution + +### Synopsis + +Download k0s, a zero friction Kubernetes distribution, +using a Go-native implementation. This will download the k0s +binary directly to the OMS workdir. + +``` +oms-cli download k0s [flags] +``` + +### Examples + +``` +# Download k0s using the Go-native implementation +$ oms-cli download k0s + +# 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 +``` + +### SEE ALSO + +* [oms-cli download](oms-cli_download.md) - Download resources available through OMS + +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_download_package.md b/docs/oms-cli_download_package.md index b9e323bf..b2df39a4 100644 --- a/docs/oms-cli_download_package.md +++ b/docs/oms-cli_download_package.md @@ -36,4 +36,4 @@ $ oms-cli download package --version codesphere-v1.55.0 --file installer-lite.ta * [oms-cli download](oms-cli_download.md) - Download resources available through OMS -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_install.md b/docs/oms-cli_install.md index c3530a5b..41dcf504 100644 --- a/docs/oms-cli_install.md +++ b/docs/oms-cli_install.md @@ -16,5 +16,6 @@ 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 -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_install_codesphere.md b/docs/oms-cli_install_codesphere.md index 716f2c53..29816e92 100644 --- a/docs/oms-cli_install_codesphere.md +++ b/docs/oms-cli_install_codesphere.md @@ -26,4 +26,4 @@ oms-cli install codesphere [flags] * [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_install_k0s.md b/docs/oms-cli_install_k0s.md new file mode 100644 index 00000000..4f088806 --- /dev/null +++ b/docs/oms-cli_install_k0s.md @@ -0,0 +1,41 @@ +## oms-cli install k0s + +Install k0s Kubernetes distribution + +### Synopsis + +Install k0s, a zero friction Kubernetes distribution, +using a Go-native implementation. This will download the k0s +binary directly to the OMS workdir, if not already present, and install it. + +``` +oms-cli install k0s [flags] +``` + +### Examples + +``` +# Install k0s using the Go-native implementation +$ oms-cli install k0s + +# Path to k0s configuration file, if not set k0s will be installed with the '--single' flag +$ oms-cli install k0s --config + +# Force new download and installation even if k0s binary exists or is already installed +$ oms-cli install k0s --force + +``` + +### Options + +``` + -c, --config string Path to k0s configuration file + -f, --force Force new download and installation + -h, --help help for k0s +``` + +### SEE ALSO + +* [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components + +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_licenses.md b/docs/oms-cli_licenses.md index 23d01f97..e2fb56df 100644 --- a/docs/oms-cli_licenses.md +++ b/docs/oms-cli_licenses.md @@ -20,4 +20,4 @@ oms-cli licenses [flags] * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_list.md b/docs/oms-cli_list.md index 3bb421d5..781c5eee 100644 --- a/docs/oms-cli_list.md +++ b/docs/oms-cli_list.md @@ -19,4 +19,4 @@ eg. available Codesphere packages * [oms-cli list api-keys](oms-cli_list_api-keys.md) - List API keys * [oms-cli list packages](oms-cli_list_packages.md) - List available packages -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_list_api-keys.md b/docs/oms-cli_list_api-keys.md index 02274e0b..9cec7b1e 100644 --- a/docs/oms-cli_list_api-keys.md +++ b/docs/oms-cli_list_api-keys.md @@ -20,4 +20,4 @@ oms-cli list api-keys [flags] * [oms-cli list](oms-cli_list.md) - List resources available through OMS -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_list_packages.md b/docs/oms-cli_list_packages.md index 9cb4e521..f6d76165 100644 --- a/docs/oms-cli_list_packages.md +++ b/docs/oms-cli_list_packages.md @@ -20,4 +20,4 @@ oms-cli list packages [flags] * [oms-cli list](oms-cli_list.md) - List resources available through OMS -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_register.md b/docs/oms-cli_register.md index 7e7b93ce..8a152d48 100644 --- a/docs/oms-cli_register.md +++ b/docs/oms-cli_register.md @@ -24,4 +24,4 @@ oms-cli register [flags] * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_revoke.md b/docs/oms-cli_revoke.md index cac7846d..541c96c8 100644 --- a/docs/oms-cli_revoke.md +++ b/docs/oms-cli_revoke.md @@ -18,4 +18,4 @@ eg. api keys. * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) * [oms-cli revoke api-key](oms-cli_revoke_api-key.md) - Revoke an API key -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_revoke_api-key.md b/docs/oms-cli_revoke_api-key.md index a2746f85..738a5543 100644 --- a/docs/oms-cli_revoke_api-key.md +++ b/docs/oms-cli_revoke_api-key.md @@ -21,4 +21,4 @@ oms-cli revoke api-key [flags] * [oms-cli revoke](oms-cli_revoke.md) - Revoke resources available through OMS -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update.md b/docs/oms-cli_update.md index f8b93b90..d9e2f3dd 100644 --- a/docs/oms-cli_update.md +++ b/docs/oms-cli_update.md @@ -24,4 +24,4 @@ oms-cli update [flags] * [oms-cli update oms](oms-cli_update_oms.md) - Update the OMS CLI * [oms-cli update package](oms-cli_update_package.md) - Download a codesphere package -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update_api-key.md b/docs/oms-cli_update_api-key.md index 8d811f06..257fcfb7 100644 --- a/docs/oms-cli_update_api-key.md +++ b/docs/oms-cli_update_api-key.md @@ -22,4 +22,4 @@ oms-cli update api-key [flags] * [oms-cli update](oms-cli_update.md) - Update OMS related resources -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update_dockerfile.md b/docs/oms-cli_update_dockerfile.md index 57658229..14b84f39 100644 --- a/docs/oms-cli_update_dockerfile.md +++ b/docs/oms-cli_update_dockerfile.md @@ -38,4 +38,4 @@ $ oms-cli update dockerfile --dockerfile baseimage/Dockerfile --package codesphe * [oms-cli update](oms-cli_update.md) - Update OMS related resources -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update_oms.md b/docs/oms-cli_update_oms.md index 9d36592f..b0ef6900 100644 --- a/docs/oms-cli_update_oms.md +++ b/docs/oms-cli_update_oms.md @@ -20,4 +20,4 @@ oms-cli update oms [flags] * [oms-cli update](oms-cli_update.md) - Update OMS related resources -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update_package.md b/docs/oms-cli_update_package.md index 9d2d28b0..34468393 100644 --- a/docs/oms-cli_update_package.md +++ b/docs/oms-cli_update_package.md @@ -36,4 +36,4 @@ $ oms-cli download package --version codesphere-v1.55.0 --file installer-lite.ta * [oms-cli update](oms-cli_update.md) - Update OMS related resources -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_version.md b/docs/oms-cli_version.md index bea79035..cb0bde01 100644 --- a/docs/oms-cli_version.md +++ b/docs/oms-cli_version.md @@ -20,4 +20,4 @@ oms-cli version [flags] * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) -###### Auto generated by spf13/cobra on 3-Nov-2025 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index 123c2dad..1a45540e 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -1,3 +1,6 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + package installer import ( diff --git a/internal/util/command.go b/internal/util/command.go index 06290b8e..6b55d6a6 100644 --- a/internal/util/command.go +++ b/internal/util/command.go @@ -1,3 +1,6 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + package util import ( From 43e5f95e91140434c790bc2a0f4ddaa91a3fc22b Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Mon, 10 Nov 2025 17:35:04 +0100 Subject: [PATCH 06/20] update: update install k0s command to also use package --- cli/cmd/download_k0s.go | 34 ++++- cli/cmd/download_k0s_test.go | 130 ++++++++-------- cli/cmd/install_k0s.go | 50 +++++-- cli/cmd/install_k0s_test.go | 165 ++++++++------------- internal/installer/k0s.go | 66 ++++----- internal/installer/k0s_test.go | 153 +++++++++---------- internal/installer/mocks.go | 118 ++++++++------- internal/installer/package.go | 12 +- internal/installer/package_test.go | 228 +++-------------------------- 9 files changed, 385 insertions(+), 571 deletions(-) diff --git a/cli/cmd/download_k0s.go b/cli/cmd/download_k0s.go index 513426bb..19401277 100644 --- a/cli/cmd/download_k0s.go +++ b/cli/cmd/download_k0s.go @@ -5,6 +5,7 @@ package cmd import ( "fmt" + "log" packageio "github.com/codesphere-cloud/cs-go/pkg/io" "github.com/spf13/cobra" @@ -25,8 +26,9 @@ type DownloadK0sCmd struct { type DownloadK0sOpts struct { *GlobalOptions - Force bool - Quiet bool + Version string + Force bool + Quiet bool } func (c *DownloadK0sCmd) RunE(_ *cobra.Command, args []string) error { @@ -34,7 +36,7 @@ func (c *DownloadK0sCmd) RunE(_ *cobra.Command, args []string) error { env := c.Env k0s := installer.NewK0s(hw, env, c.FileWriter) - err := k0s.Download(c.Opts.Force, c.Opts.Quiet) + err := c.DownloadK0s(k0s) if err != nil { return fmt.Errorf("failed to download k0s: %w", err) } @@ -47,11 +49,11 @@ func AddDownloadK0sCmd(download *cobra.Command, opts *GlobalOptions) { cmd: &cobra.Command{ Use: "k0s", Short: "Download k0s Kubernetes distribution", - Long: packageio.Long(`Download k0s, a zero friction Kubernetes distribution, - using a Go-native implementation. This will download the k0s - binary directly to the OMS workdir.`), + 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"), @@ -60,6 +62,7 @@ func AddDownloadK0sCmd(download *cobra.Command, opts *GlobalOptions) { 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") @@ -67,3 +70,22 @@ func AddDownloadK0sCmd(download *cobra.Command, opts *GlobalOptions) { k0s.cmd.RunE = k0s.RunE } + +func (c *DownloadK0sCmd) DownloadK0s(k0s installer.K0sManager) error { + if c.Opts.Version == "" { + version, err := k0s.GetLatestVersion() + if err != nil { + return fmt.Errorf("failed to get latest k0s version: %w", err) + } + c.Opts.Version = version + } + + k0sPath, err := k0s.Download(c.Opts.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 index 37fb2208..8b6fc46a 100644 --- a/cli/cmd/download_k0s_test.go +++ b/cli/cmd/download_k0s_test.go @@ -5,20 +5,21 @@ package cmd_test import ( "errors" - "runtime" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/spf13/cobra" "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 ( - downloadK0sCmd *cmd.DownloadK0sCmd + c cmd.DownloadK0sCmd + opts *cmd.DownloadK0sOpts + globalOpts *cmd.GlobalOptions mockEnv *env.MockEnv mockFileWriter *util.MockFileIO ) @@ -26,13 +27,15 @@ var _ = Describe("DownloadK0sCmd", func() { BeforeEach(func() { mockEnv = env.NewMockEnv(GinkgoT()) mockFileWriter = util.NewMockFileIO(GinkgoT()) - - downloadK0sCmd = &cmd.DownloadK0sCmd{ - Opts: cmd.DownloadK0sOpts{ - GlobalOptions: &cmd.GlobalOptions{}, - Force: false, - Quiet: false, - }, + globalOpts = &cmd.GlobalOptions{} + opts = &cmd.DownloadK0sOpts{ + GlobalOptions: globalOpts, + Version: "", + Force: false, + Quiet: false, + } + c = cmd.DownloadK0sCmd{ + Opts: *opts, Env: mockEnv, FileWriter: mockFileWriter, } @@ -43,77 +46,60 @@ var _ = Describe("DownloadK0sCmd", func() { mockFileWriter.AssertExpectations(GinkgoT()) }) - Context("RunE", func() { - It("should successfully handle k0s download integration", func() { - // Add mock expectations for the download functionality, intentionally causing create to fail - mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() - mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(false).Maybe() - mockFileWriter.EXPECT().Create("/test/workdir/k0s").Return(nil, errors.New("mock create error")).Maybe() + Context("RunE method", func() { + It("calls DownloadK0s and fails with network error", func() { + err := c.RunE(nil, []string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download k0s")) + }) + }) + + 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()) - err := downloadK0sCmd.RunE(nil, nil) + 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")) - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - // Should fail with platform error on non-Linux amd64 platforms - Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) - } else { - // On Linux amd64, it should fail on network/version fetch since we don't have real network access - Expect(err.Error()).To(ContainSubstring("mock create error")) - } + Expect(err.Error()).To(ContainSubstring("download failed")) }) - }) -}) -var _ = Describe("AddDownloadK0sCmd", func() { - var ( - parentCmd *cobra.Command - globalOpts *cmd.GlobalOptions - ) + It("succeeds when version is specified and download works", func() { + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - BeforeEach(func() { - parentCmd = &cobra.Command{Use: "download"} - globalOpts = &cmd.GlobalOptions{} - }) + 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("adds the k0s command with correct properties and flags", func() { - cmd.AddDownloadK0sCmd(parentCmd, globalOpts) + It("succeeds when version is auto-detected and download works", func() { + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - var k0sCmd *cobra.Command - for _, c := range parentCmd.Commands() { - if c.Use == "k0s" { - k0sCmd = c - break - } - } + 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) - Expect(k0sCmd).NotTo(BeNil()) - Expect(k0sCmd.Use).To(Equal("k0s")) - Expect(k0sCmd.Short).To(Equal("Download k0s Kubernetes distribution")) - Expect(k0sCmd.Long).To(ContainSubstring("Download k0s, a zero friction Kubernetes distribution")) - Expect(k0sCmd.Long).To(ContainSubstring("using a Go-native implementation")) - Expect(k0sCmd.RunE).NotTo(BeNil()) - - Expect(k0sCmd.Parent()).To(Equal(parentCmd)) - Expect(parentCmd.Commands()).To(ContainElement(k0sCmd)) - - // Check flags - forceFlag := k0sCmd.Flags().Lookup("force") - Expect(forceFlag).NotTo(BeNil()) - Expect(forceFlag.Shorthand).To(Equal("f")) - Expect(forceFlag.DefValue).To(Equal("false")) - Expect(forceFlag.Usage).To(Equal("Force download even if k0s binary exists")) - - quietFlag := k0sCmd.Flags().Lookup("quiet") - Expect(quietFlag).NotTo(BeNil()) - Expect(quietFlag.Shorthand).To(Equal("q")) - Expect(quietFlag.DefValue).To(Equal("false")) - Expect(quietFlag.Usage).To(Equal("Suppress progress output during download")) - - // Check examples - Expect(k0sCmd.Example).NotTo(BeEmpty()) - Expect(k0sCmd.Example).To(ContainSubstring("oms-cli download k0s")) - Expect(k0sCmd.Example).To(ContainSubstring("--quiet")) - Expect(k0sCmd.Example).To(ContainSubstring("--force")) + err := c.DownloadK0s(mockK0sManager) + Expect(err).ToNot(HaveOccurred()) + }) }) }) diff --git a/cli/cmd/install_k0s.go b/cli/cmd/install_k0s.go index 27129554..28c44548 100644 --- a/cli/cmd/install_k0s.go +++ b/cli/cmd/install_k0s.go @@ -25,23 +25,19 @@ type InstallK0sCmd struct { type InstallK0sOpts struct { *GlobalOptions - Config string - Force bool + 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) - if !k0s.BinaryExists() || c.Opts.Force { - err := k0s.Download(c.Opts.Force, false) - if err != nil { - return fmt.Errorf("failed to download k0s: %w", err) - } - } - - err := k0s.Install(c.Opts.Config, c.Opts.Force) + err := c.InstallK0s(pm, k0s) if err != nil { return fmt.Errorf("failed to install k0s: %w", err) } @@ -54,11 +50,15 @@ func AddInstallK0sCmd(install *cobra.Command, opts *GlobalOptions) { cmd: &cobra.Command{ Use: "k0s", Short: "Install k0s Kubernetes distribution", - Long: packageio.Long(`Install k0s, a zero friction Kubernetes distribution, - using a Go-native implementation. This will download the k0s - binary directly to the OMS workdir, if not already present, and install it.`), + 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: "--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"), @@ -67,6 +67,8 @@ func AddInstallK0sCmd(install *cobra.Command, opts *GlobalOptions) { 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().StringVarP(&k0s.Opts.Config, "config", "c", "", "Path to k0s configuration file") k0s.cmd.Flags().BoolVarP(&k0s.Opts.Force, "force", "f", false, "Force new download and installation") @@ -74,3 +76,25 @@ func AddInstallK0sCmd(install *cobra.Command, opts *GlobalOptions) { 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 index 8761c377..e1899122 100644 --- a/cli/cmd/install_k0s_test.go +++ b/cli/cmd/install_k0s_test.go @@ -5,11 +5,9 @@ package cmd_test import ( "errors" - "runtime" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/spf13/cobra" "github.com/codesphere-cloud/oms/cli/cmd" "github.com/codesphere-cloud/oms/internal/env" @@ -19,23 +17,26 @@ import ( var _ = Describe("InstallK0sCmd", func() { var ( - installK0sCmd *cmd.InstallK0sCmd + c cmd.InstallK0sCmd + opts *cmd.InstallK0sOpts + globalOpts *cmd.GlobalOptions mockEnv *env.MockEnv mockFileWriter *util.MockFileIO - mockK0sManager *installer.MockK0sManager ) BeforeEach(func() { mockEnv = env.NewMockEnv(GinkgoT()) mockFileWriter = util.NewMockFileIO(GinkgoT()) - mockK0sManager = installer.NewMockK0sManager(GinkgoT()) - - installK0sCmd = &cmd.InstallK0sCmd{ - Opts: cmd.InstallK0sOpts{ - GlobalOptions: &cmd.GlobalOptions{}, - Config: "", - Force: false, - }, + globalOpts = &cmd.GlobalOptions{} + opts = &cmd.InstallK0sOpts{ + GlobalOptions: globalOpts, + Version: "", + Package: "", + Config: "", + Force: false, + } + c = cmd.InstallK0sCmd{ + Opts: *opts, Env: mockEnv, FileWriter: mockFileWriter, } @@ -44,119 +45,75 @@ var _ = Describe("InstallK0sCmd", func() { AfterEach(func() { mockEnv.AssertExpectations(GinkgoT()) mockFileWriter.AssertExpectations(GinkgoT()) - if mockK0sManager != nil { - mockK0sManager.AssertExpectations(GinkgoT()) - } }) - Context("RunE", func() { - It("should successfully handle k0s install integration", func() { - // Add mock expectations for the new BinaryExists and download functionality, intentionally causing create to fail - mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() - mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(false).Maybe() - mockFileWriter.EXPECT().Create("/test/workdir/k0s").Return(nil, errors.New("mock create error")).Maybe() - - err := installK0sCmd.RunE(nil, nil) + Context("RunE method", func() { + It("calls InstallK0s and fails with network error", func() { + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir") + err := c.RunE(nil, []string{}) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to download k0s")) - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - // Should fail with platform error on non-Linux amd64 platforms - Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) - } else { - // On Linux amd64, it should fail on file creation or network/version fetch since we don't have real network access - Expect(err.Error()).To(ContainSubstring("mock create error")) - } + Expect(err.Error()).To(ContainSubstring("failed to install k0s")) }) + }) - It("should download k0s when binary doesn't exist", func() { - // Add mock expectations for the download functionality, intentionally causing create to fail - mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() - mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(false).Maybe() - mockFileWriter.EXPECT().Create("/test/workdir/k0s").Return(nil, errors.New("mock create error")).Maybe() + Context("InstallK0s method", func() { + It("fails when package is not specified and k0s download fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - err := installK0sCmd.RunE(nil, nil) + 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")) - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - // Should fail with platform error on non-Linux amd64 platforms - Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) - } else { - // On Linux amd64, it should fail on file creation or network/version fetch since we don't have real network access - Expect(err.Error()).To(ContainSubstring("mock create error")) - } + Expect(err.Error()).To(ContainSubstring("download failed")) }) - It("should skip download when binary exists and force is false", func() { - // Set up the test so that binary exists - mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() - mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(true).Maybe() + It("fails when k0s install fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - err := installK0sCmd.RunE(nil, nil) + 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")) - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - // Should fail with platform error on non-Linux amd64 platforms - Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) - } else { - // On Linux amd64, it should fail on file creation or network/version fetch since we don't have real network access - Expect(err.Error()).To(ContainSubstring("no such file or directory")) - } + Expect(err.Error()).To(ContainSubstring("install failed")) }) - }) -}) -var _ = Describe("AddInstallK0sCmd", func() { - var ( - parentCmd *cobra.Command - globalOpts *cmd.GlobalOptions - ) + It("succeeds when package is not specified and k0s download and install work", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - BeforeEach(func() { - parentCmd = &cobra.Command{Use: "install"} - globalOpts = &cmd.GlobalOptions{} - }) + 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) - It("adds the k0s command with correct properties and flags", func() { - cmd.AddInstallK0sCmd(parentCmd, globalOpts) + err := c.InstallK0s(mockPackageManager, mockK0sManager) + Expect(err).ToNot(HaveOccurred()) + }) - var k0sCmd *cobra.Command - for _, c := range parentCmd.Commands() { - if c.Use == "k0s" { - k0sCmd = c - break - } - } + It("succeeds when package is specified and k0s install works", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - Expect(k0sCmd).NotTo(BeNil()) - Expect(k0sCmd.Use).To(Equal("k0s")) - Expect(k0sCmd.Short).To(Equal("Install k0s Kubernetes distribution")) - Expect(k0sCmd.Long).To(ContainSubstring("Install k0s, a zero friction Kubernetes distribution")) - Expect(k0sCmd.RunE).NotTo(BeNil()) - - Expect(k0sCmd.Parent()).To(Equal(parentCmd)) - Expect(parentCmd.Commands()).To(ContainElement(k0sCmd)) - - // Check flags - configFlag := k0sCmd.Flags().Lookup("config") - Expect(configFlag).NotTo(BeNil()) - Expect(configFlag.Shorthand).To(Equal("c")) - Expect(configFlag.DefValue).To(Equal("")) - Expect(configFlag.Usage).To(Equal("Path to k0s configuration file")) - - forceFlag := k0sCmd.Flags().Lookup("force") - Expect(forceFlag).NotTo(BeNil()) - Expect(forceFlag.Shorthand).To(Equal("f")) - Expect(forceFlag.DefValue).To(Equal("false")) - Expect(forceFlag.Usage).To(Equal("Force new download and installation")) - - // Check examples - Expect(k0sCmd).NotTo(BeNil()) - Expect(k0sCmd.Example).NotTo(BeEmpty()) - Expect(k0sCmd.Example).To(ContainSubstring("oms-cli install k0s")) - Expect(k0sCmd.Example).To(ContainSubstring("--config")) - Expect(k0sCmd.Example).To(ContainSubstring("--force")) + 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/internal/installer/k0s.go b/internal/installer/k0s.go index 1a45540e..1c350b37 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -17,9 +17,9 @@ import ( ) type K0sManager interface { - BinaryExists() bool - Download(force bool, quiet bool) error - Install(configPath string, force bool) error + GetLatestVersion() (string, error) + Download(version string, force bool, quiet bool) (string, error) + Install(configPath string, k0sPath string, force bool) error } type K0s struct { @@ -40,38 +40,36 @@ func NewK0s(hw portal.Http, env env.Env, fw util.FileIO) K0sManager { } } -func (k *K0s) BinaryExists() bool { - workdir := k.Env.GetOmsWorkdir() - k0sPath := filepath.Join(workdir, "k0s") - return k.FileWriter.Exists(k0sPath) -} - -func (k *K0s) Download(force bool, quiet bool) 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) - } - - // Get the latest k0s version +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) + 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 "", 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.BinaryExists() && !force { - return fmt.Errorf("k0s binary already exists at %s. Use --force to overwrite", k0sPath) + 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) + return "", fmt.Errorf("failed to create k0s binary file: %w", err) } defer util.CloseFileIgnoreError(file) @@ -81,32 +79,30 @@ func (k *K0s) Download(force bool, quiet bool) error { 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) + 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) + return "", fmt.Errorf("failed to make k0s binary executable: %w", err) } log.Printf("k0s binary downloaded and made executable at '%s'", k0sPath) - return nil + return k0sPath, nil } -func (k *K0s) Install(configPath string, force bool) error { +func (k *K0s) Install(configPath string, k0sPath string, force bool) 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) + return fmt.Errorf("k0s installation is only supported on Linux amd64. Current platform: %s/%s", k.Goos, k.Goarch) } - workdir := k.Env.GetOmsWorkdir() - k0sPath := filepath.Join(workdir, "k0s") - if !k.BinaryExists() { + if !k.FileWriter.Exists(k0sPath) { return fmt.Errorf("k0s binary does not exist in '%s', please download first", k0sPath) } - args := []string{"./k0s", "install", "controller"} + args := []string{k0sPath, "install", "controller"} if configPath != "" { args = append(args, "--config", configPath) } else { @@ -117,14 +113,18 @@ func (k *K0s) Install(configPath string, force bool) error { args = append(args, "--force") } - err := util.RunCommand("sudo", args, workdir) + err := util.RunCommand("sudo", args, "") if err != nil { return fmt.Errorf("failed to install k0s: %w", err) } - log.Println("k0s installed successfully in single-node mode.") - log.Printf("You can start it using 'sudo %v/k0s start'", workdir) - log.Printf("You can check the status using 'sudo %v/k0s status'", workdir) + 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 index 7cc8d149..774b01d4 100644 --- a/internal/installer/k0s_test.go +++ b/internal/installer/k0s_test.go @@ -55,6 +55,56 @@ var _ = Describe("K0s", func() { Expect(k0sStruct.Goos).ToNot(BeEmpty()) Expect(k0sStruct.Goarch).ToNot(BeEmpty()) }) + + It("implements K0sManager interface", func() { + var manager installer.K0sManager = 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() { @@ -63,7 +113,7 @@ var _ = Describe("K0s", func() { k0sImpl.Goos = "windows" k0sImpl.Goarch = "amd64" - err := k0s.Download(false, false) + _, 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")) @@ -73,7 +123,7 @@ var _ = Describe("K0s", func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "arm64" - err := k0s.Download(false, false) + _, 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")) @@ -86,27 +136,7 @@ var _ = Describe("K0s", func() { k0sImpl.Goarch = "amd64" }) - It("should fail when version fetch fails", func() { - mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(nil, errors.New("network error")) - - err := k0s.Download(false, false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to fetch version info")) - Expect(err.Error()).To(ContainSubstring("network error")) - }) - - It("should fail when version is empty", func() { - emptyVersionBytes := []byte(" \n ") - mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(emptyVersionBytes, nil) - - err := k0s.Download(false, false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("version info is empty")) - }) - - It("should handle version with whitespace correctly", func() { - versionWithWhitespace := []byte(" v1.29.1+k0s.0 \n") - mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(versionWithWhitespace, nil) + It("should handle version parameter correctly", func() { mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) mockFileWriter.EXPECT().Exists(k0sPath).Return(false) @@ -122,8 +152,9 @@ var _ = Describe("K0s", func() { 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) - err = k0s.Download(false, false) + path, err := k0s.Download("v1.29.1+k0s.0", false, false) Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(k0sPath)) }) }) @@ -131,14 +162,13 @@ var _ = Describe("K0s", func() { BeforeEach(func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "amd64" - mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return([]byte("v1.29.1+k0s.0"), nil) 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(false, false) + _, 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")) @@ -159,8 +189,9 @@ var _ = Describe("K0s", func() { 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) - err = k0s.Download(true, false) + path, err := k0s.Download("v1.29.1+k0s.0", true, false) Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(k0sPath)) }) }) @@ -168,7 +199,6 @@ var _ = Describe("K0s", func() { BeforeEach(func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "amd64" - mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return([]byte("v1.29.1+k0s.0"), nil) mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) mockFileWriter.EXPECT().Exists(k0sPath).Return(false) }) @@ -176,7 +206,7 @@ var _ = Describe("K0s", func() { It("should fail when file creation fails", func() { mockFileWriter.EXPECT().Create(k0sPath).Return(nil, errors.New("permission denied")) - err := k0s.Download(false, false) + _, 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")) @@ -194,7 +224,7 @@ var _ = Describe("K0s", func() { 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(false, false) + _, 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")) @@ -212,8 +242,9 @@ var _ = Describe("K0s", func() { 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) - err = k0s.Download(false, false) + 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) @@ -231,7 +262,6 @@ var _ = Describe("K0s", func() { It("should construct correct download URL for amd64", func() { k0sImpl.Goarch = "amd64" - mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return([]byte("v1.29.1+k0s.0"), nil) // Create the workdir first err := os.MkdirAll(workDir, 0755) @@ -245,8 +275,9 @@ var _ = Describe("K0s", func() { 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) - err = k0s.Download(false, false) + path, err := k0s.Download("v1.29.1+k0s.0", false, false) Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(k0sPath)) }) }) }) @@ -257,9 +288,9 @@ var _ = Describe("K0s", func() { k0sImpl.Goos = "windows" k0sImpl.Goarch = "amd64" - err := k0s.Install("", false) + err := k0s.Install("", k0sPath, false) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) + Expect(err.Error()).To(ContainSubstring("k0s installation is only supported on Linux amd64")) Expect(err.Error()).To(ContainSubstring("windows/amd64")) }) @@ -267,9 +298,9 @@ var _ = Describe("K0s", func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "arm64" - err := k0s.Install("", false) + err := k0s.Install("", k0sPath, false) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) + Expect(err.Error()).To(ContainSubstring("k0s installation is only supported on Linux amd64")) Expect(err.Error()).To(ContainSubstring("linux/arm64")) }) }) @@ -278,62 +309,16 @@ var _ = Describe("K0s", func() { BeforeEach(func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "amd64" - mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) }) It("should fail when k0s binary doesn't exist", func() { mockFileWriter.EXPECT().Exists(k0sPath).Return(false) - err := k0s.Install("", 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")) }) - - It("should proceed when k0s binary exists", func() { - mockFileWriter.EXPECT().Exists(k0sPath).Return(true) - - // This will fail with exec error since we can't actually run k0s in tests - // but it will pass the existence check - err := k0s.Install("", false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to install k0s")) - }) - }) - - Context("Installation modes", func() { - BeforeEach(func() { - k0sImpl.Goos = "linux" - k0sImpl.Goarch = "amd64" - mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) - mockFileWriter.EXPECT().Exists(k0sPath).Return(true) - }) - - It("should install in single-node mode when no config path is provided", func() { - // This will fail with exec error but we can verify the command would be called - // The command should be: ./k0s install controller --single - err := k0s.Install("", false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to install k0s")) - }) - - It("should install with custom config when config path is provided", func() { - configPath := "/path/to/k0s.yaml" - // This will fail with exec error but we can verify the command would be called - // The command should be: ./k0s install controller --config /path/to/k0s.yaml - err := k0s.Install(configPath, false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to install k0s")) - }) - - It("should install with custom config and force flag", func() { - configPath := "/path/to/k0s.yaml" - // This will fail with exec error but we can verify the command would be called - // The command should be: ./k0s install controller --config /path/to/k0s.yaml --force - err := k0s.Install(configPath, true) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to install k0s")) - }) }) }) }) diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 0057cfbd..9deade20 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -118,107 +118,126 @@ func (_m *MockK0sManager) EXPECT() *MockK0sManager_Expecter { return &MockK0sManager_Expecter{mock: &_m.Mock} } -// BinaryExists provides a mock function for the type MockK0sManager -func (_mock *MockK0sManager) BinaryExists() bool { - ret := _mock.Called() +// 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 BinaryExists") + panic("no return value specified for Download") } - var r0 bool - if returnFunc, ok := ret.Get(0).(func() bool); ok { - r0 = returnFunc() + 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).(bool) + r0 = ret.Get(0).(string) } - return r0 + 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_BinaryExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BinaryExists' -type MockK0sManager_BinaryExists_Call struct { +// 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 } -// BinaryExists is a helper method to define mock.On call -func (_e *MockK0sManager_Expecter) BinaryExists() *MockK0sManager_BinaryExists_Call { - return &MockK0sManager_BinaryExists_Call{Call: _e.mock.On("BinaryExists")} +// 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_BinaryExists_Call) Run(run func()) *MockK0sManager_BinaryExists_Call { +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() + run(args[0].(string), args[1].(bool), args[2].(bool)) }) return _c } -func (_c *MockK0sManager_BinaryExists_Call) Return(b bool) *MockK0sManager_BinaryExists_Call { - _c.Call.Return(b) +func (_c *MockK0sManager_Download_Call) Return(s string, err error) *MockK0sManager_Download_Call { + _c.Call.Return(s, err) return _c } -func (_c *MockK0sManager_BinaryExists_Call) RunAndReturn(run func() bool) *MockK0sManager_BinaryExists_Call { +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 } -// Download provides a mock function for the type MockK0sManager -func (_mock *MockK0sManager) Download(force bool, quiet bool) error { - ret := _mock.Called(force, quiet) +// 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 Download") + panic("no return value specified for GetLatestVersion") } - var r0 error - if returnFunc, ok := ret.Get(0).(func(bool, bool) error); ok { - r0 = returnFunc(force, quiet) + 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.Error(0) + r0 = ret.Get(0).(string) } - return r0 + if returnFunc, ok := ret.Get(1).(func() error); ok { + r1 = returnFunc() + } 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 { +// 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 } -// Download is a helper method to define mock.On call -// - force -// - quiet -func (_e *MockK0sManager_Expecter) Download(force interface{}, quiet interface{}) *MockK0sManager_Download_Call { - return &MockK0sManager_Download_Call{Call: _e.mock.On("Download", force, quiet)} +// 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_Download_Call) Run(run func(force bool, quiet bool)) *MockK0sManager_Download_Call { +func (_c *MockK0sManager_GetLatestVersion_Call) Run(run func()) *MockK0sManager_GetLatestVersion_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(bool), args[1].(bool)) + run() }) return _c } -func (_c *MockK0sManager_Download_Call) Return(err error) *MockK0sManager_Download_Call { - _c.Call.Return(err) +func (_c *MockK0sManager_GetLatestVersion_Call) Return(s string, err error) *MockK0sManager_GetLatestVersion_Call { + _c.Call.Return(s, err) return _c } -func (_c *MockK0sManager_Download_Call) RunAndReturn(run func(force bool, quiet bool) error) *MockK0sManager_Download_Call { +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, force bool) error { - ret := _mock.Called(configPath, force) +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, bool) error); ok { - r0 = returnFunc(configPath, force) + if returnFunc, ok := ret.Get(0).(func(string, string, bool) error); ok { + r0 = returnFunc(configPath, k0sPath, force) } else { r0 = ret.Error(0) } @@ -232,14 +251,15 @@ type MockK0sManager_Install_Call struct { // Install is a helper method to define mock.On call // - configPath +// - k0sPath // - force -func (_e *MockK0sManager_Expecter) Install(configPath interface{}, force interface{}) *MockK0sManager_Install_Call { - return &MockK0sManager_Install_Call{Call: _e.mock.On("Install", configPath, 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, force bool)) *MockK0sManager_Install_Call { +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].(bool)) + run(args[0].(string), args[1].(string), args[2].(bool)) }) return _c } @@ -249,7 +269,7 @@ func (_c *MockK0sManager_Install_Call) Return(err error) *MockK0sManager_Install return _c } -func (_c *MockK0sManager_Install_Call) RunAndReturn(run func(configPath string, force bool) error) *MockK0sManager_Install_Call { +func (_c *MockK0sManager_Install_Call) RunAndReturn(run func(configPath string, k0sPath string, force bool) error) *MockK0sManager_Install_Call { _c.Call.Return(run) return _c } 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) From b670405db297adbea10bcacf6eddc2de113be3b3 Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Tue, 4 Nov 2025 17:27:48 +0100 Subject: [PATCH 07/20] feat: add download and install k0s command --- cli/cmd/download.go | 1 + cli/cmd/download_k0s.go | 69 ++++ cli/cmd/download_k0s_test.go | 119 +++++++ cli/cmd/install.go | 2 + cli/cmd/install_k0s.go | 76 +++++ cli/cmd/install_k0s_test.go | 162 ++++++++++ internal/installer/k0s.go | 126 ++++++++ internal/installer/k0s_test.go | 337 +++++++++++++++++++ internal/installer/mocks.go | 163 ++++++++++ internal/portal/http.go | 290 ++--------------- internal/portal/http_test.go | 574 +++++++++++++++++---------------- internal/portal/mocks.go | 188 +++++++++++ internal/portal/portal.go | 318 ++++++++++++++++++ internal/portal/portal_test.go | 363 +++++++++++++++++++++ internal/util/command.go | 26 ++ 15 files changed, 2272 insertions(+), 542 deletions(-) create mode 100644 cli/cmd/download_k0s.go create mode 100644 cli/cmd/download_k0s_test.go create mode 100644 cli/cmd/install_k0s.go create mode 100644 cli/cmd/install_k0s_test.go create mode 100644 internal/installer/k0s.go create mode 100644 internal/installer/k0s_test.go create mode 100644 internal/portal/portal.go create mode 100644 internal/portal/portal_test.go create mode 100644 internal/util/command.go 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..513426bb --- /dev/null +++ b/cli/cmd/download_k0s.go @@ -0,0 +1,69 @@ +// 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" +) + +// DownloadK0sCmd represents the k0s download command +type DownloadK0sCmd struct { + cmd *cobra.Command + Opts DownloadK0sOpts + Env env.Env + FileWriter util.FileIO +} + +type DownloadK0sOpts struct { + *GlobalOptions + 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 := k0s.Download(c.Opts.Force, c.Opts.Quiet) + 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 k0s, a zero friction Kubernetes distribution, + using a Go-native implementation. This will download the k0s + binary directly to the OMS workdir.`), + Example: formatExamplesWithBinary("download k0s", []packageio.Example{ + {Cmd: "", Desc: "Download k0s using the Go-native implementation"}, + {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().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 +} diff --git a/cli/cmd/download_k0s_test.go b/cli/cmd/download_k0s_test.go new file mode 100644 index 00000000..37fb2208 --- /dev/null +++ b/cli/cmd/download_k0s_test.go @@ -0,0 +1,119 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "errors" + "runtime" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + + "github.com/codesphere-cloud/oms/cli/cmd" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/util" +) + +var _ = Describe("DownloadK0sCmd", func() { + var ( + downloadK0sCmd *cmd.DownloadK0sCmd + mockEnv *env.MockEnv + mockFileWriter *util.MockFileIO + ) + + BeforeEach(func() { + mockEnv = env.NewMockEnv(GinkgoT()) + mockFileWriter = util.NewMockFileIO(GinkgoT()) + + downloadK0sCmd = &cmd.DownloadK0sCmd{ + Opts: cmd.DownloadK0sOpts{ + GlobalOptions: &cmd.GlobalOptions{}, + Force: false, + Quiet: false, + }, + Env: mockEnv, + FileWriter: mockFileWriter, + } + }) + + AfterEach(func() { + mockEnv.AssertExpectations(GinkgoT()) + mockFileWriter.AssertExpectations(GinkgoT()) + }) + + Context("RunE", func() { + It("should successfully handle k0s download integration", func() { + // Add mock expectations for the download functionality, intentionally causing create to fail + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() + mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(false).Maybe() + mockFileWriter.EXPECT().Create("/test/workdir/k0s").Return(nil, errors.New("mock create error")).Maybe() + + err := downloadK0sCmd.RunE(nil, nil) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download k0s")) + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + // Should fail with platform error on non-Linux amd64 platforms + Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) + } else { + // On Linux amd64, it should fail on network/version fetch since we don't have real network access + Expect(err.Error()).To(ContainSubstring("mock create error")) + } + }) + }) +}) + +var _ = Describe("AddDownloadK0sCmd", func() { + var ( + parentCmd *cobra.Command + globalOpts *cmd.GlobalOptions + ) + + BeforeEach(func() { + parentCmd = &cobra.Command{Use: "download"} + globalOpts = &cmd.GlobalOptions{} + }) + + It("adds the k0s command with correct properties and flags", func() { + cmd.AddDownloadK0sCmd(parentCmd, globalOpts) + + var k0sCmd *cobra.Command + for _, c := range parentCmd.Commands() { + if c.Use == "k0s" { + k0sCmd = c + break + } + } + + Expect(k0sCmd).NotTo(BeNil()) + Expect(k0sCmd.Use).To(Equal("k0s")) + Expect(k0sCmd.Short).To(Equal("Download k0s Kubernetes distribution")) + Expect(k0sCmd.Long).To(ContainSubstring("Download k0s, a zero friction Kubernetes distribution")) + Expect(k0sCmd.Long).To(ContainSubstring("using a Go-native implementation")) + Expect(k0sCmd.RunE).NotTo(BeNil()) + + Expect(k0sCmd.Parent()).To(Equal(parentCmd)) + Expect(parentCmd.Commands()).To(ContainElement(k0sCmd)) + + // Check flags + forceFlag := k0sCmd.Flags().Lookup("force") + Expect(forceFlag).NotTo(BeNil()) + Expect(forceFlag.Shorthand).To(Equal("f")) + Expect(forceFlag.DefValue).To(Equal("false")) + Expect(forceFlag.Usage).To(Equal("Force download even if k0s binary exists")) + + quietFlag := k0sCmd.Flags().Lookup("quiet") + Expect(quietFlag).NotTo(BeNil()) + Expect(quietFlag.Shorthand).To(Equal("q")) + Expect(quietFlag.DefValue).To(Equal("false")) + Expect(quietFlag.Usage).To(Equal("Suppress progress output during download")) + + // Check examples + Expect(k0sCmd.Example).NotTo(BeEmpty()) + Expect(k0sCmd.Example).To(ContainSubstring("oms-cli download k0s")) + Expect(k0sCmd.Example).To(ContainSubstring("--quiet")) + Expect(k0sCmd.Example).To(ContainSubstring("--force")) + }) +}) 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..27129554 --- /dev/null +++ b/cli/cmd/install_k0s.go @@ -0,0 +1,76 @@ +// 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 + Config string + Force bool +} + +func (c *InstallK0sCmd) RunE(_ *cobra.Command, args []string) error { + hw := portal.NewHttpWrapper() + env := c.Env + k0s := installer.NewK0s(hw, env, c.FileWriter) + + if !k0s.BinaryExists() || c.Opts.Force { + err := k0s.Download(c.Opts.Force, false) + if err != nil { + return fmt.Errorf("failed to download k0s: %w", err) + } + } + + err := k0s.Install(c.Opts.Config, c.Opts.Force) + 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, a zero friction Kubernetes distribution, + using a Go-native implementation. This will download the k0s + binary directly to the OMS workdir, if not already present, and install it.`), + Example: formatExamplesWithBinary("install k0s", []packageio.Example{ + {Cmd: "", Desc: "Install k0s using the Go-native implementation"}, + {Cmd: "--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.Config, "config", "c", "", "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 +} diff --git a/cli/cmd/install_k0s_test.go b/cli/cmd/install_k0s_test.go new file mode 100644 index 00000000..8761c377 --- /dev/null +++ b/cli/cmd/install_k0s_test.go @@ -0,0 +1,162 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd_test + +import ( + "errors" + "runtime" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" + + "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 ( + installK0sCmd *cmd.InstallK0sCmd + mockEnv *env.MockEnv + mockFileWriter *util.MockFileIO + mockK0sManager *installer.MockK0sManager + ) + + BeforeEach(func() { + mockEnv = env.NewMockEnv(GinkgoT()) + mockFileWriter = util.NewMockFileIO(GinkgoT()) + mockK0sManager = installer.NewMockK0sManager(GinkgoT()) + + installK0sCmd = &cmd.InstallK0sCmd{ + Opts: cmd.InstallK0sOpts{ + GlobalOptions: &cmd.GlobalOptions{}, + Config: "", + Force: false, + }, + Env: mockEnv, + FileWriter: mockFileWriter, + } + }) + + AfterEach(func() { + mockEnv.AssertExpectations(GinkgoT()) + mockFileWriter.AssertExpectations(GinkgoT()) + if mockK0sManager != nil { + mockK0sManager.AssertExpectations(GinkgoT()) + } + }) + + Context("RunE", func() { + It("should successfully handle k0s install integration", func() { + // Add mock expectations for the new BinaryExists and download functionality, intentionally causing create to fail + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() + mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(false).Maybe() + mockFileWriter.EXPECT().Create("/test/workdir/k0s").Return(nil, errors.New("mock create error")).Maybe() + + err := installK0sCmd.RunE(nil, nil) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download k0s")) + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + // Should fail with platform error on non-Linux amd64 platforms + Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) + } else { + // On Linux amd64, it should fail on file creation or network/version fetch since we don't have real network access + Expect(err.Error()).To(ContainSubstring("mock create error")) + } + }) + + It("should download k0s when binary doesn't exist", func() { + // Add mock expectations for the download functionality, intentionally causing create to fail + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() + mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(false).Maybe() + mockFileWriter.EXPECT().Create("/test/workdir/k0s").Return(nil, errors.New("mock create error")).Maybe() + + err := installK0sCmd.RunE(nil, nil) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download k0s")) + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + // Should fail with platform error on non-Linux amd64 platforms + Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) + } else { + // On Linux amd64, it should fail on file creation or network/version fetch since we don't have real network access + Expect(err.Error()).To(ContainSubstring("mock create error")) + } + }) + + It("should skip download when binary exists and force is false", func() { + // Set up the test so that binary exists + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() + mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(true).Maybe() + + err := installK0sCmd.RunE(nil, nil) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install k0s")) + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + // Should fail with platform error on non-Linux amd64 platforms + Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) + } else { + // On Linux amd64, it should fail on file creation or network/version fetch since we don't have real network access + Expect(err.Error()).To(ContainSubstring("no such file or directory")) + } + }) + }) +}) + +var _ = Describe("AddInstallK0sCmd", func() { + var ( + parentCmd *cobra.Command + globalOpts *cmd.GlobalOptions + ) + + BeforeEach(func() { + parentCmd = &cobra.Command{Use: "install"} + globalOpts = &cmd.GlobalOptions{} + }) + + It("adds the k0s command with correct properties and flags", func() { + cmd.AddInstallK0sCmd(parentCmd, globalOpts) + + var k0sCmd *cobra.Command + for _, c := range parentCmd.Commands() { + if c.Use == "k0s" { + k0sCmd = c + break + } + } + + Expect(k0sCmd).NotTo(BeNil()) + Expect(k0sCmd.Use).To(Equal("k0s")) + Expect(k0sCmd.Short).To(Equal("Install k0s Kubernetes distribution")) + Expect(k0sCmd.Long).To(ContainSubstring("Install k0s, a zero friction Kubernetes distribution")) + Expect(k0sCmd.RunE).NotTo(BeNil()) + + Expect(k0sCmd.Parent()).To(Equal(parentCmd)) + Expect(parentCmd.Commands()).To(ContainElement(k0sCmd)) + + // Check flags + configFlag := k0sCmd.Flags().Lookup("config") + Expect(configFlag).NotTo(BeNil()) + Expect(configFlag.Shorthand).To(Equal("c")) + Expect(configFlag.DefValue).To(Equal("")) + Expect(configFlag.Usage).To(Equal("Path to k0s configuration file")) + + forceFlag := k0sCmd.Flags().Lookup("force") + Expect(forceFlag).NotTo(BeNil()) + Expect(forceFlag.Shorthand).To(Equal("f")) + Expect(forceFlag.DefValue).To(Equal("false")) + Expect(forceFlag.Usage).To(Equal("Force new download and installation")) + + // Check examples + Expect(k0sCmd).NotTo(BeNil()) + Expect(k0sCmd.Example).NotTo(BeEmpty()) + Expect(k0sCmd.Example).To(ContainSubstring("oms-cli install k0s")) + Expect(k0sCmd.Example).To(ContainSubstring("--config")) + Expect(k0sCmd.Example).To(ContainSubstring("--force")) + }) +}) diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go new file mode 100644 index 00000000..ae1f50da --- /dev/null +++ b/internal/installer/k0s.go @@ -0,0 +1,126 @@ +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 { + BinaryExists() bool + Download(force bool, quiet bool) error + Install(configPath 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) BinaryExists() bool { + workdir := k.Env.GetOmsWorkdir() + k0sPath := filepath.Join(workdir, "k0s") + return k.FileWriter.Exists(k0sPath) +} + +func (k *K0s) Download(force bool, quiet bool) 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) + } + + // Get the latest k0s version + 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") + } + + // Check if k0s binary already exists and create destination file + workdir := k.Env.GetOmsWorkdir() + k0sPath := filepath.Join(workdir, "k0s") + if k.BinaryExists() && !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 file.Close() + + // 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 nil +} + +func (k *K0s) Install(configPath string, force bool) 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) + } + + workdir := k.Env.GetOmsWorkdir() + k0sPath := filepath.Join(workdir, "k0s") + if !k.BinaryExists() { + return fmt.Errorf("k0s binary does not exist in '%s', please download first", k0sPath) + } + + args := []string{"./k0s", "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, workdir) + if err != nil { + return fmt.Errorf("failed to install k0s: %w", err) + } + + log.Println("k0s installed successfully in single-node mode.") + log.Println("You can start it using 'sudo ./k0s start'") + + return nil +} diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go new file mode 100644 index 00000000..cc055603 --- /dev/null +++ b/internal/installer/k0s_test.go @@ -0,0 +1,337 @@ +// 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()) + }) + }) + + Describe("Download", func() { + Context("Platform support", func() { + It("should fail on non-Linux platforms", func() { + k0sImpl.Goos = "windows" + k0sImpl.Goarch = "amd64" + + err := k0s.Download(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(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 fail when version fetch fails", func() { + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(nil, errors.New("network error")) + + err := k0s.Download(false, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to fetch version info")) + Expect(err.Error()).To(ContainSubstring("network error")) + }) + + It("should fail when version is empty", func() { + emptyVersionBytes := []byte(" \n ") + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(emptyVersionBytes, nil) + + err := k0s.Download(false, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("version info is empty")) + }) + + It("should handle version with whitespace correctly", func() { + versionWithWhitespace := []byte(" v1.29.1+k0s.0 \n") + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(versionWithWhitespace, nil) + 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 realFile.Close() + + 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) + + err = k0s.Download(false, false) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("File existence checks", func() { + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return([]byte("v1.29.1+k0s.0"), nil) + 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(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 realFile.Close() + + 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) + + err = k0s.Download(true, false) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("File operations", func() { + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return([]byte("v1.29.1+k0s.0"), nil) + 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(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 os.Remove(mockFile.Name()) + defer mockFile.Close() + + 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(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 realFile.Close() + + 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) + + err = k0s.Download(false, false) + Expect(err).ToNot(HaveOccurred()) + + // 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" + mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return([]byte("v1.29.1+k0s.0"), nil) + + // 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 realFile.Close() + + 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) + + err = k0s.Download(false, false) + Expect(err).ToNot(HaveOccurred()) + }) + }) + }) + + Describe("Install", func() { + Context("Platform support", func() { + It("should fail on non-Linux platforms", func() { + k0sImpl.Goos = "windows" + k0sImpl.Goarch = "amd64" + + err := k0s.Install("", 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.Install("", 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("Binary existence checks", func() { + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) + }) + + It("should fail when k0s binary doesn't exist", func() { + mockFileWriter.EXPECT().Exists(k0sPath).Return(false) + + err := k0s.Install("", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("k0s binary does not exist")) + Expect(err.Error()).To(ContainSubstring("please download first")) + }) + + It("should proceed when k0s binary exists", func() { + mockFileWriter.EXPECT().Exists(k0sPath).Return(true) + + // This will fail with exec error since we can't actually run k0s in tests + // but it will pass the existence check + err := k0s.Install("", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install k0s")) + }) + }) + + Context("Installation modes", func() { + BeforeEach(func() { + k0sImpl.Goos = "linux" + k0sImpl.Goarch = "amd64" + mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) + mockFileWriter.EXPECT().Exists(k0sPath).Return(true) + }) + + It("should install in single-node mode when no config path is provided", func() { + // This will fail with exec error but we can verify the command would be called + // The command should be: ./k0s install controller --single + err := k0s.Install("", false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install k0s")) + }) + + It("should install with custom config when config path is provided", func() { + configPath := "/path/to/k0s.yaml" + // This will fail with exec error but we can verify the command would be called + // The command should be: ./k0s install controller --config /path/to/k0s.yaml + err := k0s.Install(configPath, false) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install k0s")) + }) + + It("should install with custom config and force flag", func() { + configPath := "/path/to/k0s.yaml" + // This will fail with exec error but we can verify the command would be called + // The command should be: ./k0s install controller --config /path/to/k0s.yaml --force + err := k0s.Install(configPath, true) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install k0s")) + }) + }) + }) +}) diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 9779117e..0057cfbd 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -91,6 +91,169 @@ func (_c *MockConfigManager_ParseConfigYaml_Call) RunAndReturn(run func(configPa 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} +} + +// BinaryExists provides a mock function for the type MockK0sManager +func (_mock *MockK0sManager) BinaryExists() bool { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for BinaryExists") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func() bool); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockK0sManager_BinaryExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BinaryExists' +type MockK0sManager_BinaryExists_Call struct { + *mock.Call +} + +// BinaryExists is a helper method to define mock.On call +func (_e *MockK0sManager_Expecter) BinaryExists() *MockK0sManager_BinaryExists_Call { + return &MockK0sManager_BinaryExists_Call{Call: _e.mock.On("BinaryExists")} +} + +func (_c *MockK0sManager_BinaryExists_Call) Run(run func()) *MockK0sManager_BinaryExists_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockK0sManager_BinaryExists_Call) Return(b bool) *MockK0sManager_BinaryExists_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockK0sManager_BinaryExists_Call) RunAndReturn(run func() bool) *MockK0sManager_BinaryExists_Call { + _c.Call.Return(run) + return _c +} + +// Download provides a mock function for the type MockK0sManager +func (_mock *MockK0sManager) Download(force bool, quiet bool) error { + ret := _mock.Called(force, quiet) + + if len(ret) == 0 { + panic("no return value specified for Download") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(bool, bool) error); ok { + r0 = returnFunc(force, quiet) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// 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 +// - force +// - quiet +func (_e *MockK0sManager_Expecter) Download(force interface{}, quiet interface{}) *MockK0sManager_Download_Call { + return &MockK0sManager_Download_Call{Call: _e.mock.On("Download", force, quiet)} +} + +func (_c *MockK0sManager_Download_Call) Run(run func(force bool, quiet bool)) *MockK0sManager_Download_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(bool), args[1].(bool)) + }) + return _c +} + +func (_c *MockK0sManager_Download_Call) Return(err error) *MockK0sManager_Download_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockK0sManager_Download_Call) RunAndReturn(run func(force bool, quiet bool) error) *MockK0sManager_Download_Call { + _c.Call.Return(run) + return _c +} + +// Install provides a mock function for the type MockK0sManager +func (_mock *MockK0sManager) Install(configPath string, force bool) error { + ret := _mock.Called(configPath, force) + + if len(ret) == 0 { + panic("no return value specified for Install") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = returnFunc(configPath, 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 +// - force +func (_e *MockK0sManager_Expecter) Install(configPath interface{}, force interface{}) *MockK0sManager_Install_Call { + return &MockK0sManager_Install_Call{Call: _e.mock.On("Install", configPath, force)} +} + +func (_c *MockK0sManager_Install_Call) Run(run func(configPath string, force bool)) *MockK0sManager_Install_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(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, 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/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..02423a29 --- /dev/null +++ b/internal/portal/portal.go @@ -0,0 +1,318 @@ +// 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" +) + +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) + 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) + if err != nil { + err = fmt.Errorf("failed to read response body: %w", err) + return + } + + 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 + } + + 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 +} + +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 *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 +} + +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/portal_test.go b/internal/portal/portal_test.go new file mode 100644 index 00000000..9aff801d --- /dev/null +++ b/internal/portal/portal_test.go @@ -0,0 +1,363 @@ +// 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 + 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 + 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, false) + Expect(err).NotTo(HaveOccurred()) + Expect(fakeWriter.String()).To(Equal(downloadResponse)) + Expect(getUrl.String()).To(Equal("fake-portal.com/packages/codesphere/download")) + }) + + It("emits progress logs when not quiet", func() { + var logBuf bytes.Buffer + prev := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(prev) + + fakeWriter := NewFakeWriter() + err := client.DownloadBuildArtifact(product, build, fakeWriter, false) + Expect(err).NotTo(HaveOccurred()) + Expect(logBuf.String()).To(ContainSubstring("Downloading...")) + }) + + It("does not emit progress logs when quiet", func() { + var logBuf bytes.Buffer + prev := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(prev) + + fakeWriter := NewFakeWriter() + err := client.DownloadBuildArtifact(product, build, fakeWriter, true) + Expect(err).NotTo(HaveOccurred()) + Expect(logBuf.String()).NotTo(ContainSubstring("Downloading...")) + }) + }) + + Describe("GetLatestOmsBuild", func() { + 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..06290b8e --- /dev/null +++ b/internal/util/command.go @@ -0,0 +1,26 @@ +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 +} From 2644abf50065f9ba76ab9e2c514f20b279b7598f Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Wed, 5 Nov 2025 16:30:49 +0100 Subject: [PATCH 08/20] update: update logs with oms workdir for k0s start command --- internal/installer/k0s.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index ae1f50da..d49157cf 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -120,7 +120,8 @@ func (k *K0s) Install(configPath string, force bool) error { } log.Println("k0s installed successfully in single-node mode.") - log.Println("You can start it using 'sudo ./k0s start'") + log.Printf("You can start it using 'sudo %v/k0s start'", workdir) + log.Printf("You can check the status using 'sudo %v/k0s status'", workdir) return nil } From d31b396e4851dcc2bd30a56c37e0eb94f3285760 Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Wed, 5 Nov 2025 16:52:50 +0100 Subject: [PATCH 09/20] fix: fix lint errors --- internal/installer/k0s.go | 2 +- internal/installer/k0s_test.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index d49157cf..123c2dad 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -70,7 +70,7 @@ func (k *K0s) Download(force bool, quiet bool) error { if err != nil { return fmt.Errorf("failed to create k0s binary file: %w", err) } - defer file.Close() + defer util.CloseFileIgnoreError(file) // Download using the portal Http wrapper with WriteCounter log.Printf("Downloading k0s version %s", version) diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go index cc055603..ad543656 100644 --- a/internal/installer/k0s_test.go +++ b/internal/installer/k0s_test.go @@ -117,7 +117,7 @@ var _ = Describe("K0s", func() { // Create a real file for the test realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) - defer realFile.Close() + 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) @@ -154,7 +154,7 @@ var _ = Describe("K0s", func() { // Create a real file for the test realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) - defer realFile.Close() + 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) @@ -205,7 +205,7 @@ var _ = Describe("K0s", func() { realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) - defer realFile.Close() + 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) @@ -238,7 +238,7 @@ var _ = Describe("K0s", func() { // Create a real file for the test realFile, err := os.Create(k0sPath) Expect(err).ToNot(HaveOccurred()) - defer realFile.Close() + 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) From 716a765df875d2ba90f2f9f7be8fe4c6e75fa48f Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Wed, 5 Nov 2025 16:55:01 +0100 Subject: [PATCH 10/20] fix: fix lint errors --- internal/installer/k0s_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go index ad543656..7cc8d149 100644 --- a/internal/installer/k0s_test.go +++ b/internal/installer/k0s_test.go @@ -186,8 +186,10 @@ var _ = Describe("K0s", func() { // Create a mock file for the test mockFile, err := os.CreateTemp("", "k0s-test") Expect(err).ToNot(HaveOccurred()) - defer os.Remove(mockFile.Name()) - defer mockFile.Close() + 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")) From e2197bab9fdd30c7a77e212272d2b849d651e7ff Mon Sep 17 00:00:00 2001 From: siherrmann <25087590+siherrmann@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:58:24 +0000 Subject: [PATCH 11/20] chore(docs): Auto-update docs and licenses Signed-off-by: siherrmann <25087590+siherrmann@users.noreply.github.com> --- docs/README.md | 1 + docs/oms-cli.md | 1 + docs/oms-cli_beta.md | 1 + docs/oms-cli_beta_extend.md | 1 + docs/oms-cli_beta_extend_baseimage.md | 1 + docs/oms-cli_build.md | 1 + docs/oms-cli_build_image.md | 2 +- docs/oms-cli_build_images.md | 2 +- docs/oms-cli_download.md | 2 ++ docs/oms-cli_download_k0s.md | 41 +++++++++++++++++++++++++++ docs/oms-cli_download_package.md | 1 + docs/oms-cli_install.md | 2 ++ docs/oms-cli_install_codesphere.md | 1 + docs/oms-cli_install_k0s.md | 41 +++++++++++++++++++++++++++ docs/oms-cli_licenses.md | 1 + docs/oms-cli_list.md | 1 + docs/oms-cli_list_api-keys.md | 1 + docs/oms-cli_list_packages.md | 1 + docs/oms-cli_register.md | 1 + docs/oms-cli_revoke.md | 1 + docs/oms-cli_revoke_api-key.md | 1 + docs/oms-cli_update.md | 1 + docs/oms-cli_update_api-key.md | 1 + docs/oms-cli_update_dockerfile.md | 1 + docs/oms-cli_update_oms.md | 1 + docs/oms-cli_update_package.md | 1 + docs/oms-cli_version.md | 1 + internal/installer/k0s.go | 3 ++ internal/util/command.go | 3 ++ 29 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 docs/oms-cli_download_k0s.md create mode 100644 docs/oms-cli_install_k0s.md diff --git a/docs/README.md b/docs/README.md index a172e71e..689000b1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,3 +28,4 @@ like downloading new versions. * [oms-cli update](oms-cli_update.md) - Update OMS related resources * [oms-cli version](oms-cli_version.md) - Print version +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli.md b/docs/oms-cli.md index a172e71e..689000b1 100644 --- a/docs/oms-cli.md +++ b/docs/oms-cli.md @@ -28,3 +28,4 @@ like downloading new versions. * [oms-cli update](oms-cli_update.md) - Update OMS related resources * [oms-cli version](oms-cli_version.md) - Print version +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_beta.md b/docs/oms-cli_beta.md index 6952fda5..5449a11e 100644 --- a/docs/oms-cli_beta.md +++ b/docs/oms-cli_beta.md @@ -18,3 +18,4 @@ Be aware that that usage and behavior may change as the features are developed. * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) * [oms-cli beta extend](oms-cli_beta_extend.md) - Extend Codesphere ressources such as base images. +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_beta_extend.md b/docs/oms-cli_beta_extend.md index 95bc3e63..4022a7a5 100644 --- a/docs/oms-cli_beta_extend.md +++ b/docs/oms-cli_beta_extend.md @@ -17,3 +17,4 @@ Extend Codesphere ressources such as base images to customize them for your need * [oms-cli beta](oms-cli_beta.md) - Commands for early testing * [oms-cli beta extend baseimage](oms-cli_beta_extend_baseimage.md) - Extend Codesphere's workspace base image for customization +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_beta_extend_baseimage.md b/docs/oms-cli_beta_extend_baseimage.md index 50b8fa19..10be7546 100644 --- a/docs/oms-cli_beta_extend_baseimage.md +++ b/docs/oms-cli_beta_extend_baseimage.md @@ -28,3 +28,4 @@ oms-cli beta extend baseimage [flags] * [oms-cli beta extend](oms-cli_beta_extend.md) - Extend Codesphere ressources such as base images. +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_build.md b/docs/oms-cli_build.md index 76481ada..881d263d 100644 --- a/docs/oms-cli_build.md +++ b/docs/oms-cli_build.md @@ -18,3 +18,4 @@ Build and push container images to a registry using the provided configuration. * [oms-cli build image](oms-cli_build_image.md) - Build and push Docker image using Dockerfile and Codesphere package version * [oms-cli build images](oms-cli_build_images.md) - Build and push container images +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_build_image.md b/docs/oms-cli_build_image.md index bc55ea4a..37a3e284 100644 --- a/docs/oms-cli_build_image.md +++ b/docs/oms-cli_build_image.md @@ -22,7 +22,6 @@ $ oms-cli build image --dockerfile baseimage/Dockerfile --package codesphere-v1. ``` -d, --dockerfile string Path to the Dockerfile to build (required) - -f, --force Force new unpacking of the package even if already extracted -h, --help help for image -p, --package string Path to the Codesphere package (required) -r, --registry string Registry URL to push to (e.g., my-registry.com/my-image) (required) @@ -32,3 +31,4 @@ $ oms-cli build image --dockerfile baseimage/Dockerfile --package codesphere-v1. * [oms-cli build](oms-cli_build.md) - Build and push images to a registry +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_build_images.md b/docs/oms-cli_build_images.md index a2f5e409..e2309618 100644 --- a/docs/oms-cli_build_images.md +++ b/docs/oms-cli_build_images.md @@ -15,7 +15,6 @@ oms-cli build images [flags] ``` -c, --config string Path to the configuration YAML file - -f, --force Force new unpacking of the package even if already extracted -h, --help help for images ``` @@ -23,3 +22,4 @@ oms-cli build images [flags] * [oms-cli build](oms-cli_build.md) - Build and push images to a registry +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_download.md b/docs/oms-cli_download.md index fff85d97..c5d8efb8 100644 --- a/docs/oms-cli_download.md +++ b/docs/oms-cli_download.md @@ -16,5 +16,7 @@ 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 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_download_k0s.md b/docs/oms-cli_download_k0s.md new file mode 100644 index 00000000..57105d47 --- /dev/null +++ b/docs/oms-cli_download_k0s.md @@ -0,0 +1,41 @@ +## oms-cli download k0s + +Download k0s Kubernetes distribution + +### Synopsis + +Download k0s, a zero friction Kubernetes distribution, +using a Go-native implementation. This will download the k0s +binary directly to the OMS workdir. + +``` +oms-cli download k0s [flags] +``` + +### Examples + +``` +# Download k0s using the Go-native implementation +$ oms-cli download k0s + +# 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 +``` + +### SEE ALSO + +* [oms-cli download](oms-cli_download.md) - Download resources available through OMS + +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_download_package.md b/docs/oms-cli_download_package.md index 7f1ee1b1..b2df39a4 100644 --- a/docs/oms-cli_download_package.md +++ b/docs/oms-cli_download_package.md @@ -36,3 +36,4 @@ $ oms-cli download package --version codesphere-v1.55.0 --file installer-lite.ta * [oms-cli download](oms-cli_download.md) - Download resources available through OMS +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_install.md b/docs/oms-cli_install.md index f63765b8..41dcf504 100644 --- a/docs/oms-cli_install.md +++ b/docs/oms-cli_install.md @@ -16,4 +16,6 @@ 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 +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_install_codesphere.md b/docs/oms-cli_install_codesphere.md index f2df7677..29816e92 100644 --- a/docs/oms-cli_install_codesphere.md +++ b/docs/oms-cli_install_codesphere.md @@ -26,3 +26,4 @@ oms-cli install codesphere [flags] * [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_install_k0s.md b/docs/oms-cli_install_k0s.md new file mode 100644 index 00000000..4f088806 --- /dev/null +++ b/docs/oms-cli_install_k0s.md @@ -0,0 +1,41 @@ +## oms-cli install k0s + +Install k0s Kubernetes distribution + +### Synopsis + +Install k0s, a zero friction Kubernetes distribution, +using a Go-native implementation. This will download the k0s +binary directly to the OMS workdir, if not already present, and install it. + +``` +oms-cli install k0s [flags] +``` + +### Examples + +``` +# Install k0s using the Go-native implementation +$ oms-cli install k0s + +# Path to k0s configuration file, if not set k0s will be installed with the '--single' flag +$ oms-cli install k0s --config + +# Force new download and installation even if k0s binary exists or is already installed +$ oms-cli install k0s --force + +``` + +### Options + +``` + -c, --config string Path to k0s configuration file + -f, --force Force new download and installation + -h, --help help for k0s +``` + +### SEE ALSO + +* [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components + +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_licenses.md b/docs/oms-cli_licenses.md index d6e1487b..e2fb56df 100644 --- a/docs/oms-cli_licenses.md +++ b/docs/oms-cli_licenses.md @@ -20,3 +20,4 @@ oms-cli licenses [flags] * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_list.md b/docs/oms-cli_list.md index 0d5a7c4b..781c5eee 100644 --- a/docs/oms-cli_list.md +++ b/docs/oms-cli_list.md @@ -19,3 +19,4 @@ eg. available Codesphere packages * [oms-cli list api-keys](oms-cli_list_api-keys.md) - List API keys * [oms-cli list packages](oms-cli_list_packages.md) - List available packages +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_list_api-keys.md b/docs/oms-cli_list_api-keys.md index dd7b1c41..9cec7b1e 100644 --- a/docs/oms-cli_list_api-keys.md +++ b/docs/oms-cli_list_api-keys.md @@ -20,3 +20,4 @@ oms-cli list api-keys [flags] * [oms-cli list](oms-cli_list.md) - List resources available through OMS +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_list_packages.md b/docs/oms-cli_list_packages.md index 1b2ae8bf..f6d76165 100644 --- a/docs/oms-cli_list_packages.md +++ b/docs/oms-cli_list_packages.md @@ -20,3 +20,4 @@ oms-cli list packages [flags] * [oms-cli list](oms-cli_list.md) - List resources available through OMS +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_register.md b/docs/oms-cli_register.md index a2438b90..8a152d48 100644 --- a/docs/oms-cli_register.md +++ b/docs/oms-cli_register.md @@ -24,3 +24,4 @@ oms-cli register [flags] * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_revoke.md b/docs/oms-cli_revoke.md index dcab1d81..541c96c8 100644 --- a/docs/oms-cli_revoke.md +++ b/docs/oms-cli_revoke.md @@ -18,3 +18,4 @@ eg. api keys. * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) * [oms-cli revoke api-key](oms-cli_revoke_api-key.md) - Revoke an API key +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_revoke_api-key.md b/docs/oms-cli_revoke_api-key.md index 45524164..738a5543 100644 --- a/docs/oms-cli_revoke_api-key.md +++ b/docs/oms-cli_revoke_api-key.md @@ -21,3 +21,4 @@ oms-cli revoke api-key [flags] * [oms-cli revoke](oms-cli_revoke.md) - Revoke resources available through OMS +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update.md b/docs/oms-cli_update.md index 817b510f..d9e2f3dd 100644 --- a/docs/oms-cli_update.md +++ b/docs/oms-cli_update.md @@ -24,3 +24,4 @@ oms-cli update [flags] * [oms-cli update oms](oms-cli_update_oms.md) - Update the OMS CLI * [oms-cli update package](oms-cli_update_package.md) - Download a codesphere package +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update_api-key.md b/docs/oms-cli_update_api-key.md index 4948844a..257fcfb7 100644 --- a/docs/oms-cli_update_api-key.md +++ b/docs/oms-cli_update_api-key.md @@ -22,3 +22,4 @@ oms-cli update api-key [flags] * [oms-cli update](oms-cli_update.md) - Update OMS related resources +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update_dockerfile.md b/docs/oms-cli_update_dockerfile.md index f127c12b..14b84f39 100644 --- a/docs/oms-cli_update_dockerfile.md +++ b/docs/oms-cli_update_dockerfile.md @@ -38,3 +38,4 @@ $ oms-cli update dockerfile --dockerfile baseimage/Dockerfile --package codesphe * [oms-cli update](oms-cli_update.md) - Update OMS related resources +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update_oms.md b/docs/oms-cli_update_oms.md index 1a481d7d..b0ef6900 100644 --- a/docs/oms-cli_update_oms.md +++ b/docs/oms-cli_update_oms.md @@ -20,3 +20,4 @@ oms-cli update oms [flags] * [oms-cli update](oms-cli_update.md) - Update OMS related resources +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update_package.md b/docs/oms-cli_update_package.md index 03bf25e4..34468393 100644 --- a/docs/oms-cli_update_package.md +++ b/docs/oms-cli_update_package.md @@ -36,3 +36,4 @@ $ oms-cli download package --version codesphere-v1.55.0 --file installer-lite.ta * [oms-cli update](oms-cli_update.md) - Update OMS related resources +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_version.md b/docs/oms-cli_version.md index 17daf6ef..cb0bde01 100644 --- a/docs/oms-cli_version.md +++ b/docs/oms-cli_version.md @@ -20,3 +20,4 @@ oms-cli version [flags] * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) +###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/internal/installer/k0s.go b/internal/installer/k0s.go index 123c2dad..1a45540e 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -1,3 +1,6 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + package installer import ( diff --git a/internal/util/command.go b/internal/util/command.go index 06290b8e..6b55d6a6 100644 --- a/internal/util/command.go +++ b/internal/util/command.go @@ -1,3 +1,6 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + package util import ( From 8c6186bb992e7274fcd90f9715c7e7bdb8b2fa36 Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Mon, 10 Nov 2025 17:35:04 +0100 Subject: [PATCH 12/20] update: update install k0s command to also use package --- cli/cmd/download_k0s.go | 34 ++++- cli/cmd/download_k0s_test.go | 130 ++++++++-------- cli/cmd/install_k0s.go | 50 +++++-- cli/cmd/install_k0s_test.go | 165 ++++++++------------- internal/installer/k0s.go | 66 ++++----- internal/installer/k0s_test.go | 153 +++++++++---------- internal/installer/mocks.go | 118 ++++++++------- internal/installer/package.go | 12 +- internal/installer/package_test.go | 228 +++-------------------------- 9 files changed, 385 insertions(+), 571 deletions(-) diff --git a/cli/cmd/download_k0s.go b/cli/cmd/download_k0s.go index 513426bb..19401277 100644 --- a/cli/cmd/download_k0s.go +++ b/cli/cmd/download_k0s.go @@ -5,6 +5,7 @@ package cmd import ( "fmt" + "log" packageio "github.com/codesphere-cloud/cs-go/pkg/io" "github.com/spf13/cobra" @@ -25,8 +26,9 @@ type DownloadK0sCmd struct { type DownloadK0sOpts struct { *GlobalOptions - Force bool - Quiet bool + Version string + Force bool + Quiet bool } func (c *DownloadK0sCmd) RunE(_ *cobra.Command, args []string) error { @@ -34,7 +36,7 @@ func (c *DownloadK0sCmd) RunE(_ *cobra.Command, args []string) error { env := c.Env k0s := installer.NewK0s(hw, env, c.FileWriter) - err := k0s.Download(c.Opts.Force, c.Opts.Quiet) + err := c.DownloadK0s(k0s) if err != nil { return fmt.Errorf("failed to download k0s: %w", err) } @@ -47,11 +49,11 @@ func AddDownloadK0sCmd(download *cobra.Command, opts *GlobalOptions) { cmd: &cobra.Command{ Use: "k0s", Short: "Download k0s Kubernetes distribution", - Long: packageio.Long(`Download k0s, a zero friction Kubernetes distribution, - using a Go-native implementation. This will download the k0s - binary directly to the OMS workdir.`), + 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"), @@ -60,6 +62,7 @@ func AddDownloadK0sCmd(download *cobra.Command, opts *GlobalOptions) { 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") @@ -67,3 +70,22 @@ func AddDownloadK0sCmd(download *cobra.Command, opts *GlobalOptions) { k0s.cmd.RunE = k0s.RunE } + +func (c *DownloadK0sCmd) DownloadK0s(k0s installer.K0sManager) error { + if c.Opts.Version == "" { + version, err := k0s.GetLatestVersion() + if err != nil { + return fmt.Errorf("failed to get latest k0s version: %w", err) + } + c.Opts.Version = version + } + + k0sPath, err := k0s.Download(c.Opts.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 index 37fb2208..8b6fc46a 100644 --- a/cli/cmd/download_k0s_test.go +++ b/cli/cmd/download_k0s_test.go @@ -5,20 +5,21 @@ package cmd_test import ( "errors" - "runtime" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/spf13/cobra" "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 ( - downloadK0sCmd *cmd.DownloadK0sCmd + c cmd.DownloadK0sCmd + opts *cmd.DownloadK0sOpts + globalOpts *cmd.GlobalOptions mockEnv *env.MockEnv mockFileWriter *util.MockFileIO ) @@ -26,13 +27,15 @@ var _ = Describe("DownloadK0sCmd", func() { BeforeEach(func() { mockEnv = env.NewMockEnv(GinkgoT()) mockFileWriter = util.NewMockFileIO(GinkgoT()) - - downloadK0sCmd = &cmd.DownloadK0sCmd{ - Opts: cmd.DownloadK0sOpts{ - GlobalOptions: &cmd.GlobalOptions{}, - Force: false, - Quiet: false, - }, + globalOpts = &cmd.GlobalOptions{} + opts = &cmd.DownloadK0sOpts{ + GlobalOptions: globalOpts, + Version: "", + Force: false, + Quiet: false, + } + c = cmd.DownloadK0sCmd{ + Opts: *opts, Env: mockEnv, FileWriter: mockFileWriter, } @@ -43,77 +46,60 @@ var _ = Describe("DownloadK0sCmd", func() { mockFileWriter.AssertExpectations(GinkgoT()) }) - Context("RunE", func() { - It("should successfully handle k0s download integration", func() { - // Add mock expectations for the download functionality, intentionally causing create to fail - mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() - mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(false).Maybe() - mockFileWriter.EXPECT().Create("/test/workdir/k0s").Return(nil, errors.New("mock create error")).Maybe() + Context("RunE method", func() { + It("calls DownloadK0s and fails with network error", func() { + err := c.RunE(nil, []string{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to download k0s")) + }) + }) + + 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()) - err := downloadK0sCmd.RunE(nil, nil) + 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")) - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - // Should fail with platform error on non-Linux amd64 platforms - Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) - } else { - // On Linux amd64, it should fail on network/version fetch since we don't have real network access - Expect(err.Error()).To(ContainSubstring("mock create error")) - } + Expect(err.Error()).To(ContainSubstring("download failed")) }) - }) -}) -var _ = Describe("AddDownloadK0sCmd", func() { - var ( - parentCmd *cobra.Command - globalOpts *cmd.GlobalOptions - ) + It("succeeds when version is specified and download works", func() { + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - BeforeEach(func() { - parentCmd = &cobra.Command{Use: "download"} - globalOpts = &cmd.GlobalOptions{} - }) + 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("adds the k0s command with correct properties and flags", func() { - cmd.AddDownloadK0sCmd(parentCmd, globalOpts) + It("succeeds when version is auto-detected and download works", func() { + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - var k0sCmd *cobra.Command - for _, c := range parentCmd.Commands() { - if c.Use == "k0s" { - k0sCmd = c - break - } - } + 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) - Expect(k0sCmd).NotTo(BeNil()) - Expect(k0sCmd.Use).To(Equal("k0s")) - Expect(k0sCmd.Short).To(Equal("Download k0s Kubernetes distribution")) - Expect(k0sCmd.Long).To(ContainSubstring("Download k0s, a zero friction Kubernetes distribution")) - Expect(k0sCmd.Long).To(ContainSubstring("using a Go-native implementation")) - Expect(k0sCmd.RunE).NotTo(BeNil()) - - Expect(k0sCmd.Parent()).To(Equal(parentCmd)) - Expect(parentCmd.Commands()).To(ContainElement(k0sCmd)) - - // Check flags - forceFlag := k0sCmd.Flags().Lookup("force") - Expect(forceFlag).NotTo(BeNil()) - Expect(forceFlag.Shorthand).To(Equal("f")) - Expect(forceFlag.DefValue).To(Equal("false")) - Expect(forceFlag.Usage).To(Equal("Force download even if k0s binary exists")) - - quietFlag := k0sCmd.Flags().Lookup("quiet") - Expect(quietFlag).NotTo(BeNil()) - Expect(quietFlag.Shorthand).To(Equal("q")) - Expect(quietFlag.DefValue).To(Equal("false")) - Expect(quietFlag.Usage).To(Equal("Suppress progress output during download")) - - // Check examples - Expect(k0sCmd.Example).NotTo(BeEmpty()) - Expect(k0sCmd.Example).To(ContainSubstring("oms-cli download k0s")) - Expect(k0sCmd.Example).To(ContainSubstring("--quiet")) - Expect(k0sCmd.Example).To(ContainSubstring("--force")) + err := c.DownloadK0s(mockK0sManager) + Expect(err).ToNot(HaveOccurred()) + }) }) }) diff --git a/cli/cmd/install_k0s.go b/cli/cmd/install_k0s.go index 27129554..28c44548 100644 --- a/cli/cmd/install_k0s.go +++ b/cli/cmd/install_k0s.go @@ -25,23 +25,19 @@ type InstallK0sCmd struct { type InstallK0sOpts struct { *GlobalOptions - Config string - Force bool + 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) - if !k0s.BinaryExists() || c.Opts.Force { - err := k0s.Download(c.Opts.Force, false) - if err != nil { - return fmt.Errorf("failed to download k0s: %w", err) - } - } - - err := k0s.Install(c.Opts.Config, c.Opts.Force) + err := c.InstallK0s(pm, k0s) if err != nil { return fmt.Errorf("failed to install k0s: %w", err) } @@ -54,11 +50,15 @@ func AddInstallK0sCmd(install *cobra.Command, opts *GlobalOptions) { cmd: &cobra.Command{ Use: "k0s", Short: "Install k0s Kubernetes distribution", - Long: packageio.Long(`Install k0s, a zero friction Kubernetes distribution, - using a Go-native implementation. This will download the k0s - binary directly to the OMS workdir, if not already present, and install it.`), + 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: "--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"), @@ -67,6 +67,8 @@ func AddInstallK0sCmd(install *cobra.Command, opts *GlobalOptions) { 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().StringVarP(&k0s.Opts.Config, "config", "c", "", "Path to k0s configuration file") k0s.cmd.Flags().BoolVarP(&k0s.Opts.Force, "force", "f", false, "Force new download and installation") @@ -74,3 +76,25 @@ func AddInstallK0sCmd(install *cobra.Command, opts *GlobalOptions) { 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 index 8761c377..e1899122 100644 --- a/cli/cmd/install_k0s_test.go +++ b/cli/cmd/install_k0s_test.go @@ -5,11 +5,9 @@ package cmd_test import ( "errors" - "runtime" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/spf13/cobra" "github.com/codesphere-cloud/oms/cli/cmd" "github.com/codesphere-cloud/oms/internal/env" @@ -19,23 +17,26 @@ import ( var _ = Describe("InstallK0sCmd", func() { var ( - installK0sCmd *cmd.InstallK0sCmd + c cmd.InstallK0sCmd + opts *cmd.InstallK0sOpts + globalOpts *cmd.GlobalOptions mockEnv *env.MockEnv mockFileWriter *util.MockFileIO - mockK0sManager *installer.MockK0sManager ) BeforeEach(func() { mockEnv = env.NewMockEnv(GinkgoT()) mockFileWriter = util.NewMockFileIO(GinkgoT()) - mockK0sManager = installer.NewMockK0sManager(GinkgoT()) - - installK0sCmd = &cmd.InstallK0sCmd{ - Opts: cmd.InstallK0sOpts{ - GlobalOptions: &cmd.GlobalOptions{}, - Config: "", - Force: false, - }, + globalOpts = &cmd.GlobalOptions{} + opts = &cmd.InstallK0sOpts{ + GlobalOptions: globalOpts, + Version: "", + Package: "", + Config: "", + Force: false, + } + c = cmd.InstallK0sCmd{ + Opts: *opts, Env: mockEnv, FileWriter: mockFileWriter, } @@ -44,119 +45,75 @@ var _ = Describe("InstallK0sCmd", func() { AfterEach(func() { mockEnv.AssertExpectations(GinkgoT()) mockFileWriter.AssertExpectations(GinkgoT()) - if mockK0sManager != nil { - mockK0sManager.AssertExpectations(GinkgoT()) - } }) - Context("RunE", func() { - It("should successfully handle k0s install integration", func() { - // Add mock expectations for the new BinaryExists and download functionality, intentionally causing create to fail - mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() - mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(false).Maybe() - mockFileWriter.EXPECT().Create("/test/workdir/k0s").Return(nil, errors.New("mock create error")).Maybe() - - err := installK0sCmd.RunE(nil, nil) + Context("RunE method", func() { + It("calls InstallK0s and fails with network error", func() { + mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir") + err := c.RunE(nil, []string{}) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to download k0s")) - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - // Should fail with platform error on non-Linux amd64 platforms - Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) - } else { - // On Linux amd64, it should fail on file creation or network/version fetch since we don't have real network access - Expect(err.Error()).To(ContainSubstring("mock create error")) - } + Expect(err.Error()).To(ContainSubstring("failed to install k0s")) }) + }) - It("should download k0s when binary doesn't exist", func() { - // Add mock expectations for the download functionality, intentionally causing create to fail - mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() - mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(false).Maybe() - mockFileWriter.EXPECT().Create("/test/workdir/k0s").Return(nil, errors.New("mock create error")).Maybe() + Context("InstallK0s method", func() { + It("fails when package is not specified and k0s download fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - err := installK0sCmd.RunE(nil, nil) + 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")) - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - // Should fail with platform error on non-Linux amd64 platforms - Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) - } else { - // On Linux amd64, it should fail on file creation or network/version fetch since we don't have real network access - Expect(err.Error()).To(ContainSubstring("mock create error")) - } + Expect(err.Error()).To(ContainSubstring("download failed")) }) - It("should skip download when binary exists and force is false", func() { - // Set up the test so that binary exists - mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir").Maybe() - mockFileWriter.EXPECT().Exists("/test/workdir/k0s").Return(true).Maybe() + It("fails when k0s install fails", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - err := installK0sCmd.RunE(nil, nil) + 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")) - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { - // Should fail with platform error on non-Linux amd64 platforms - Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) - } else { - // On Linux amd64, it should fail on file creation or network/version fetch since we don't have real network access - Expect(err.Error()).To(ContainSubstring("no such file or directory")) - } + Expect(err.Error()).To(ContainSubstring("install failed")) }) - }) -}) -var _ = Describe("AddInstallK0sCmd", func() { - var ( - parentCmd *cobra.Command - globalOpts *cmd.GlobalOptions - ) + It("succeeds when package is not specified and k0s download and install work", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - BeforeEach(func() { - parentCmd = &cobra.Command{Use: "install"} - globalOpts = &cmd.GlobalOptions{} - }) + 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) - It("adds the k0s command with correct properties and flags", func() { - cmd.AddInstallK0sCmd(parentCmd, globalOpts) + err := c.InstallK0s(mockPackageManager, mockK0sManager) + Expect(err).ToNot(HaveOccurred()) + }) - var k0sCmd *cobra.Command - for _, c := range parentCmd.Commands() { - if c.Use == "k0s" { - k0sCmd = c - break - } - } + It("succeeds when package is specified and k0s install works", func() { + mockPackageManager := installer.NewMockPackageManager(GinkgoT()) + mockK0sManager := installer.NewMockK0sManager(GinkgoT()) - Expect(k0sCmd).NotTo(BeNil()) - Expect(k0sCmd.Use).To(Equal("k0s")) - Expect(k0sCmd.Short).To(Equal("Install k0s Kubernetes distribution")) - Expect(k0sCmd.Long).To(ContainSubstring("Install k0s, a zero friction Kubernetes distribution")) - Expect(k0sCmd.RunE).NotTo(BeNil()) - - Expect(k0sCmd.Parent()).To(Equal(parentCmd)) - Expect(parentCmd.Commands()).To(ContainElement(k0sCmd)) - - // Check flags - configFlag := k0sCmd.Flags().Lookup("config") - Expect(configFlag).NotTo(BeNil()) - Expect(configFlag.Shorthand).To(Equal("c")) - Expect(configFlag.DefValue).To(Equal("")) - Expect(configFlag.Usage).To(Equal("Path to k0s configuration file")) - - forceFlag := k0sCmd.Flags().Lookup("force") - Expect(forceFlag).NotTo(BeNil()) - Expect(forceFlag.Shorthand).To(Equal("f")) - Expect(forceFlag.DefValue).To(Equal("false")) - Expect(forceFlag.Usage).To(Equal("Force new download and installation")) - - // Check examples - Expect(k0sCmd).NotTo(BeNil()) - Expect(k0sCmd.Example).NotTo(BeEmpty()) - Expect(k0sCmd.Example).To(ContainSubstring("oms-cli install k0s")) - Expect(k0sCmd.Example).To(ContainSubstring("--config")) - Expect(k0sCmd.Example).To(ContainSubstring("--force")) + 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/internal/installer/k0s.go b/internal/installer/k0s.go index 1a45540e..1c350b37 100644 --- a/internal/installer/k0s.go +++ b/internal/installer/k0s.go @@ -17,9 +17,9 @@ import ( ) type K0sManager interface { - BinaryExists() bool - Download(force bool, quiet bool) error - Install(configPath string, force bool) error + GetLatestVersion() (string, error) + Download(version string, force bool, quiet bool) (string, error) + Install(configPath string, k0sPath string, force bool) error } type K0s struct { @@ -40,38 +40,36 @@ func NewK0s(hw portal.Http, env env.Env, fw util.FileIO) K0sManager { } } -func (k *K0s) BinaryExists() bool { - workdir := k.Env.GetOmsWorkdir() - k0sPath := filepath.Join(workdir, "k0s") - return k.FileWriter.Exists(k0sPath) -} - -func (k *K0s) Download(force bool, quiet bool) 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) - } - - // Get the latest k0s version +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) + 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 "", 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.BinaryExists() && !force { - return fmt.Errorf("k0s binary already exists at %s. Use --force to overwrite", k0sPath) + 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) + return "", fmt.Errorf("failed to create k0s binary file: %w", err) } defer util.CloseFileIgnoreError(file) @@ -81,32 +79,30 @@ func (k *K0s) Download(force bool, quiet bool) error { 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) + 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) + return "", fmt.Errorf("failed to make k0s binary executable: %w", err) } log.Printf("k0s binary downloaded and made executable at '%s'", k0sPath) - return nil + return k0sPath, nil } -func (k *K0s) Install(configPath string, force bool) error { +func (k *K0s) Install(configPath string, k0sPath string, force bool) 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) + return fmt.Errorf("k0s installation is only supported on Linux amd64. Current platform: %s/%s", k.Goos, k.Goarch) } - workdir := k.Env.GetOmsWorkdir() - k0sPath := filepath.Join(workdir, "k0s") - if !k.BinaryExists() { + if !k.FileWriter.Exists(k0sPath) { return fmt.Errorf("k0s binary does not exist in '%s', please download first", k0sPath) } - args := []string{"./k0s", "install", "controller"} + args := []string{k0sPath, "install", "controller"} if configPath != "" { args = append(args, "--config", configPath) } else { @@ -117,14 +113,18 @@ func (k *K0s) Install(configPath string, force bool) error { args = append(args, "--force") } - err := util.RunCommand("sudo", args, workdir) + err := util.RunCommand("sudo", args, "") if err != nil { return fmt.Errorf("failed to install k0s: %w", err) } - log.Println("k0s installed successfully in single-node mode.") - log.Printf("You can start it using 'sudo %v/k0s start'", workdir) - log.Printf("You can check the status using 'sudo %v/k0s status'", workdir) + 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 index 7cc8d149..774b01d4 100644 --- a/internal/installer/k0s_test.go +++ b/internal/installer/k0s_test.go @@ -55,6 +55,56 @@ var _ = Describe("K0s", func() { Expect(k0sStruct.Goos).ToNot(BeEmpty()) Expect(k0sStruct.Goarch).ToNot(BeEmpty()) }) + + It("implements K0sManager interface", func() { + var manager installer.K0sManager = 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() { @@ -63,7 +113,7 @@ var _ = Describe("K0s", func() { k0sImpl.Goos = "windows" k0sImpl.Goarch = "amd64" - err := k0s.Download(false, false) + _, 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")) @@ -73,7 +123,7 @@ var _ = Describe("K0s", func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "arm64" - err := k0s.Download(false, false) + _, 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")) @@ -86,27 +136,7 @@ var _ = Describe("K0s", func() { k0sImpl.Goarch = "amd64" }) - It("should fail when version fetch fails", func() { - mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(nil, errors.New("network error")) - - err := k0s.Download(false, false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to fetch version info")) - Expect(err.Error()).To(ContainSubstring("network error")) - }) - - It("should fail when version is empty", func() { - emptyVersionBytes := []byte(" \n ") - mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(emptyVersionBytes, nil) - - err := k0s.Download(false, false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("version info is empty")) - }) - - It("should handle version with whitespace correctly", func() { - versionWithWhitespace := []byte(" v1.29.1+k0s.0 \n") - mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return(versionWithWhitespace, nil) + It("should handle version parameter correctly", func() { mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) mockFileWriter.EXPECT().Exists(k0sPath).Return(false) @@ -122,8 +152,9 @@ var _ = Describe("K0s", func() { 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) - err = k0s.Download(false, false) + path, err := k0s.Download("v1.29.1+k0s.0", false, false) Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(k0sPath)) }) }) @@ -131,14 +162,13 @@ var _ = Describe("K0s", func() { BeforeEach(func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "amd64" - mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return([]byte("v1.29.1+k0s.0"), nil) 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(false, false) + _, 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")) @@ -159,8 +189,9 @@ var _ = Describe("K0s", func() { 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) - err = k0s.Download(true, false) + path, err := k0s.Download("v1.29.1+k0s.0", true, false) Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(k0sPath)) }) }) @@ -168,7 +199,6 @@ var _ = Describe("K0s", func() { BeforeEach(func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "amd64" - mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return([]byte("v1.29.1+k0s.0"), nil) mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) mockFileWriter.EXPECT().Exists(k0sPath).Return(false) }) @@ -176,7 +206,7 @@ var _ = Describe("K0s", func() { It("should fail when file creation fails", func() { mockFileWriter.EXPECT().Create(k0sPath).Return(nil, errors.New("permission denied")) - err := k0s.Download(false, false) + _, 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")) @@ -194,7 +224,7 @@ var _ = Describe("K0s", func() { 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(false, false) + _, 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")) @@ -212,8 +242,9 @@ var _ = Describe("K0s", func() { 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) - err = k0s.Download(false, false) + 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) @@ -231,7 +262,6 @@ var _ = Describe("K0s", func() { It("should construct correct download URL for amd64", func() { k0sImpl.Goarch = "amd64" - mockHttp.EXPECT().Get("https://docs.k0sproject.io/stable.txt").Return([]byte("v1.29.1+k0s.0"), nil) // Create the workdir first err := os.MkdirAll(workDir, 0755) @@ -245,8 +275,9 @@ var _ = Describe("K0s", func() { 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) - err = k0s.Download(false, false) + path, err := k0s.Download("v1.29.1+k0s.0", false, false) Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(k0sPath)) }) }) }) @@ -257,9 +288,9 @@ var _ = Describe("K0s", func() { k0sImpl.Goos = "windows" k0sImpl.Goarch = "amd64" - err := k0s.Install("", false) + err := k0s.Install("", k0sPath, false) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) + Expect(err.Error()).To(ContainSubstring("k0s installation is only supported on Linux amd64")) Expect(err.Error()).To(ContainSubstring("windows/amd64")) }) @@ -267,9 +298,9 @@ var _ = Describe("K0s", func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "arm64" - err := k0s.Install("", false) + err := k0s.Install("", k0sPath, false) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("codesphere installation is only supported on Linux amd64")) + Expect(err.Error()).To(ContainSubstring("k0s installation is only supported on Linux amd64")) Expect(err.Error()).To(ContainSubstring("linux/arm64")) }) }) @@ -278,62 +309,16 @@ var _ = Describe("K0s", func() { BeforeEach(func() { k0sImpl.Goos = "linux" k0sImpl.Goarch = "amd64" - mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) }) It("should fail when k0s binary doesn't exist", func() { mockFileWriter.EXPECT().Exists(k0sPath).Return(false) - err := k0s.Install("", 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")) }) - - It("should proceed when k0s binary exists", func() { - mockFileWriter.EXPECT().Exists(k0sPath).Return(true) - - // This will fail with exec error since we can't actually run k0s in tests - // but it will pass the existence check - err := k0s.Install("", false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to install k0s")) - }) - }) - - Context("Installation modes", func() { - BeforeEach(func() { - k0sImpl.Goos = "linux" - k0sImpl.Goarch = "amd64" - mockEnv.EXPECT().GetOmsWorkdir().Return(workDir) - mockFileWriter.EXPECT().Exists(k0sPath).Return(true) - }) - - It("should install in single-node mode when no config path is provided", func() { - // This will fail with exec error but we can verify the command would be called - // The command should be: ./k0s install controller --single - err := k0s.Install("", false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to install k0s")) - }) - - It("should install with custom config when config path is provided", func() { - configPath := "/path/to/k0s.yaml" - // This will fail with exec error but we can verify the command would be called - // The command should be: ./k0s install controller --config /path/to/k0s.yaml - err := k0s.Install(configPath, false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to install k0s")) - }) - - It("should install with custom config and force flag", func() { - configPath := "/path/to/k0s.yaml" - // This will fail with exec error but we can verify the command would be called - // The command should be: ./k0s install controller --config /path/to/k0s.yaml --force - err := k0s.Install(configPath, true) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to install k0s")) - }) }) }) }) diff --git a/internal/installer/mocks.go b/internal/installer/mocks.go index 0057cfbd..9deade20 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -118,107 +118,126 @@ func (_m *MockK0sManager) EXPECT() *MockK0sManager_Expecter { return &MockK0sManager_Expecter{mock: &_m.Mock} } -// BinaryExists provides a mock function for the type MockK0sManager -func (_mock *MockK0sManager) BinaryExists() bool { - ret := _mock.Called() +// 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 BinaryExists") + panic("no return value specified for Download") } - var r0 bool - if returnFunc, ok := ret.Get(0).(func() bool); ok { - r0 = returnFunc() + 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).(bool) + r0 = ret.Get(0).(string) } - return r0 + 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_BinaryExists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BinaryExists' -type MockK0sManager_BinaryExists_Call struct { +// 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 } -// BinaryExists is a helper method to define mock.On call -func (_e *MockK0sManager_Expecter) BinaryExists() *MockK0sManager_BinaryExists_Call { - return &MockK0sManager_BinaryExists_Call{Call: _e.mock.On("BinaryExists")} +// 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_BinaryExists_Call) Run(run func()) *MockK0sManager_BinaryExists_Call { +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() + run(args[0].(string), args[1].(bool), args[2].(bool)) }) return _c } -func (_c *MockK0sManager_BinaryExists_Call) Return(b bool) *MockK0sManager_BinaryExists_Call { - _c.Call.Return(b) +func (_c *MockK0sManager_Download_Call) Return(s string, err error) *MockK0sManager_Download_Call { + _c.Call.Return(s, err) return _c } -func (_c *MockK0sManager_BinaryExists_Call) RunAndReturn(run func() bool) *MockK0sManager_BinaryExists_Call { +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 } -// Download provides a mock function for the type MockK0sManager -func (_mock *MockK0sManager) Download(force bool, quiet bool) error { - ret := _mock.Called(force, quiet) +// 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 Download") + panic("no return value specified for GetLatestVersion") } - var r0 error - if returnFunc, ok := ret.Get(0).(func(bool, bool) error); ok { - r0 = returnFunc(force, quiet) + 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.Error(0) + r0 = ret.Get(0).(string) } - return r0 + if returnFunc, ok := ret.Get(1).(func() error); ok { + r1 = returnFunc() + } 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 { +// 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 } -// Download is a helper method to define mock.On call -// - force -// - quiet -func (_e *MockK0sManager_Expecter) Download(force interface{}, quiet interface{}) *MockK0sManager_Download_Call { - return &MockK0sManager_Download_Call{Call: _e.mock.On("Download", force, quiet)} +// 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_Download_Call) Run(run func(force bool, quiet bool)) *MockK0sManager_Download_Call { +func (_c *MockK0sManager_GetLatestVersion_Call) Run(run func()) *MockK0sManager_GetLatestVersion_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(bool), args[1].(bool)) + run() }) return _c } -func (_c *MockK0sManager_Download_Call) Return(err error) *MockK0sManager_Download_Call { - _c.Call.Return(err) +func (_c *MockK0sManager_GetLatestVersion_Call) Return(s string, err error) *MockK0sManager_GetLatestVersion_Call { + _c.Call.Return(s, err) return _c } -func (_c *MockK0sManager_Download_Call) RunAndReturn(run func(force bool, quiet bool) error) *MockK0sManager_Download_Call { +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, force bool) error { - ret := _mock.Called(configPath, force) +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, bool) error); ok { - r0 = returnFunc(configPath, force) + if returnFunc, ok := ret.Get(0).(func(string, string, bool) error); ok { + r0 = returnFunc(configPath, k0sPath, force) } else { r0 = ret.Error(0) } @@ -232,14 +251,15 @@ type MockK0sManager_Install_Call struct { // Install is a helper method to define mock.On call // - configPath +// - k0sPath // - force -func (_e *MockK0sManager_Expecter) Install(configPath interface{}, force interface{}) *MockK0sManager_Install_Call { - return &MockK0sManager_Install_Call{Call: _e.mock.On("Install", configPath, 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, force bool)) *MockK0sManager_Install_Call { +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].(bool)) + run(args[0].(string), args[1].(string), args[2].(bool)) }) return _c } @@ -249,7 +269,7 @@ func (_c *MockK0sManager_Install_Call) Return(err error) *MockK0sManager_Install return _c } -func (_c *MockK0sManager_Install_Call) RunAndReturn(run func(configPath string, force bool) error) *MockK0sManager_Install_Call { +func (_c *MockK0sManager_Install_Call) RunAndReturn(run func(configPath string, k0sPath string, force bool) error) *MockK0sManager_Install_Call { _c.Call.Return(run) return _c } 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) From 15c182bc2af064e253a0ed98b8cbf5b1afd0bc1a Mon Sep 17 00:00:00 2001 From: siherrmann <25087590+siherrmann@users.noreply.github.com> Date: Tue, 11 Nov 2025 12:46:34 +0000 Subject: [PATCH 13/20] chore(docs): Auto-update docs and licenses Signed-off-by: siherrmann <25087590+siherrmann@users.noreply.github.com> --- docs/README.md | 1 - docs/oms-cli.md | 1 - docs/oms-cli_beta.md | 1 - docs/oms-cli_beta_extend.md | 1 - docs/oms-cli_beta_extend_baseimage.md | 1 - docs/oms-cli_build.md | 1 - docs/oms-cli_build_image.md | 2 +- docs/oms-cli_build_images.md | 2 +- docs/oms-cli_download.md | 1 - docs/oms-cli_download_k0s.md | 16 +++++++++------- docs/oms-cli_download_package.md | 1 - docs/oms-cli_install.md | 1 - docs/oms-cli_install_codesphere.md | 1 - docs/oms-cli_install_k0s.md | 23 ++++++++++++++++------- docs/oms-cli_licenses.md | 1 - docs/oms-cli_list.md | 1 - docs/oms-cli_list_api-keys.md | 1 - docs/oms-cli_list_packages.md | 1 - docs/oms-cli_register.md | 1 - docs/oms-cli_revoke.md | 1 - docs/oms-cli_revoke_api-key.md | 1 - docs/oms-cli_update.md | 1 - docs/oms-cli_update_api-key.md | 1 - docs/oms-cli_update_dockerfile.md | 1 - docs/oms-cli_update_oms.md | 1 - docs/oms-cli_update_package.md | 1 - docs/oms-cli_version.md | 1 - 27 files changed, 27 insertions(+), 39 deletions(-) diff --git a/docs/README.md b/docs/README.md index 689000b1..a172e71e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,4 +28,3 @@ like downloading new versions. * [oms-cli update](oms-cli_update.md) - Update OMS related resources * [oms-cli version](oms-cli_version.md) - Print version -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli.md b/docs/oms-cli.md index 689000b1..a172e71e 100644 --- a/docs/oms-cli.md +++ b/docs/oms-cli.md @@ -28,4 +28,3 @@ like downloading new versions. * [oms-cli update](oms-cli_update.md) - Update OMS related resources * [oms-cli version](oms-cli_version.md) - Print version -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_beta.md b/docs/oms-cli_beta.md index 5449a11e..6952fda5 100644 --- a/docs/oms-cli_beta.md +++ b/docs/oms-cli_beta.md @@ -18,4 +18,3 @@ Be aware that that usage and behavior may change as the features are developed. * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) * [oms-cli beta extend](oms-cli_beta_extend.md) - Extend Codesphere ressources such as base images. -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_beta_extend.md b/docs/oms-cli_beta_extend.md index 4022a7a5..95bc3e63 100644 --- a/docs/oms-cli_beta_extend.md +++ b/docs/oms-cli_beta_extend.md @@ -17,4 +17,3 @@ Extend Codesphere ressources such as base images to customize them for your need * [oms-cli beta](oms-cli_beta.md) - Commands for early testing * [oms-cli beta extend baseimage](oms-cli_beta_extend_baseimage.md) - Extend Codesphere's workspace base image for customization -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_beta_extend_baseimage.md b/docs/oms-cli_beta_extend_baseimage.md index 10be7546..50b8fa19 100644 --- a/docs/oms-cli_beta_extend_baseimage.md +++ b/docs/oms-cli_beta_extend_baseimage.md @@ -28,4 +28,3 @@ oms-cli beta extend baseimage [flags] * [oms-cli beta extend](oms-cli_beta_extend.md) - Extend Codesphere ressources such as base images. -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_build.md b/docs/oms-cli_build.md index 881d263d..76481ada 100644 --- a/docs/oms-cli_build.md +++ b/docs/oms-cli_build.md @@ -18,4 +18,3 @@ Build and push container images to a registry using the provided configuration. * [oms-cli build image](oms-cli_build_image.md) - Build and push Docker image using Dockerfile and Codesphere package version * [oms-cli build images](oms-cli_build_images.md) - Build and push container images -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_build_image.md b/docs/oms-cli_build_image.md index 37a3e284..bc55ea4a 100644 --- a/docs/oms-cli_build_image.md +++ b/docs/oms-cli_build_image.md @@ -22,6 +22,7 @@ $ oms-cli build image --dockerfile baseimage/Dockerfile --package codesphere-v1. ``` -d, --dockerfile string Path to the Dockerfile to build (required) + -f, --force Force new unpacking of the package even if already extracted -h, --help help for image -p, --package string Path to the Codesphere package (required) -r, --registry string Registry URL to push to (e.g., my-registry.com/my-image) (required) @@ -31,4 +32,3 @@ $ oms-cli build image --dockerfile baseimage/Dockerfile --package codesphere-v1. * [oms-cli build](oms-cli_build.md) - Build and push images to a registry -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_build_images.md b/docs/oms-cli_build_images.md index e2309618..a2f5e409 100644 --- a/docs/oms-cli_build_images.md +++ b/docs/oms-cli_build_images.md @@ -15,6 +15,7 @@ oms-cli build images [flags] ``` -c, --config string Path to the configuration YAML file + -f, --force Force new unpacking of the package even if already extracted -h, --help help for images ``` @@ -22,4 +23,3 @@ oms-cli build images [flags] * [oms-cli build](oms-cli_build.md) - Build and push images to a registry -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_download.md b/docs/oms-cli_download.md index c5d8efb8..236f45ff 100644 --- a/docs/oms-cli_download.md +++ b/docs/oms-cli_download.md @@ -19,4 +19,3 @@ e.g. available Codesphere packages * [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 -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_download_k0s.md b/docs/oms-cli_download_k0s.md index 57105d47..2473948c 100644 --- a/docs/oms-cli_download_k0s.md +++ b/docs/oms-cli_download_k0s.md @@ -4,9 +4,8 @@ Download k0s Kubernetes distribution ### Synopsis -Download k0s, a zero friction Kubernetes distribution, -using a Go-native implementation. This will download the k0s -binary directly to the OMS workdir. +Download a k0s binary directly to the OMS workdir. +Will download the latest version if no version is specified. ``` oms-cli download k0s [flags] @@ -18,6 +17,9 @@ oms-cli download k0s [flags] # 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 @@ -29,13 +31,13 @@ $ 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 + -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 -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_download_package.md b/docs/oms-cli_download_package.md index b2df39a4..7f1ee1b1 100644 --- a/docs/oms-cli_download_package.md +++ b/docs/oms-cli_download_package.md @@ -36,4 +36,3 @@ $ oms-cli download package --version codesphere-v1.55.0 --file installer-lite.ta * [oms-cli download](oms-cli_download.md) - Download resources available through OMS -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_install.md b/docs/oms-cli_install.md index 41dcf504..89ee5f30 100644 --- a/docs/oms-cli_install.md +++ b/docs/oms-cli_install.md @@ -18,4 +18,3 @@ Coming soon: Install Codesphere and other components like Ceph and PostgreSQL. * [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 -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_install_codesphere.md b/docs/oms-cli_install_codesphere.md index 29816e92..f2df7677 100644 --- a/docs/oms-cli_install_codesphere.md +++ b/docs/oms-cli_install_codesphere.md @@ -26,4 +26,3 @@ oms-cli install codesphere [flags] * [oms-cli install](oms-cli_install.md) - Coming soon: Install Codesphere and other components -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_install_k0s.md b/docs/oms-cli_install_k0s.md index 4f088806..084631d7 100644 --- a/docs/oms-cli_install_k0s.md +++ b/docs/oms-cli_install_k0s.md @@ -4,9 +4,11 @@ Install k0s Kubernetes distribution ### Synopsis -Install k0s, a zero friction Kubernetes distribution, -using a Go-native implementation. This will download the k0s -binary directly to the OMS workdir, if not already present, and install it. +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] @@ -18,6 +20,12 @@ oms-cli install k0s [flags] # 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 --config @@ -29,13 +37,14 @@ $ oms-cli install k0s --force ### Options ``` - -c, --config string Path to k0s configuration file - -f, --force Force new download and installation - -h, --help help for k0s + -c, --config string Path to k0s configuration file + -f, --force Force new download and installation + -h, --help help for k0s + -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 -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_licenses.md b/docs/oms-cli_licenses.md index e2fb56df..d6e1487b 100644 --- a/docs/oms-cli_licenses.md +++ b/docs/oms-cli_licenses.md @@ -20,4 +20,3 @@ oms-cli licenses [flags] * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_list.md b/docs/oms-cli_list.md index 781c5eee..0d5a7c4b 100644 --- a/docs/oms-cli_list.md +++ b/docs/oms-cli_list.md @@ -19,4 +19,3 @@ eg. available Codesphere packages * [oms-cli list api-keys](oms-cli_list_api-keys.md) - List API keys * [oms-cli list packages](oms-cli_list_packages.md) - List available packages -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_list_api-keys.md b/docs/oms-cli_list_api-keys.md index 9cec7b1e..dd7b1c41 100644 --- a/docs/oms-cli_list_api-keys.md +++ b/docs/oms-cli_list_api-keys.md @@ -20,4 +20,3 @@ oms-cli list api-keys [flags] * [oms-cli list](oms-cli_list.md) - List resources available through OMS -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_list_packages.md b/docs/oms-cli_list_packages.md index f6d76165..1b2ae8bf 100644 --- a/docs/oms-cli_list_packages.md +++ b/docs/oms-cli_list_packages.md @@ -20,4 +20,3 @@ oms-cli list packages [flags] * [oms-cli list](oms-cli_list.md) - List resources available through OMS -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_register.md b/docs/oms-cli_register.md index 8a152d48..a2438b90 100644 --- a/docs/oms-cli_register.md +++ b/docs/oms-cli_register.md @@ -24,4 +24,3 @@ oms-cli register [flags] * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_revoke.md b/docs/oms-cli_revoke.md index 541c96c8..dcab1d81 100644 --- a/docs/oms-cli_revoke.md +++ b/docs/oms-cli_revoke.md @@ -18,4 +18,3 @@ eg. api keys. * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) * [oms-cli revoke api-key](oms-cli_revoke_api-key.md) - Revoke an API key -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_revoke_api-key.md b/docs/oms-cli_revoke_api-key.md index 738a5543..45524164 100644 --- a/docs/oms-cli_revoke_api-key.md +++ b/docs/oms-cli_revoke_api-key.md @@ -21,4 +21,3 @@ oms-cli revoke api-key [flags] * [oms-cli revoke](oms-cli_revoke.md) - Revoke resources available through OMS -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update.md b/docs/oms-cli_update.md index d9e2f3dd..817b510f 100644 --- a/docs/oms-cli_update.md +++ b/docs/oms-cli_update.md @@ -24,4 +24,3 @@ oms-cli update [flags] * [oms-cli update oms](oms-cli_update_oms.md) - Update the OMS CLI * [oms-cli update package](oms-cli_update_package.md) - Download a codesphere package -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update_api-key.md b/docs/oms-cli_update_api-key.md index 257fcfb7..4948844a 100644 --- a/docs/oms-cli_update_api-key.md +++ b/docs/oms-cli_update_api-key.md @@ -22,4 +22,3 @@ oms-cli update api-key [flags] * [oms-cli update](oms-cli_update.md) - Update OMS related resources -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update_dockerfile.md b/docs/oms-cli_update_dockerfile.md index 14b84f39..f127c12b 100644 --- a/docs/oms-cli_update_dockerfile.md +++ b/docs/oms-cli_update_dockerfile.md @@ -38,4 +38,3 @@ $ oms-cli update dockerfile --dockerfile baseimage/Dockerfile --package codesphe * [oms-cli update](oms-cli_update.md) - Update OMS related resources -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update_oms.md b/docs/oms-cli_update_oms.md index b0ef6900..1a481d7d 100644 --- a/docs/oms-cli_update_oms.md +++ b/docs/oms-cli_update_oms.md @@ -20,4 +20,3 @@ oms-cli update oms [flags] * [oms-cli update](oms-cli_update.md) - Update OMS related resources -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_update_package.md b/docs/oms-cli_update_package.md index 34468393..03bf25e4 100644 --- a/docs/oms-cli_update_package.md +++ b/docs/oms-cli_update_package.md @@ -36,4 +36,3 @@ $ oms-cli download package --version codesphere-v1.55.0 --file installer-lite.ta * [oms-cli update](oms-cli_update.md) - Update OMS related resources -###### Auto generated by spf13/cobra on 5-Nov-2025 diff --git a/docs/oms-cli_version.md b/docs/oms-cli_version.md index cb0bde01..17daf6ef 100644 --- a/docs/oms-cli_version.md +++ b/docs/oms-cli_version.md @@ -20,4 +20,3 @@ oms-cli version [flags] * [oms-cli](oms-cli.md) - Codesphere Operations Management System (OMS) -###### Auto generated by spf13/cobra on 5-Nov-2025 From c9b6530e005bccb4e60276105224ba981018bb88 Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Tue, 11 Nov 2025 13:49:44 +0100 Subject: [PATCH 14/20] fix: fix portal tests --- internal/portal/portal_test.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/internal/portal/portal_test.go b/internal/portal/portal_test.go index 9aff801d..a3bf3604 100644 --- a/internal/portal/portal_test.go +++ b/internal/portal/portal_test.go @@ -39,6 +39,7 @@ var _ = Describe("PortalClient", func() { status int apiUrl string getUrl url.URL + headers http.Header getResponse []byte product portal.Product apiKey string @@ -186,6 +187,7 @@ var _ = Describe("PortalClient", func() { 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))), @@ -195,12 +197,21 @@ var _ = Describe("PortalClient", func() { It("downloads the build", func() { fakeWriter := NewFakeWriter() - err := client.DownloadBuildArtifact(product, build, fakeWriter, false) + 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() @@ -208,7 +219,7 @@ var _ = Describe("PortalClient", func() { defer log.SetOutput(prev) fakeWriter := NewFakeWriter() - err := client.DownloadBuildArtifact(product, build, fakeWriter, false) + err := client.DownloadBuildArtifact(product, build, fakeWriter, 0, false) Expect(err).NotTo(HaveOccurred()) Expect(logBuf.String()).To(ContainSubstring("Downloading...")) }) @@ -220,7 +231,7 @@ var _ = Describe("PortalClient", func() { defer log.SetOutput(prev) fakeWriter := NewFakeWriter() - err := client.DownloadBuildArtifact(product, build, fakeWriter, true) + err := client.DownloadBuildArtifact(product, build, fakeWriter, 0, true) Expect(err).NotTo(HaveOccurred()) Expect(logBuf.String()).NotTo(ContainSubstring("Downloading...")) }) From ad10f12658ba23d3aa5c9172d024dbf10afcd2d2 Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Tue, 11 Nov 2025 13:53:30 +0100 Subject: [PATCH 15/20] fix: fix lint error omit type --- internal/installer/k0s_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/installer/k0s_test.go b/internal/installer/k0s_test.go index 774b01d4..83b9109f 100644 --- a/internal/installer/k0s_test.go +++ b/internal/installer/k0s_test.go @@ -57,7 +57,7 @@ var _ = Describe("K0s", func() { }) It("implements K0sManager interface", func() { - var manager installer.K0sManager = installer.NewK0s(mockHttp, mockEnv, mockFileWriter) + var manager = installer.NewK0s(mockHttp, mockEnv, mockFileWriter) Expect(manager).ToNot(BeNil()) }) }) From e08a7c140a5ec08658854a53a151681ca6d484cd Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Tue, 11 Nov 2025 14:08:51 +0100 Subject: [PATCH 16/20] fix: remove tests with real network calls --- cli/cmd/download_k0s_test.go | 8 -------- cli/cmd/install_k0s_test.go | 10 ---------- 2 files changed, 18 deletions(-) diff --git a/cli/cmd/download_k0s_test.go b/cli/cmd/download_k0s_test.go index 8b6fc46a..fb4dfc7d 100644 --- a/cli/cmd/download_k0s_test.go +++ b/cli/cmd/download_k0s_test.go @@ -46,14 +46,6 @@ var _ = Describe("DownloadK0sCmd", func() { mockFileWriter.AssertExpectations(GinkgoT()) }) - Context("RunE method", func() { - It("calls DownloadK0s and fails with network error", func() { - err := c.RunE(nil, []string{}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to download k0s")) - }) - }) - Context("DownloadK0s method", func() { It("fails when k0s manager fails to get latest version", func() { mockK0sManager := installer.NewMockK0sManager(GinkgoT()) diff --git a/cli/cmd/install_k0s_test.go b/cli/cmd/install_k0s_test.go index e1899122..8c59b542 100644 --- a/cli/cmd/install_k0s_test.go +++ b/cli/cmd/install_k0s_test.go @@ -47,16 +47,6 @@ var _ = Describe("InstallK0sCmd", func() { mockFileWriter.AssertExpectations(GinkgoT()) }) - Context("RunE method", func() { - It("calls InstallK0s and fails with network error", func() { - mockEnv.EXPECT().GetOmsWorkdir().Return("/test/workdir") - - err := c.RunE(nil, []string{}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to install k0s")) - }) - }) - Context("InstallK0s method", func() { It("fails when package is not specified and k0s download fails", func() { mockPackageManager := installer.NewMockPackageManager(GinkgoT()) From 15806670f2ca51885526afe28b14adbc494a5c8a Mon Sep 17 00:00:00 2001 From: siherrmann <25087590+siherrmann@users.noreply.github.com> Date: Thu, 13 Nov 2025 09:41:08 +0000 Subject: [PATCH 17/20] chore(docs): Auto-update docs and licenses Signed-off-by: siherrmann <25087590+siherrmann@users.noreply.github.com> --- internal/tmpl/NOTICE | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index be205461..f7a2e597 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -71,9 +71,9 @@ License URL: https://github.com/inconshreveable/go-update/blob/8152e7eb6ccf/inte ---------- Module: github.com/jedib0t/go-pretty/v6 -Version: v6.6.9 +Version: v6.7.1 License: MIT -License URL: https://github.com/jedib0t/go-pretty/blob/v6.6.9/LICENSE +License URL: https://github.com/jedib0t/go-pretty/blob/v6.7.1/LICENSE ---------- Module: github.com/mattn/go-runewidth From 500ebcfbb2404e4549029ee683246ac5cdd793c5 Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Mon, 17 Nov 2025 17:56:28 +0100 Subject: [PATCH 18/20] update: small updates for pr --- cli/cmd/download_k0s.go | 9 +++++---- cli/cmd/install_k0s.go | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/cli/cmd/download_k0s.go b/cli/cmd/download_k0s.go index 19401277..414684a7 100644 --- a/cli/cmd/download_k0s.go +++ b/cli/cmd/download_k0s.go @@ -72,15 +72,16 @@ func AddDownloadK0sCmd(download *cobra.Command, opts *GlobalOptions) { } func (c *DownloadK0sCmd) DownloadK0s(k0s installer.K0sManager) error { - if c.Opts.Version == "" { - version, err := k0s.GetLatestVersion() + 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) } - c.Opts.Version = version } - k0sPath, err := k0s.Download(c.Opts.Version, c.Opts.Force, c.Opts.Quiet) + k0sPath, err := k0s.Download(version, c.Opts.Force, c.Opts.Quiet) if err != nil { return fmt.Errorf("failed to download k0s: %w", err) } diff --git a/cli/cmd/install_k0s.go b/cli/cmd/install_k0s.go index 28c44548..a5139330 100644 --- a/cli/cmd/install_k0s.go +++ b/cli/cmd/install_k0s.go @@ -59,7 +59,7 @@ func AddInstallK0sCmd(install *cobra.Command, opts *GlobalOptions) { {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: "--config ", Desc: "Path to k0s configuration file, if not set k0s will be installed with the '--single' flag"}, + {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"), }, @@ -69,7 +69,7 @@ func AddInstallK0sCmd(install *cobra.Command, opts *GlobalOptions) { } 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().StringVarP(&k0s.Opts.Config, "config", "c", "", "Path to k0s configuration file") + 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) From 873ebbc43246332a88b834e2955add0e92f3d09d Mon Sep 17 00:00:00 2001 From: Simon Herrmann Date: Mon, 17 Nov 2025 17:58:48 +0100 Subject: [PATCH 19/20] update: update mocks after merge --- go.mod | 2 +- go.sum | 8 +- internal/installer/mocks.go | 480 ++++++++++++++++++++++++++++++++++++ 3 files changed, 483 insertions(+), 7 deletions(-) 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/mocks.go b/internal/installer/mocks.go index 9deade20..ccb2b6a9 100644 --- a/internal/installer/mocks.go +++ b/internal/installer/mocks.go @@ -91,6 +91,486 @@ func (_c *MockConfigManager_ParseConfigYaml_Call) RunAndReturn(run func(configPa return _c } +// NewMockInstallConfigManager creates a new instance of MockInstallConfigManager. 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 NewMockInstallConfigManager(t interface { + mock.TestingT + Cleanup(func()) +}) *MockInstallConfigManager { + mock := &MockInstallConfigManager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockInstallConfigManager is an autogenerated mock type for the InstallConfigManager type +type MockInstallConfigManager struct { + mock.Mock +} + +type MockInstallConfigManager_Expecter struct { + mock *mock.Mock +} + +func (_m *MockInstallConfigManager) EXPECT() *MockInstallConfigManager_Expecter { + return &MockInstallConfigManager_Expecter{mock: &_m.Mock} +} + +// ApplyProfile provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) ApplyProfile(profile string) error { + ret := _mock.Called(profile) + + if len(ret) == 0 { + panic("no return value specified for ApplyProfile") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(profile) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_ApplyProfile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ApplyProfile' +type MockInstallConfigManager_ApplyProfile_Call struct { + *mock.Call +} + +// ApplyProfile is a helper method to define mock.On call +// - profile +func (_e *MockInstallConfigManager_Expecter) ApplyProfile(profile interface{}) *MockInstallConfigManager_ApplyProfile_Call { + return &MockInstallConfigManager_ApplyProfile_Call{Call: _e.mock.On("ApplyProfile", profile)} +} + +func (_c *MockInstallConfigManager_ApplyProfile_Call) Run(run func(profile string)) *MockInstallConfigManager_ApplyProfile_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockInstallConfigManager_ApplyProfile_Call) Return(err error) *MockInstallConfigManager_ApplyProfile_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_ApplyProfile_Call) RunAndReturn(run func(profile string) error) *MockInstallConfigManager_ApplyProfile_Call { + _c.Call.Return(run) + return _c +} + +// CollectInteractively provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) CollectInteractively() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for CollectInteractively") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_CollectInteractively_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CollectInteractively' +type MockInstallConfigManager_CollectInteractively_Call struct { + *mock.Call +} + +// CollectInteractively is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) CollectInteractively() *MockInstallConfigManager_CollectInteractively_Call { + return &MockInstallConfigManager_CollectInteractively_Call{Call: _e.mock.On("CollectInteractively")} +} + +func (_c *MockInstallConfigManager_CollectInteractively_Call) Run(run func()) *MockInstallConfigManager_CollectInteractively_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockInstallConfigManager_CollectInteractively_Call) Return(err error) *MockInstallConfigManager_CollectInteractively_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_CollectInteractively_Call) RunAndReturn(run func() error) *MockInstallConfigManager_CollectInteractively_Call { + _c.Call.Return(run) + return _c +} + +// GenerateSecrets provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) GenerateSecrets() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GenerateSecrets") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_GenerateSecrets_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateSecrets' +type MockInstallConfigManager_GenerateSecrets_Call struct { + *mock.Call +} + +// GenerateSecrets is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) GenerateSecrets() *MockInstallConfigManager_GenerateSecrets_Call { + return &MockInstallConfigManager_GenerateSecrets_Call{Call: _e.mock.On("GenerateSecrets")} +} + +func (_c *MockInstallConfigManager_GenerateSecrets_Call) Run(run func()) *MockInstallConfigManager_GenerateSecrets_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockInstallConfigManager_GenerateSecrets_Call) Return(err error) *MockInstallConfigManager_GenerateSecrets_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_GenerateSecrets_Call) RunAndReturn(run func() error) *MockInstallConfigManager_GenerateSecrets_Call { + _c.Call.Return(run) + return _c +} + +// GetInstallConfig provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) GetInstallConfig() *files.RootConfig { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetInstallConfig") + } + + var r0 *files.RootConfig + if returnFunc, ok := ret.Get(0).(func() *files.RootConfig); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*files.RootConfig) + } + } + return r0 +} + +// MockInstallConfigManager_GetInstallConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetInstallConfig' +type MockInstallConfigManager_GetInstallConfig_Call struct { + *mock.Call +} + +// GetInstallConfig is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) GetInstallConfig() *MockInstallConfigManager_GetInstallConfig_Call { + return &MockInstallConfigManager_GetInstallConfig_Call{Call: _e.mock.On("GetInstallConfig")} +} + +func (_c *MockInstallConfigManager_GetInstallConfig_Call) Run(run func()) *MockInstallConfigManager_GetInstallConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockInstallConfigManager_GetInstallConfig_Call) Return(rootConfig *files.RootConfig) *MockInstallConfigManager_GetInstallConfig_Call { + _c.Call.Return(rootConfig) + return _c +} + +func (_c *MockInstallConfigManager_GetInstallConfig_Call) RunAndReturn(run func() *files.RootConfig) *MockInstallConfigManager_GetInstallConfig_Call { + _c.Call.Return(run) + return _c +} + +// LoadInstallConfigFromFile provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) LoadInstallConfigFromFile(configPath string) error { + ret := _mock.Called(configPath) + + if len(ret) == 0 { + panic("no return value specified for LoadInstallConfigFromFile") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(configPath) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_LoadInstallConfigFromFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadInstallConfigFromFile' +type MockInstallConfigManager_LoadInstallConfigFromFile_Call struct { + *mock.Call +} + +// LoadInstallConfigFromFile is a helper method to define mock.On call +// - configPath +func (_e *MockInstallConfigManager_Expecter) LoadInstallConfigFromFile(configPath interface{}) *MockInstallConfigManager_LoadInstallConfigFromFile_Call { + return &MockInstallConfigManager_LoadInstallConfigFromFile_Call{Call: _e.mock.On("LoadInstallConfigFromFile", configPath)} +} + +func (_c *MockInstallConfigManager_LoadInstallConfigFromFile_Call) Run(run func(configPath string)) *MockInstallConfigManager_LoadInstallConfigFromFile_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockInstallConfigManager_LoadInstallConfigFromFile_Call) Return(err error) *MockInstallConfigManager_LoadInstallConfigFromFile_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_LoadInstallConfigFromFile_Call) RunAndReturn(run func(configPath string) error) *MockInstallConfigManager_LoadInstallConfigFromFile_Call { + _c.Call.Return(run) + return _c +} + +// LoadVaultFromFile provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) LoadVaultFromFile(vaultPath string) error { + ret := _mock.Called(vaultPath) + + if len(ret) == 0 { + panic("no return value specified for LoadVaultFromFile") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string) error); ok { + r0 = returnFunc(vaultPath) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_LoadVaultFromFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadVaultFromFile' +type MockInstallConfigManager_LoadVaultFromFile_Call struct { + *mock.Call +} + +// LoadVaultFromFile is a helper method to define mock.On call +// - vaultPath +func (_e *MockInstallConfigManager_Expecter) LoadVaultFromFile(vaultPath interface{}) *MockInstallConfigManager_LoadVaultFromFile_Call { + return &MockInstallConfigManager_LoadVaultFromFile_Call{Call: _e.mock.On("LoadVaultFromFile", vaultPath)} +} + +func (_c *MockInstallConfigManager_LoadVaultFromFile_Call) Run(run func(vaultPath string)) *MockInstallConfigManager_LoadVaultFromFile_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockInstallConfigManager_LoadVaultFromFile_Call) Return(err error) *MockInstallConfigManager_LoadVaultFromFile_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_LoadVaultFromFile_Call) RunAndReturn(run func(vaultPath string) error) *MockInstallConfigManager_LoadVaultFromFile_Call { + _c.Call.Return(run) + return _c +} + +// ValidateInstallConfig provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) ValidateInstallConfig() []string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for ValidateInstallConfig") + } + + var r0 []string + if returnFunc, ok := ret.Get(0).(func() []string); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + return r0 +} + +// MockInstallConfigManager_ValidateInstallConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidateInstallConfig' +type MockInstallConfigManager_ValidateInstallConfig_Call struct { + *mock.Call +} + +// ValidateInstallConfig is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) ValidateInstallConfig() *MockInstallConfigManager_ValidateInstallConfig_Call { + return &MockInstallConfigManager_ValidateInstallConfig_Call{Call: _e.mock.On("ValidateInstallConfig")} +} + +func (_c *MockInstallConfigManager_ValidateInstallConfig_Call) Run(run func()) *MockInstallConfigManager_ValidateInstallConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockInstallConfigManager_ValidateInstallConfig_Call) Return(strings []string) *MockInstallConfigManager_ValidateInstallConfig_Call { + _c.Call.Return(strings) + return _c +} + +func (_c *MockInstallConfigManager_ValidateInstallConfig_Call) RunAndReturn(run func() []string) *MockInstallConfigManager_ValidateInstallConfig_Call { + _c.Call.Return(run) + return _c +} + +// ValidateVault provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) ValidateVault() []string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for ValidateVault") + } + + var r0 []string + if returnFunc, ok := ret.Get(0).(func() []string); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + return r0 +} + +// MockInstallConfigManager_ValidateVault_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ValidateVault' +type MockInstallConfigManager_ValidateVault_Call struct { + *mock.Call +} + +// ValidateVault is a helper method to define mock.On call +func (_e *MockInstallConfigManager_Expecter) ValidateVault() *MockInstallConfigManager_ValidateVault_Call { + return &MockInstallConfigManager_ValidateVault_Call{Call: _e.mock.On("ValidateVault")} +} + +func (_c *MockInstallConfigManager_ValidateVault_Call) Run(run func()) *MockInstallConfigManager_ValidateVault_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockInstallConfigManager_ValidateVault_Call) Return(strings []string) *MockInstallConfigManager_ValidateVault_Call { + _c.Call.Return(strings) + return _c +} + +func (_c *MockInstallConfigManager_ValidateVault_Call) RunAndReturn(run func() []string) *MockInstallConfigManager_ValidateVault_Call { + _c.Call.Return(run) + return _c +} + +// WriteInstallConfig provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) WriteInstallConfig(configPath string, withComments bool) error { + ret := _mock.Called(configPath, withComments) + + if len(ret) == 0 { + panic("no return value specified for WriteInstallConfig") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = returnFunc(configPath, withComments) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_WriteInstallConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteInstallConfig' +type MockInstallConfigManager_WriteInstallConfig_Call struct { + *mock.Call +} + +// WriteInstallConfig is a helper method to define mock.On call +// - configPath +// - withComments +func (_e *MockInstallConfigManager_Expecter) WriteInstallConfig(configPath interface{}, withComments interface{}) *MockInstallConfigManager_WriteInstallConfig_Call { + return &MockInstallConfigManager_WriteInstallConfig_Call{Call: _e.mock.On("WriteInstallConfig", configPath, withComments)} +} + +func (_c *MockInstallConfigManager_WriteInstallConfig_Call) Run(run func(configPath string, withComments bool)) *MockInstallConfigManager_WriteInstallConfig_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(bool)) + }) + return _c +} + +func (_c *MockInstallConfigManager_WriteInstallConfig_Call) Return(err error) *MockInstallConfigManager_WriteInstallConfig_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_WriteInstallConfig_Call) RunAndReturn(run func(configPath string, withComments bool) error) *MockInstallConfigManager_WriteInstallConfig_Call { + _c.Call.Return(run) + return _c +} + +// WriteVault provides a mock function for the type MockInstallConfigManager +func (_mock *MockInstallConfigManager) WriteVault(vaultPath string, withComments bool) error { + ret := _mock.Called(vaultPath, withComments) + + if len(ret) == 0 { + panic("no return value specified for WriteVault") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(string, bool) error); ok { + r0 = returnFunc(vaultPath, withComments) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockInstallConfigManager_WriteVault_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteVault' +type MockInstallConfigManager_WriteVault_Call struct { + *mock.Call +} + +// WriteVault is a helper method to define mock.On call +// - vaultPath +// - withComments +func (_e *MockInstallConfigManager_Expecter) WriteVault(vaultPath interface{}, withComments interface{}) *MockInstallConfigManager_WriteVault_Call { + return &MockInstallConfigManager_WriteVault_Call{Call: _e.mock.On("WriteVault", vaultPath, withComments)} +} + +func (_c *MockInstallConfigManager_WriteVault_Call) Run(run func(vaultPath string, withComments bool)) *MockInstallConfigManager_WriteVault_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(bool)) + }) + return _c +} + +func (_c *MockInstallConfigManager_WriteVault_Call) Return(err error) *MockInstallConfigManager_WriteVault_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockInstallConfigManager_WriteVault_Call) RunAndReturn(run func(vaultPath string, withComments bool) error) *MockInstallConfigManager_WriteVault_Call { + _c.Call.Return(run) + 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 { From b0b1aee5b7b994bb83d3d87a3af0fe9a008d0e0f Mon Sep 17 00:00:00 2001 From: siherrmann <25087590+siherrmann@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:02:12 +0000 Subject: [PATCH 20/20] chore(docs): Auto-update docs and licenses Signed-off-by: siherrmann <25087590+siherrmann@users.noreply.github.com> --- docs/oms-cli_install_k0s.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/oms-cli_install_k0s.md b/docs/oms-cli_install_k0s.md index 084631d7..e9c7fa06 100644 --- a/docs/oms-cli_install_k0s.md +++ b/docs/oms-cli_install_k0s.md @@ -27,7 +27,7 @@ $ oms-cli install k0s --version $ 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 --config +$ 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 @@ -37,11 +37,11 @@ $ oms-cli install k0s --force ### Options ``` - -c, --config string Path to k0s configuration file - -f, --force Force new download and installation - -h, --help help for k0s - -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 + -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